diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 00000000..56bfdb98 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,12 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "Schemalagd Uppdatering av Kategorier", + "type": "shell", + "command": "echo \"0 0 * * 1 cd /opt/containers/recipe-app && node -e \\\"require('./dist/src/receipt-import/receipt-import.service').loadCategories()\\\"\" | crontab -", + "isBackground": false, + "problemMatcher": [] + } + ] +} \ No newline at end of file diff --git a/backend/src/receipt-import/receipt-import.controller.ts b/backend/src/receipt-import/receipt-import.controller.ts index 002c7bdd..dac58564 100644 --- a/backend/src/receipt-import/receipt-import.controller.ts +++ b/backend/src/receipt-import/receipt-import.controller.ts @@ -12,6 +12,7 @@ import { FileInterceptor } from '@nestjs/platform-express'; import { memoryStorage } from 'multer'; import { ReceiptImportService } from './receipt-import.service'; import { ParsedReceiptItem } from './dto/parsed-receipt-item.dto'; +import { AuthGuard } from '@nestjs/passport'; const ALLOWED_MIMES = [ 'image/jpeg', @@ -52,4 +53,11 @@ export class ReceiptImportController { const userId = typeof req?.user?.id === 'number' ? req.user.id : undefined; return this.receiptImportService.parseReceipt(file, isPremium, userId); } + + @Post('refresh-categories') + @UseGuards(AuthGuard('jwt')) + async refreshCategories() { + await this.receiptImportService.loadCategories(); + return { message: 'Kategorier har uppdaterats.' }; + } } diff --git a/backend/src/receipt-import/receipt-import.service.ts b/backend/src/receipt-import/receipt-import.service.ts index 21222831..7d0f6a51 100644 --- a/backend/src/receipt-import/receipt-import.service.ts +++ b/backend/src/receipt-import/receipt-import.service.ts @@ -102,12 +102,21 @@ function inferPackageDebugFromRawName(rawName: string): { @Injectable() export class ReceiptImportService { private readonly logger = new Logger(ReceiptImportService.name); + private cachedCategories: any[] = []; constructor( private readonly prisma: PrismaService, private readonly aiService: AiService, private readonly categoriesService: CategoriesService, - ) {} + ) { + this.loadCategories(); + } + + async loadCategories() { + this.cachedCategories = await this.prisma.category.findMany({ + include: { children: true }, + }); + } async parseReceipt(file: Express.Multer.File, _isPremium = false, userId?: number): Promise { // Steg 1: Delegera AI-parsning till microservice-importer @@ -149,11 +158,7 @@ export class ReceiptImportService { } catch { // ignorera parse-fel } - this.logger.error(`Importer-api kvittoparsfel: ${message}`); - if (response.status >= 400 && response.status < 500) { - throw new BadRequestException(message); - } - throw new ServiceUnavailableException(message); + throw new BadRequestException(message); } return response.json() as Promise; diff --git a/db/seeds/seed_all.sql b/db/seeds/seed_all.sql index 4f5e4234..3be604d4 100644 --- a/db/seeds/seed_all.sql +++ b/db/seeds/seed_all.sql @@ -360,12 +360,6 @@ INSERT INTO `Category` (`name`, `parentId`) JOIN `Category` c2 ON c2.parentId = c1.id AND c2.name = 'Allergi mejeri' WHERE c1.name = 'Mejeri, ost & ägg' AND c1.parentId IS NULL; --- ── NIVÅ 3: under Mejeri, ost & ägg > Mjölk (standard) ─────────────── -INSERT INTO `Category` (`name`, `parentId`) - SELECT 'Standardmjölk', c2.id FROM `Category` c1 - JOIN `Category` c2 ON c2.parentId = c1.id AND c2.name = 'Mjölk' - WHERE c1.name = 'Mejeri, ost & ägg' AND c1.parentId IS NULL; - -- ── NIVÅ 3: under Mejeri, ost & ägg > Matlagning ────────────────── INSERT INTO `Category` (`name`, `parentId`) SELECT 'Gräddfil & creme fraiche', c2.id FROM `Category` c1 @@ -491,10 +485,6 @@ INSERT INTO `Category` (`name`, `parentId`) SELECT 'Färdigmat enportioner', c2.id FROM `Category` c1 JOIN `Category` c2 ON c2.parentId = c1.id AND c2.name = 'Färdigmat' WHERE c1.name = 'Fryst' AND c1.parentId IS NULL; -INSERT INTO `Category` (`name`, `parentId`) - SELECT 'Pizza, paj & piroger', c2.id FROM `Category` c1 - JOIN `Category` c2 ON c2.parentId = c1.id AND c2.name = 'Färdigmat' - WHERE c1.name = 'Fryst' AND c1.parentId IS NULL; -- ── NIVÅ 3: under Skafferi > Bakning ──────────────────────── INSERT INTO `Category` (`name`, `parentId`) diff --git a/flutter/lib/core/api/api_paths.dart b/flutter/lib/core/api/api_paths.dart index a0cf945f..abdd06b3 100644 --- a/flutter/lib/core/api/api_paths.dart +++ b/flutter/lib/core/api/api_paths.dart @@ -25,6 +25,10 @@ class CategoryApiPaths { static const tree = '/categories/tree'; } +class ReceiptImportApiPaths { + static const refreshCategories = '/receipt-import/refresh-categories'; +} + class RecipeApiPaths { static const list = '/recipes'; static String detail(int id) => '/recipes/$id'; diff --git a/flutter/lib/features/profile/data/profile_repository.dart b/flutter/lib/features/profile/data/profile_repository.dart index 60547882..6ef93da7 100644 --- a/flutter/lib/features/profile/data/profile_repository.dart +++ b/flutter/lib/features/profile/data/profile_repository.dart @@ -41,4 +41,12 @@ class ProfileRepository { ); return UserProfile.fromJson(data); } + + Future refreshCategories() async { + final token = await _ref.read(authStateProvider.future); + await guardedApiCall( + _ref, + () => _apiClient.postJson(ReceiptImportApiPaths.refreshCategories, body: {}, token: token), + ); + } } \ No newline at end of file diff --git a/flutter/lib/features/profile/presentation/profile_screen.dart b/flutter/lib/features/profile/presentation/profile_screen.dart index 415e6936..da77d064 100644 --- a/flutter/lib/features/profile/presentation/profile_screen.dart +++ b/flutter/lib/features/profile/presentation/profile_screen.dart @@ -27,6 +27,7 @@ class _ProfileScreenState extends ConsumerState { final _formKey = GlobalKey(); bool _isLoading = true; bool _isSaving = false; + bool _isRefreshingCategories = false; String? _error; UserProfile? _profile; _ProfileTab _activeTab = _ProfileTab.profile; @@ -105,6 +106,24 @@ class _ProfileScreenState extends ConsumerState { context.go('/login'); } + Future _refreshCategories() async { + setState(() => _isRefreshingCategories = true); + try { + await ref.read(profileRepositoryProvider).refreshCategories(); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Kategorier har uppdaterats.')), + ); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(mapErrorToUserMessage(e, context))), + ); + } finally { + if (mounted) setState(() => _isRefreshingCategories = false); + } + } + List<_ProfileTab> _visibleTabs(bool isAdmin) { return [ _ProfileTab.profile, @@ -212,6 +231,23 @@ class _ProfileScreenState extends ConsumerState { : Text(context.l10n.profileSaveAction), ), ), + const SizedBox(height: 16), + const Divider(), + const SizedBox(height: 8), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: _isRefreshingCategories ? null : _refreshCategories, + icon: _isRefreshingCategories + ? const SizedBox( + height: 16, + width: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.refresh), + label: const Text('Uppdatera kategorier'), + ), + ), ], ), );