From d92272e554befa00d080dd648fdd3727cf981d8f Mon Sep 17 00:00:00 2001 From: Nils-Johan Gynther Date: Thu, 7 May 2026 13:57:41 +0200 Subject: [PATCH] feat: implement matchedVia tracking for receipt items and enhance user alias management --- NEXT_STEPS.md | 11 ++ .../dto/parsed-receipt-item.dto.ts | 4 + .../receipt-import.service.spec.ts | 37 ++++ .../receipt-import/receipt-import.service.ts | 6 +- .../presentation/admin_aliases_panel.dart | 22 ++- .../import/domain/parsed_receipt_item.dart | 5 + .../presentation/receipt_import_tab.dart | 35 +++- .../profile/presentation/profile_screen.dart | 13 ++ .../presentation/user_aliases_screen.dart | 162 ++++++++++++++++++ 9 files changed, 287 insertions(+), 8 deletions(-) create mode 100644 flutter/lib/features/profile/presentation/user_aliases_screen.dart diff --git a/NEXT_STEPS.md b/NEXT_STEPS.md index 502074f8..1b4f3294 100644 --- a/NEXT_STEPS.md +++ b/NEXT_STEPS.md @@ -42,6 +42,17 @@ All detaljhistorik och djup teknisk bakgrund finns i respektive tekniska dokumen ## Framtida förbättringsområden +### Säkerhet: httpOnly cookies för Flutter Web + +Idag lagras JWT-token i localStorage via SharedPreferences i Flutter Web. För att minska XSS-risk bör backend och Flutter Web stödja httpOnly-cookies för tokens. Detta kräver: +- Backend: endpoint för att sätta och läsa httpOnly-cookie vid login. +- Flutter Web: anpassning så att token inte läses från localStorage utan session hanteras via cookie. +Detta är en större arkitekturändring och endast relevant för webben. + +### Säkerhet: Gitea webhook-signaturvalidering + +Om Gitea-webhooks används, implementera endpoint i backend som validerar `X-Gitea-Signature` med timing-safe jämförelse. Lägg till `GITEA_WEBHOOK_SECRET` i .env.example. Se säkerhetshärdningsplanen för kodexempel. + ### Alternativa ingredienser — migrering till relationsmodell (Option B) Nuläge: `RecipeIngredient.alternativeProductIds` lagras som JSON-kolumn (Option A). diff --git a/backend/src/receipt-import/dto/parsed-receipt-item.dto.ts b/backend/src/receipt-import/dto/parsed-receipt-item.dto.ts index 222c841c..147db208 100644 --- a/backend/src/receipt-import/dto/parsed-receipt-item.dto.ts +++ b/backend/src/receipt-import/dto/parsed-receipt-item.dto.ts @@ -1,5 +1,7 @@ import type { CategorySuggestion } from '../../ai/ai.service'; +export type MatchedVia = 'alias' | 'wordmatch' | 'ai' | 'none'; + export interface ParsedReceiptItem { rawName: string; quantity: number; @@ -15,4 +17,6 @@ export interface ParsedReceiptItem { suggestedProductName?: string; // AI-kategorisuggestion för ej matchade varor (premium) categorySuggestion?: CategorySuggestion; + // matchkälla för UI-visning + matchedVia?: MatchedVia; } diff --git a/backend/src/receipt-import/receipt-import.service.spec.ts b/backend/src/receipt-import/receipt-import.service.spec.ts index eae1dbb4..10c74a45 100644 --- a/backend/src/receipt-import/receipt-import.service.spec.ts +++ b/backend/src/receipt-import/receipt-import.service.spec.ts @@ -257,4 +257,41 @@ describe('ReceiptImportService test matrix', () => { expect(result[0].unit).toBe('st'); }); }); + + describe('matchedVia', () => { + it('sätter matchedVia: alias vid aliasträff', async () => { + prismaMock.receiptAlias.findMany.mockResolvedValue([ + { + receiptName: 'snickers', + productId: 222, + product: { id: 222, name: 'Snickers', canonicalName: 'Snickers', categoryId: null, categoryRef: null }, + }, + ]); + prismaMock.product.findMany.mockResolvedValue([]); + + const result = await (service as any).matchProducts([{ rawName: 'SNICKERS' }], 10); + + expect(result[0].matchedVia).toBe('alias'); + }); + + it('sätter matchedVia: wordmatch vid ordbaserad matchning', async () => { + prismaMock.receiptAlias.findMany.mockResolvedValue([]); + prismaMock.product.findMany.mockResolvedValue([ + { id: 300, name: 'Mjolk', canonicalName: 'Mjolk', categoryId: null, categoryRef: null }, + ]); + + const result = await (service as any).matchProducts([{ rawName: 'MJOLK 1L' }], 10); + + expect(result[0].matchedVia).toBe('wordmatch'); + }); + + it('sätter matchedVia: none när ingen matchning finns', async () => { + prismaMock.receiptAlias.findMany.mockResolvedValue([]); + prismaMock.product.findMany.mockResolvedValue([]); + + const result = await (service as any).matchProducts([{ rawName: 'XYZXYZ' }], 10); + + expect(result[0].matchedVia).toBe('none'); + }); + }); }); diff --git a/backend/src/receipt-import/receipt-import.service.ts b/backend/src/receipt-import/receipt-import.service.ts index 7a068323..72fc297c 100644 --- a/backend/src/receipt-import/receipt-import.service.ts +++ b/backend/src/receipt-import/receipt-import.service.ts @@ -309,6 +309,7 @@ export class ReceiptImportService { matchedProductId: alias.product.id, matchedProductName: alias.product.canonicalName ?? alias.product.name, unit: mappedUnit ?? item.unit, + matchedVia: 'alias' as const, ...(cat ? { categorySuggestion: { categoryId: cat.id, categoryName: cat.name, path: cat.name, confidence: 'high' as const, usedFallback: false } } : {}), }; } @@ -316,7 +317,7 @@ export class ReceiptImportService { // 2. Ordbaserad matchning (förslag, kräver bekräftelse) const suggestion = this.findWordMatch(raw, products); if (!suggestion) { - return { ...item }; + return { ...item, matchedVia: 'none' as const }; } // Kontrollera om det finns en enhetsmappning för produkten och användaren @@ -333,6 +334,7 @@ export class ReceiptImportService { suggestedProductId: suggestion.id, suggestedProductName: suggestion.canonicalName ?? suggestion.name, unit: preferredUnit, + matchedVia: 'wordmatch' as const, ...(cat ? { categorySuggestion: { categoryId: cat.id, categoryName: cat.name, path: cat.name, confidence: 'medium' as const, usedFallback: false } } : {}), }; }); @@ -547,7 +549,7 @@ export class ReceiptImportService { enriched.push( finalSuggestion - ? { ...item, categorySuggestion: finalSuggestion } + ? { ...item, categorySuggestion: finalSuggestion, matchedVia: item.matchedVia ?? (finalSuggestion ? 'ai' as const : 'none' as const) } : item, ); } catch (err) { diff --git a/flutter/lib/features/admin/presentation/admin_aliases_panel.dart b/flutter/lib/features/admin/presentation/admin_aliases_panel.dart index 5309585a..6c65e338 100644 --- a/flutter/lib/features/admin/presentation/admin_aliases_panel.dart +++ b/flutter/lib/features/admin/presentation/admin_aliases_panel.dart @@ -236,12 +236,30 @@ class _AdminAliasesPanelState extends ConsumerState { ...filteredAliases.map( (alias) => Card( child: ListTile( - title: Text(alias.receiptName), - subtitle: Text('Produkt: ${alias.displayProductName}'), + leading: const Icon(Icons.link_outlined), + title: Text(alias.receiptName, style: const TextStyle(fontWeight: FontWeight.w500)), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '→ ${alias.displayProductName}', + style: const TextStyle(fontWeight: FontWeight.w400), + ), + Text( + 'Produkt-ID: ${alias.productId}', + style: TextStyle( + fontSize: 11, + color: Theme.of(context).colorScheme.outline, + ), + ), + ], + ), trailing: IconButton( onPressed: () => _removeAlias(alias), icon: const Icon(Icons.delete_outline), tooltip: 'Ta bort alias', + color: Theme.of(context).colorScheme.error, ), ), ), diff --git a/flutter/lib/features/import/domain/parsed_receipt_item.dart b/flutter/lib/features/import/domain/parsed_receipt_item.dart index bd3ddaaa..ed5f3a8d 100644 --- a/flutter/lib/features/import/domain/parsed_receipt_item.dart +++ b/flutter/lib/features/import/domain/parsed_receipt_item.dart @@ -16,6 +16,8 @@ class ParsedReceiptItem { final String? categorySuggestionName; final String? categorySuggestionPath; final int? categorySuggestionId; + // matchkälla för UI-visning: 'alias' | 'wordmatch' | 'ai' | 'none' + final String? matchedVia; ParsedReceiptItem({ required this.rawName, @@ -31,6 +33,7 @@ class ParsedReceiptItem { this.categorySuggestionName, this.categorySuggestionPath, this.categorySuggestionId, + this.matchedVia, }); factory ParsedReceiptItem.fromJson(Map json) { @@ -49,6 +52,7 @@ class ParsedReceiptItem { categorySuggestionName: cat?['categoryName'] as String?, categorySuggestionPath: cat?['path'] as String?, categorySuggestionId: (cat?['categoryId'] as num?)?.toInt(), + matchedVia: json['matchedVia'] as String?, ); } @@ -72,6 +76,7 @@ class ParsedReceiptItem { 'categoryName': categorySuggestionName, 'path': categorySuggestionPath, }, + if (matchedVia != null) 'matchedVia': matchedVia, }; } } diff --git a/flutter/lib/features/import/presentation/receipt_import_tab.dart b/flutter/lib/features/import/presentation/receipt_import_tab.dart index 93112f45..c868798b 100644 --- a/flutter/lib/features/import/presentation/receipt_import_tab.dart +++ b/flutter/lib/features/import/presentation/receipt_import_tab.dart @@ -540,15 +540,16 @@ class _ReceiptImportTabState extends ConsumerState { } final normalizedReceiptName = item.rawName.trim().toLowerCase(); - final shouldLearnAlias = - canManageAliases && - normalizedReceiptName.isNotEmpty && - item.matchedProductId != pid; + // Spara alias för alla användare (user-scope) när raden inte redan matchades via alias, + // eller admin sparar global alias. + final alreadyAliasMatch = item.matchedVia == 'alias' && item.matchedProductId == pid; + final shouldLearnAlias = normalizedReceiptName.isNotEmpty && !alreadyAliasMatch; if (shouldLearnAlias) { try { await adminRepo.upsertReceiptAlias( receiptName: normalizedReceiptName, productId: pid, + isGlobal: canManageAliases, ); aliasesLearned++; } catch (e, st) { @@ -644,6 +645,31 @@ class _ReceiptImportTabState extends ConsumerState { ); } + Widget _buildMatchedViaBadge(ParsedReceiptItem item, ThemeData theme) { + final via = item.matchedVia; + if (via == null || via == 'none') return const SizedBox.shrink(); + + final (label, bg, fg) = switch (via) { + 'alias' => ('Alias', Colors.teal.shade50, Colors.teal.shade800), + 'wordmatch' => ('Ordmatch', Colors.blue.shade50, Colors.blue.shade800), + 'ai' => ('AI-kategori', Colors.purple.shade50, Colors.purple.shade800), + _ => ('Matchad', theme.colorScheme.surfaceContainerHighest, theme.colorScheme.onSurfaceVariant), + }; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2), + decoration: BoxDecoration( + color: bg, + borderRadius: BorderRadius.circular(999), + border: Border.all(color: fg.withOpacity(0.3)), + ), + child: Text( + label, + style: theme.textTheme.labelSmall?.copyWith(color: fg, fontWeight: FontWeight.w600), + ), + ); + } + @override Widget build(BuildContext context) { final session = ref.watch(receiptImportSessionProvider); @@ -797,6 +823,7 @@ class _ReceiptImportTabState extends ConsumerState { fontWeight: FontWeight.w500, ), ), + _buildMatchedViaBadge(item, theme), if (edit.categorySource != null) Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), diff --git a/flutter/lib/features/profile/presentation/profile_screen.dart b/flutter/lib/features/profile/presentation/profile_screen.dart index e0e9bcd3..613212b6 100644 --- a/flutter/lib/features/profile/presentation/profile_screen.dart +++ b/flutter/lib/features/profile/presentation/profile_screen.dart @@ -7,6 +7,7 @@ import '../../../core/l10n/l10n.dart'; import '../../auth/data/auth_providers.dart'; import '../data/profile_repository.dart'; import '../domain/user_profile.dart'; +import 'user_aliases_screen.dart'; enum _ProfileTab { profile } @@ -276,6 +277,18 @@ class _ProfileScreenState extends ConsumerState { const SizedBox(height: 16), _buildActiveTabContent(context, theme), const SizedBox(height: 24), + ListTile( + leading: const Icon(Icons.link_outlined), + title: const Text('Mina kvittoalias'), + subtitle: const Text('Visa och hantera sparade alias från kvittoimport'), + trailing: const Icon(Icons.chevron_right), + onTap: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const UserAliasesScreen()), + ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + tileColor: Theme.of(context).colorScheme.surfaceContainerHighest.withOpacity(0.4), + ), + const SizedBox(height: 24), SizedBox( width: double.infinity, child: OutlinedButton.icon( diff --git a/flutter/lib/features/profile/presentation/user_aliases_screen.dart b/flutter/lib/features/profile/presentation/user_aliases_screen.dart new file mode 100644 index 00000000..e0a5e90a --- /dev/null +++ b/flutter/lib/features/profile/presentation/user_aliases_screen.dart @@ -0,0 +1,162 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../admin/data/admin_repository.dart'; +import '../../admin/domain/receipt_alias.dart'; +import '../../auth/data/auth_providers.dart'; + +class UserAliasesScreen extends ConsumerStatefulWidget { + const UserAliasesScreen({super.key}); + + @override + ConsumerState createState() => _UserAliasesScreenState(); +} + +class _UserAliasesScreenState extends ConsumerState { + List _aliases = []; + bool _isLoading = true; + String? _error; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + setState(() { + _isLoading = true; + _error = null; + }); + try { + final aliases = await ref.read(adminRepositoryProvider).listReceiptAliases(); + if (!mounted) return; + setState(() { + _aliases = aliases; + }); + } catch (e) { + if (!mounted) return; + setState(() => _error = 'Kunde inte ladda alias: $e'); + } finally { + if (mounted) setState(() => _isLoading = false); + } + } + + Future _delete(ReceiptAlias alias) async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Ta bort alias'), + content: Text('Ta bort alias "${alias.receiptName}" → ${alias.displayProductName}?'), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Avbryt')), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + child: const Text('Ta bort'), + ), + ], + ), + ); + if (confirmed != true || !mounted) return; + try { + await ref.read(adminRepositoryProvider).removeReceiptAlias(alias.id); + await _load(); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Alias borttaget.')), + ); + } + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Kunde inte ta bort alias: $e')), + ); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Scaffold( + appBar: AppBar( + title: const Text('Mina kvittoalias'), + actions: [ + IconButton( + onPressed: _load, + icon: const Icon(Icons.refresh), + tooltip: 'Uppdatera', + ), + ], + ), + body: Builder(builder: (_) { + if (_isLoading) return const Center(child: CircularProgressIndicator()); + if (_error != null) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(_error!, style: TextStyle(color: theme.colorScheme.error)), + const SizedBox(height: 12), + FilledButton(onPressed: _load, child: const Text('Försök igen')), + ], + ), + ); + } + if (_aliases.isEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.link_off_outlined, size: 48, color: theme.colorScheme.outlineVariant), + const SizedBox(height: 16), + Text( + 'Inga alias sparade ännu.', + style: theme.textTheme.bodyLarge, + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + 'Alias skapas automatiskt när du sparar kvittorader i inventariet.', + style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } + return ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: _aliases.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (ctx, i) { + final alias = _aliases[i]; + return ListTile( + leading: Icon( + Icons.link_outlined, + color: theme.colorScheme.primary, + ), + title: Text( + alias.receiptName, + style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w500), + ), + subtitle: Text( + '→ ${alias.displayProductName}', + style: theme.textTheme.bodySmall, + ), + trailing: IconButton( + icon: const Icon(Icons.delete_outline), + tooltip: 'Ta bort alias', + color: theme.colorScheme.error, + onPressed: () => _delete(alias), + ), + ); + }, + ); + }), + ); + } +}