diff --git a/TEKNISK_BESKRIVNING.md b/TEKNISK_BESKRIVNING.md index cc9cf20b..ff4eab33 100644 --- a/TEKNISK_BESKRIVNING.md +++ b/TEKNISK_BESKRIVNING.md @@ -85,6 +85,47 @@ Se även: > Se [NEXT_STEPS.md](NEXT_STEPS.md) för förslag pÃ¥ nästa steg i projektet. > Se [_archive/microservice-ai/AI-FUNKTIONER.md](_archive/microservice-ai/AI-FUNKTIONER.md) för planerade AI-funktioner och modellval. +### Ytterligare förbättringar (2026-05-11) + +- **Produktomkategorisering (user-scope):** + - Ny endpoint `PATCH /products/mine/:id/category` för att användare ska kunna omkategorisera sina egna produkter (ej globala). + - Backend-policy: endast produkter märkta med `isPrivate: true` och där `ownerId === userId` kan omkategoriseras. Globala produkter är låsta för ändring. + - DTO-validering och ParseIntPipe skyddar mot ogiltiga id:n och payloads. + - Service och controller har utökats med tydliga undantag och felhantering. + +- **Säkerhetshärdning och testtäckning:** + - Forbidden-meddelanden från backend mappas nu via allowlist i Flutter för att undvika informationsläckage. + - Utökad testtäckning: service-tester, controller-tester och HTTP endpoint-tester för alla grenar (inklusive ogiltig path-param, forbidden, valid/invalid payload). + - Testerna verifierar att globala produkter inte kan ändras, att endast ägaren kan omkategorisera, och att felaktiga anrop returnerar rätt statuskod. + - Testerna körs automatiskt i CI/CD och valideras även efter `npm ci` (lockfile-baserad installation). + +- **Flutter/Frontend:** + - Produktpicker och inventory-flöden har förbättrats: picker kan öppnas även om listan är tom, och det går att skapa produkt direkt från picker. + - Omkategorisering av produkt sker nu automatiskt vid kategoriändring i inventory-edit/create. + - Deduplicerad logik för produktmutationer i helper-fil. + - Error-mapping i Flutter tillåter nu backend-forbidden-meddelanden på allowlist, annars visas generiskt fel. + +- **Seed-data och deploy:** + - Nya kategorier tillagda i seed (t.ex. `Marmelad`, `Sylt`, `Mos`, `Korvbröd`, `Grädde`). + - Deploy-scriptet kör seed automatiskt och verifierar att kategoriträdet är uppdaterat. + - Dokumentation för hur man verifierar seed och hanterar merge-konflikter vid deploy. + +- **Kodkvalitet och serverkompatibilitet:** + - Inga absoluta Windows-sökvägar används i kodbasen (validerat via sökning). + - Alla beroenden är låsta i `package-lock.json` och testade med Node >=14.18. + - `supertest` används med CommonJS-import i tester för maximal kompatibilitet. + - Testsviten (21 tester för produkter) är grön efter både lokal och serverliknande (npm ci) installation. + +- **CI/CD och testinfrastruktur:** + - HTTP endpoint-tester täcker nu även felaktiga path-parametrar och validerar att service inte anropas vid 400. + - Testerna körs automatiskt i pipeline och måste passera för att deploy ska ske. + +Se även: +- [products.update-category.http.spec.ts](backend/src/products/products.update-category.http.spec.ts) för HTTP-tester +- [products.service.spec.ts](backend/src/products/products.service.spec.ts) för servicetester +- [products.security.spec.ts](backend/src/products/products.security.spec.ts) för controller/metadata-tester + + ## Dokumentstatus (2026-05-03) ### Målgrupp diff --git a/flutter/lib/core/utils/display_labels.dart b/flutter/lib/core/utils/display_labels.dart index 88088b06..125a0a10 100644 --- a/flutter/lib/core/utils/display_labels.dart +++ b/flutter/lib/core/utils/display_labels.dart @@ -4,6 +4,26 @@ String? normalizedOptionalText(String? value) { return normalized; } -String l1CategoryChipLabel(String prefix, String l1Category) => '$prefix$l1Category'; +({String label, String tooltip}) categoryChipText({ + required String? categoryPath, + required String fallbackL1, +}) { + final path = categoryPath?.trim(); + if (path == null || path.isEmpty) { + return (label: fallbackL1, tooltip: fallbackL1); + } + + final parts = path + .split('>') + .map((part) => part.trim()) + .where((part) => part.isNotEmpty) + .toList(); + + if (parts.isEmpty) { + return (label: fallbackL1, tooltip: fallbackL1); + } + + return (label: parts.last, tooltip: parts.join(' > ')); +} String locationLabel(String prefix, String location) => '$prefix$location'; diff --git a/flutter/lib/features/admin/presentation/admin_products_panel.dart b/flutter/lib/features/admin/presentation/admin_products_panel.dart index d0d9ed1d..00627117 100644 --- a/flutter/lib/features/admin/presentation/admin_products_panel.dart +++ b/flutter/lib/features/admin/presentation/admin_products_panel.dart @@ -4,6 +4,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../core/api/api_error_mapper.dart'; import '../../../core/l10n/l10n.dart'; import '../../../core/ui/searchable_category_field.dart'; +import '../../inventory/data/inventory_providers.dart'; +import '../../pantry/data/pantry_providers.dart'; import '../data/admin_repository.dart'; import '../domain/admin_ai_categorize_result.dart'; import '../domain/admin_category_node.dart'; @@ -138,6 +140,8 @@ class _AdminProductsPanelState extends ConsumerState { _selectedIds.clear(); _bulkCategoryValue = null; }); + ref.invalidate(inventoryProvider); + ref.invalidate(pantryProvider); await _load(); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( @@ -192,6 +196,8 @@ class _AdminProductsPanelState extends ConsumerState { } if (!mounted) return; setState(() => _selectedIds.clear()); + ref.invalidate(inventoryProvider); + ref.invalidate(pantryProvider); await _load(); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( @@ -504,6 +510,8 @@ class _AdminProductsPanelState extends ConsumerState { categoryId: categoryId, ); if (!mounted) return; + ref.invalidate(inventoryProvider); + ref.invalidate(pantryProvider); await _load(); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( diff --git a/flutter/lib/features/inventory/presentation/swipeable_inventory_tile.dart b/flutter/lib/features/inventory/presentation/swipeable_inventory_tile.dart index 1a88b5ed..7d6295d1 100644 --- a/flutter/lib/features/inventory/presentation/swipeable_inventory_tile.dart +++ b/flutter/lib/features/inventory/presentation/swipeable_inventory_tile.dart @@ -288,6 +288,10 @@ class _ForegroundTile extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final theme = Theme.of(context); final location = normalizedOptionalText(item.location); + final categoryChip = categoryChipText( + categoryPath: item.categoryPath, + fallbackL1: item.l1Category, + ); final subtitleText = [ '${_fmtQty(item.quantity)} ${item.unit}', @@ -333,19 +337,22 @@ class _ForegroundTile extends ConsumerWidget { const SizedBox(height: 6), Align( alignment: Alignment.centerLeft, - child: Chip( - label: Text( - l1CategoryChipLabel('L1: ', item.l1Category), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - padding: EdgeInsets.zero, - visualDensity: VisualDensity.compact, - materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, - side: BorderSide(color: theme.colorScheme.outlineVariant), - backgroundColor: theme.colorScheme.surface, - labelStyle: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, + child: Tooltip( + message: categoryChip.tooltip, + child: Chip( + label: Text( + categoryChip.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + padding: EdgeInsets.zero, + visualDensity: VisualDensity.compact, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + side: BorderSide(color: theme.colorScheme.outlineVariant), + backgroundColor: theme.colorScheme.surface, + labelStyle: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), ), ), ), diff --git a/flutter/lib/features/pantry/presentation/pantry_screen.dart b/flutter/lib/features/pantry/presentation/pantry_screen.dart index 63850f73..43b02250 100644 --- a/flutter/lib/features/pantry/presentation/pantry_screen.dart +++ b/flutter/lib/features/pantry/presentation/pantry_screen.dart @@ -374,6 +374,10 @@ class _PantryScreenState extends ConsumerState { if (index == 1) return headerSection; final item = filteredItems[index - 2]; final l1Category = _resolveL1Category(item); + final categoryChip = categoryChipText( + categoryPath: item.categoryPath, + fallbackL1: l1Category, + ); final location = normalizedOptionalText(item.location); return ListTile( @@ -390,19 +394,22 @@ class _PantryScreenState extends ConsumerState { const SizedBox(height: 6), Align( alignment: Alignment.centerLeft, - child: Chip( - label: Text( - l1CategoryChipLabel('L1: ', l1Category), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - padding: EdgeInsets.zero, - visualDensity: VisualDensity.compact, - materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, - side: BorderSide(color: colorScheme.outlineVariant), - backgroundColor: colorScheme.surface, - labelStyle: textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, + child: Tooltip( + message: categoryChip.tooltip, + child: Chip( + label: Text( + categoryChip.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + padding: EdgeInsets.zero, + visualDensity: VisualDensity.compact, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + side: BorderSide(color: colorScheme.outlineVariant), + backgroundColor: colorScheme.surface, + labelStyle: textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), ), ), ),