From 3d9b1247662e2707781d6e91c98e9b9d5dcb340b Mon Sep 17 00:00:00 2001 From: Nils-Johan Gynther Date: Wed, 13 May 2026 16:20:04 +0200 Subject: [PATCH] feat: add HelpText model, service, and controller for dynamic help text management --- .../migration.sql | 35 +++++++ backend/prisma/schema.prisma | 14 +++ backend/src/app.module.ts | 2 + .../help-texts/dto/upsert-help-text.dto.ts | 14 +++ .../src/help-texts/help-texts.controller.ts | 28 ++++++ backend/src/help-texts/help-texts.module.ts | 11 +++ backend/src/help-texts/help-texts.service.ts | 99 +++++++++++++++++++ flutter/lib/core/api/api_paths.dart | 4 + .../import/data/import_repository.dart | 33 +++++++ .../import/domain/help_text_content.dart | 27 +++++ .../presentation/receipt_import_tab.dart | 85 +++++++++++++++- 11 files changed, 349 insertions(+), 3 deletions(-) create mode 100644 backend/prisma/migrations/20260513150000_add_help_texts/migration.sql create mode 100644 backend/src/help-texts/dto/upsert-help-text.dto.ts create mode 100644 backend/src/help-texts/help-texts.controller.ts create mode 100644 backend/src/help-texts/help-texts.module.ts create mode 100644 backend/src/help-texts/help-texts.service.ts create mode 100644 flutter/lib/features/import/domain/help_text_content.dart diff --git a/backend/prisma/migrations/20260513150000_add_help_texts/migration.sql b/backend/prisma/migrations/20260513150000_add_help_texts/migration.sql new file mode 100644 index 00000000..b76af15d --- /dev/null +++ b/backend/prisma/migrations/20260513150000_add_help_texts/migration.sql @@ -0,0 +1,35 @@ +CREATE TABLE `HelpText` ( + `id` INTEGER NOT NULL AUTO_INCREMENT, + `key` VARCHAR(191) NOT NULL, + `scope` VARCHAR(191) NOT NULL DEFAULT 'default', + `title` VARCHAR(191) NOT NULL, + `content` TEXT NOT NULL, + `isActive` BOOLEAN NOT NULL DEFAULT true, + `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updatedAt` DATETIME(3) NOT NULL, + + UNIQUE INDEX `HelpText_key_scope_key`(`key`, `scope`), + INDEX `HelpText_key_isActive_idx`(`key`, `isActive`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +INSERT INTO `HelpText` (`key`, `scope`, `title`, `content`, `isActive`, `createdAt`, `updatedAt`) +VALUES +( + 'receipt_import', + 'default', + 'Hjälp: Kvittoimport', + 'Kvittoimporten hjälper dig att tolka kvitton och lägga till varor i inventarie eller baslager.\n\nSteg:\n1. Ladda upp PDF eller bild.\n2. Granska raderna och justera produkt, mängd och enhet vid behov.\n3. Välj destination (inventarie eller baslager).\n4. Spara markerade rader.\n\nTips:\n- Om en rad är osäker, redigera innan du sparar.\n- Du kan lära in alias för bättre träffar nästa gång.', + true, + NOW(3), + NOW(3) +), +( + 'receipt_import', + 'admin', + 'Hjälp: Kvittoimport för administratörer', + 'Kvittoimporten hjälper dig att läsa in kvitton och omvandla rader till produkter i inventarie eller baslager. Som administratör har du utökade möjligheter att förbättra träffsäkerheten för hela systemet.\n\nSå fungerar flödet:\n1. Ladda upp kvitto som PDF eller bild.\n2. Systemet tolkar raderna och föreslår produktmatchning, mängd och enhet.\n3. Granska varje rad innan du sparar.\n4. Välj destination: Inventarie eller Baslager.\n5. Spara valda rader.\n\nMatchning och förslag:\n- Alias-träff: raden matchar mot inlärda alias.\n- Ordbaserad träff: systemet hittar sannolik produkt, men du bör bekräfta.\n- AI-kategoriförslag: visas som stöd när produkt inte matchas direkt.\n\nDet du kan göra per rad:\n- Byta till annan befintlig produkt.\n- Skapa ny produkt om ingen passande finns.\n- Justera mängd, enhet och paketinformation.\n- Välja kategori manuellt vid behov.\n- Markera om alias ska läras in.\n\nAdmin-funktioner i kvittoimport:\n- Du kan spara globala alias som blir fallback för alla användare.\n- Du kan använda privata alias för egna avvikelser.\n- Du kan efter import gå vidare till admin-vyer för att städa dubbletter och kvalitetssäkra data.\n\nRekommenderat arbetssätt:\n- Kontrollera rader med låg säkerhet först.\n- Skapa globala alias bara för stabila och återkommande kvittonamn.\n- Undvik att skapa för många nästan-identiska produkter.', + true, + NOW(3), + NOW(3) +); \ No newline at end of file diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index b66443f6..1928c89b 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -263,3 +263,17 @@ model UnitMapping { @@index([productId]) @@index([userId]) } + +model HelpText { + id Int @id @default(autoincrement()) + key String + scope String @default("default") + title String + content String @db.Text + isActive Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([key, scope]) + @@index([key, isActive]) +} diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 469ddbf1..e275c85a 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -17,6 +17,7 @@ import { UserProductsModule } from './user-products/user-products.module'; import { CategoriesModule } from './categories/categories.module'; import { AiModule } from './ai/ai.module'; import { RealtimeModule } from './realtime/realtime.module'; +import { HelpTextsModule } from './help-texts/help-texts.module'; import { JwtAuthGuard } from './auth/jwt-auth.guard'; import { RolesGuard } from './auth/roles.guard'; @@ -46,6 +47,7 @@ import { RolesGuard } from './auth/roles.guard'; CategoriesModule, AiModule, RealtimeModule, + HelpTextsModule, ], providers: [ { diff --git a/backend/src/help-texts/dto/upsert-help-text.dto.ts b/backend/src/help-texts/dto/upsert-help-text.dto.ts new file mode 100644 index 00000000..70afbb80 --- /dev/null +++ b/backend/src/help-texts/dto/upsert-help-text.dto.ts @@ -0,0 +1,14 @@ +import { IsBoolean, IsOptional, IsString, MaxLength } from 'class-validator'; + +export class UpsertHelpTextDto { + @IsString() + @MaxLength(120) + title!: string; + + @IsString() + content!: string; + + @IsOptional() + @IsBoolean() + isActive?: boolean; +} diff --git a/backend/src/help-texts/help-texts.controller.ts b/backend/src/help-texts/help-texts.controller.ts new file mode 100644 index 00000000..51bdaf68 --- /dev/null +++ b/backend/src/help-texts/help-texts.controller.ts @@ -0,0 +1,28 @@ +import { Body, Controller, Get, Param, Put } from '@nestjs/common'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { Roles } from '../auth/decorators/roles.decorator'; +import { UpsertHelpTextDto } from './dto/upsert-help-text.dto'; +import { HelpTextsService } from './help-texts.service'; + +@Controller('help-texts') +export class HelpTextsController { + constructor(private readonly helpTextsService: HelpTextsService) {} + + @Get(':key') + getByKey( + @Param('key') key: string, + @CurrentUser() user: { role?: string }, + ) { + return this.helpTextsService.getResolvedByKey(key, user?.role); + } + + @Roles('admin') + @Put(':key/:scope') + upsert( + @Param('key') key: string, + @Param('scope') scope: string, + @Body() dto: UpsertHelpTextDto, + ) { + return this.helpTextsService.upsert(key, scope, dto); + } +} diff --git a/backend/src/help-texts/help-texts.module.ts b/backend/src/help-texts/help-texts.module.ts new file mode 100644 index 00000000..82ce30ea --- /dev/null +++ b/backend/src/help-texts/help-texts.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { PrismaModule } from '../prisma/prisma.module'; +import { HelpTextsController } from './help-texts.controller'; +import { HelpTextsService } from './help-texts.service'; + +@Module({ + imports: [PrismaModule], + controllers: [HelpTextsController], + providers: [HelpTextsService], +}) +export class HelpTextsModule {} diff --git a/backend/src/help-texts/help-texts.service.ts b/backend/src/help-texts/help-texts.service.ts new file mode 100644 index 00000000..86866fc7 --- /dev/null +++ b/backend/src/help-texts/help-texts.service.ts @@ -0,0 +1,99 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { UpsertHelpTextDto } from './dto/upsert-help-text.dto'; + +type HelpTextScope = 'default' | 'user' | 'admin'; + +@Injectable() +export class HelpTextsService { + private readonly allowedScopes: HelpTextScope[] = ['default', 'user', 'admin']; + + constructor(private readonly prisma: PrismaService) {} + + async getResolvedByKey(keyRaw: string, roleRaw?: string) { + const key = this.normalizeKey(keyRaw); + const role = (roleRaw ?? 'user').toLowerCase(); + const scopePriority: HelpTextScope[] = role === 'admin' + ? ['admin', 'user', 'default'] + : ['user', 'default']; + + const rows = await this.prisma.helpText.findMany({ + where: { + key, + isActive: true, + scope: { in: scopePriority }, + }, + select: { + key: true, + scope: true, + title: true, + content: true, + updatedAt: true, + }, + }); + + for (const scope of scopePriority) { + const hit = rows.find((row) => row.scope === scope); + if (hit) { + return { + 'key': hit.key, + 'scope': hit.scope, + 'title': hit.title, + 'content': hit.content, + 'updatedAt': hit.updatedAt, + }; + } + } + + throw new NotFoundException(`Ingen aktiv hjälptext hittades för key '${key}'.`); + } + + async upsert(keyRaw: string, scopeRaw: string, dto: UpsertHelpTextDto) { + const key = this.normalizeKey(keyRaw); + const scope = this.normalizeScope(scopeRaw); + + return this.prisma.helpText.upsert({ + where: { + key_scope: { key, scope }, + }, + update: { + title: dto.title.trim(), + content: dto.content.trim(), + isActive: dto.isActive ?? true, + }, + create: { + key, + scope, + title: dto.title.trim(), + content: dto.content.trim(), + isActive: dto.isActive ?? true, + }, + select: { + key: true, + scope: true, + title: true, + content: true, + isActive: true, + updatedAt: true, + }, + }); + } + + private normalizeKey(value: string): string { + const normalized = value.trim().toLowerCase(); + if (!normalized) { + throw new BadRequestException('Hjälptext-nyckel måste anges.'); + } + return normalized; + } + + private normalizeScope(value: string): HelpTextScope { + const normalized = value.trim().toLowerCase() as HelpTextScope; + if (!this.allowedScopes.includes(normalized)) { + throw new BadRequestException( + `Ogiltig scope '${value}'. Tillåtna scopes: ${this.allowedScopes.join(', ')}`, + ); + } + return normalized; + } +} diff --git a/flutter/lib/core/api/api_paths.dart b/flutter/lib/core/api/api_paths.dart index 443f387a..8be3ec7b 100644 --- a/flutter/lib/core/api/api_paths.dart +++ b/flutter/lib/core/api/api_paths.dart @@ -38,6 +38,10 @@ class ReceiptImportApiPaths { static const unitMappings = '/receipt-import/unit-mappings'; } +class HelpTextApiPaths { + static String byKey(String key) => '/help-texts/${Uri.encodeComponent(key)}'; +} + class ReceiptAliasApiPaths { static const list = '/receipt-aliases'; static String update(int id) => '/receipt-aliases/$id'; diff --git a/flutter/lib/features/import/data/import_repository.dart b/flutter/lib/features/import/data/import_repository.dart index 06acad1e..3cff40d6 100644 --- a/flutter/lib/features/import/data/import_repository.dart +++ b/flutter/lib/features/import/data/import_repository.dart @@ -7,6 +7,7 @@ import 'dart:developer' as developer; import '../../../core/api/api_paths.dart'; import '../../../core/api/api_exception.dart'; +import '../domain/help_text_content.dart'; import '../domain/quick_import_result.dart'; /// Handles communication with the quick-import API endpoint. @@ -25,6 +26,38 @@ class ImportRepository { defaultValue: '/api', ); + Future fetchHelpTextByKey( + String key, { + String? token, + }) async { + final uri = Uri.parse('$_baseUrl${HelpTextApiPaths.byKey(key)}'); + final response = await _client.get( + uri, + headers: { + 'Content-Type': 'application/json', + if (token != null) 'Authorization': 'Bearer $token', + }, + ); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw ApiException( + type: _mapStatusCodeToErrorType(response.statusCode), + message: 'Kunde inte hämta hjälptext: ${response.body}', + statusCode: response.statusCode, + ); + } + + final parsed = _parseResponse(response); + if (parsed is! Map) { + throw ApiException( + type: ApiErrorType.unknown, + message: 'Felaktigt svar vid hämtning av hjälptext.', + ); + } + + return HelpTextContent.fromJson(parsed); + } + /// Upload a receipt file for parsing (Fas 6b). /// Returns a list of parsed receipt items. Future> importReceiptFile({ diff --git a/flutter/lib/features/import/domain/help_text_content.dart b/flutter/lib/features/import/domain/help_text_content.dart new file mode 100644 index 00000000..d3a3f819 --- /dev/null +++ b/flutter/lib/features/import/domain/help_text_content.dart @@ -0,0 +1,27 @@ +class HelpTextContent { + final String key; + final String scope; + final String title; + final String content; + final DateTime? updatedAt; + + const HelpTextContent({ + required this.key, + required this.scope, + required this.title, + required this.content, + this.updatedAt, + }); + + factory HelpTextContent.fromJson(Map json) { + return HelpTextContent( + key: (json['key'] as String? ?? '').trim(), + scope: (json['scope'] as String? ?? 'default').trim(), + title: (json['title'] as String? ?? '').trim(), + content: (json['content'] as String? ?? '').trim(), + updatedAt: json['updatedAt'] is String + ? DateTime.tryParse(json['updatedAt'] as String) + : null, + ); + } +} diff --git a/flutter/lib/features/import/presentation/receipt_import_tab.dart b/flutter/lib/features/import/presentation/receipt_import_tab.dart index f9794360..a3be6b17 100644 --- a/flutter/lib/features/import/presentation/receipt_import_tab.dart +++ b/flutter/lib/features/import/presentation/receipt_import_tab.dart @@ -14,6 +14,7 @@ import '../../pantry/data/pantry_providers.dart'; import '../../pantry/domain/pantry_item.dart'; import '../data/import_providers.dart'; import '../data/receipt_import_session.dart'; +import '../domain/help_text_content.dart'; import '../domain/parsed_receipt_item.dart'; import '../../../core/ui/product_picker_field.dart' show ProductOption; import '../utils/receipt_import_utils.dart'; @@ -37,6 +38,7 @@ class ReceiptImportTab extends ConsumerStatefulWidget { class _ReceiptImportTabState extends ConsumerState { bool _isLoading = false; bool _isSaving = false; + bool _isHelpLoading = false; PlatformFile? _pickedFile; bool _categoryLoadFailed = false; bool _globalProductsLoadFailed = false; @@ -460,6 +462,64 @@ class _ReceiptImportTabState extends ConsumerState { ); } + Future _showHelpForReceiptImport() async { + if (_isHelpLoading) return; + setState(() => _isHelpLoading = true); + try { + final token = await ref.read(authStateProvider.future); + final repo = ref.read(importRepositoryProvider); + final help = await repo.fetchHelpTextByKey('receipt_import', token: token); + if (!mounted) return; + await _showHelpDialog(help); + } catch (e) { + if (!mounted) return; + showGlobalErrorDialog(context, 'Kunde inte läsa hjälptexten just nu: $e'); + } finally { + if (mounted) setState(() => _isHelpLoading = false); + } + } + + Future _showHelpDialog(HelpTextContent help) { + final updatedAt = help.updatedAt; + final updatedAtText = updatedAt == null + ? null + : '${updatedAt.year.toString().padLeft(4, '0')}-${updatedAt.month.toString().padLeft(2, '0')}-${updatedAt.day.toString().padLeft(2, '0')}'; + + return showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(help.title.isEmpty ? 'Hjälp: Kvittoimport' : help.title), + content: SizedBox( + width: 560, + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + SelectableText(help.content), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + Chip(label: Text('Scope: ${help.scope}')), + if (updatedAtText != null) Chip(label: Text('Uppdaterad: $updatedAtText')), + ], + ), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(), + child: const Text('Stäng'), + ), + ], + ), + ); + } + void _deleteItem(int index) { final items = _items; if (items == null || index < 0 || index >= items.length) return; @@ -819,9 +879,28 @@ class _ReceiptImportTabState extends ConsumerState { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - 'Ladda upp ett kvitto (PDF eller bild) — raderna tolkas och kan läggas till i ditt inventarie.', - style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + 'Ladda upp ett kvitto (PDF eller bild) — raderna tolkas och kan läggas till i ditt inventarie.', + style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant), + ), + ), + const SizedBox(width: 8), + TextButton.icon( + onPressed: _isHelpLoading ? null : _showHelpForReceiptImport, + icon: _isHelpLoading + ? const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.help_outline), + label: const Text('Läs hjälp'), + ), + ], ), const SizedBox(height: 20), OutlinedButton.icon(