Compare commits

..

71 Commits

Author SHA1 Message Date
Nils-Johan Gynther e917b2965c docs(filanalys): expand security analysis section with NestJS/Prisma/Next.js specifics and automated detection
Test Suite / backend-pr-quick (push) Has been skipped
Test Suite / quick-import-pr-quick (push) Has been skipped
Test Suite / backend-full (push) Successful in 2m30s
Test Suite / flutter-quality (push) Failing after 1m21s
- Added detailed security risk table with examples for SQL injection, XSS, secrets exposure, CSRF, and insecure deserialization
- Included NestJS/Prisma/Next.js-specific detection patterns and PowerShell scripts for automated scanning
- Added tool integration instructions for npm audit and eslint-plugin-security
- Expanded security analysis to focus on framework-specific vulnerabilities rather than generic risks
2026-05-25 11:52:25 +02:00
Nils-Johan Gynther f6b9af2f38 feat(filanalys): add TypeScript syntax analysis section with automated error detection
Test Suite / backend-pr-quick (push) Has been skipped
Test Suite / quick-import-pr-quick (push) Has been skipped
Test Suite / backend-full (push) Successful in 2m36s
Test Suite / flutter-quality (push) Failing after 1m21s
2026-05-25 11:40:02 +02:00
Nils-Johan Gynther 9e513c2f5e chore(docs): consolidate legacy documentation into new structure
Test Suite / backend-pr-quick (push) Has been skipped
Test Suite / quick-import-pr-quick (push) Has been skipped
Test Suite / backend-full (push) Successful in 2m57s
Test Suite / flutter-quality (push) Failing after 1m16s
- Removed outdated documentation files (MVP_CHECKLISTA.md, NEXT_STEPS.md, README.md, TEKNISK_BESKRIVNING.md, filanalys.md, flyerimporter.md, kilo.json, plan-dokumentation.md)
- Added new centralized documentation structure under docs/ directory
- Added .kilo/ directory for Kilo AI agent configuration and plans

BREAKING CHANGE: Legacy documentation files removed and replaced with new centralized structure
2026-05-25 08:14:35 +02:00
Nils-Johan Gynther 2a87a18edd fix(flyer-import): handle null displayNameDetailed in fallback assignment
Test Suite / backend-pr-quick (push) Has been skipped
Test Suite / quick-import-pr-quick (push) Has been skipped
Test Suite / backend-full (push) Successful in 3m27s
Test Suite / flutter-quality (push) Failing after 1m33s
- Changed fallback assignment from direct value to null coalescing
- Ensures consistent null handling when displayNameDetailed is falsy
2026-05-24 22:15:37 +02:00
Nils-Johan Gynther 451d04cf39 refactor(flyer-import): improve displayNameDetailed handling
Test Suite / backend-pr-quick (push) Has been skipped
Test Suite / quick-import-pr-quick (push) Has been skipped
Test Suite / backend-full (push) Failing after 2m37s
Test Suite / flutter-quality (push) Failing after 1m11s
- Simplified conditional logic for displayNameDetailed assignment
- Added fallback to preserve existing value when displayNameDetailed is falsy
- Reformatted multi-line object initialization for better readability
2026-05-24 22:09:44 +02:00
Nils-Johan Gynther e492ea9a2e test(admin-panel): add product context to AI trace warnings
Test Suite / backend-pr-quick (push) Has been skipped
Test Suite / quick-import-pr-quick (push) Has been skipped
Test Suite / backend-full (push) Failing after 2m19s
Test Suite / flutter-quality (push) Failing after 1m11s
- Added `productName` field to `AdminAiWarning` in test data
- Updated warning test cases to include product context for better traceability
2026-05-24 21:55:45 +02:00
Nils-Johan Gynther 27d622bfe6 feat(inventory): add multi-country origin tracking
Test Suite / backend-pr-quick (push) Has been skipped
Test Suite / quick-import-pr-quick (push) Has been skipped
Test Suite / backend-full (push) Failing after 3m32s
Test Suite / flutter-quality (push) Failing after 1m0s
- Added `originCountries` field to `InventoryItem` model for multi-country origin support
- Updated `CreateInventoryDto` and `UpdateInventoryDto` with `originCountries` array field
- Modified `InventoryService` to handle `originCountries` in create and update operations
- Added `origin` field to `FlyerImportItem` response type for consistency
- Added `categoryId` field to `ParsedReceiptItem` DTO for improved receipt parsing
- Created database migration `20260524_add_origin_countries` for schema changes
2026-05-24 21:31:53 +02:00
Nils-Johan Gynther ca1eed5061 feat(ai): enhance AI trace warnings with product context
Test Suite / backend-pr-quick (push) Has been skipped
Test Suite / quick-import-pr-quick (push) Has been skipped
Test Suite / backend-full (push) Successful in 2m31s
Test Suite / flutter-quality (push) Failing after 1m12s
- Added `productName` field to `AdminAiWarning` to include product context in warnings
- Updated `collectWarnings` to extract and include `rawName` as `productName` in AI trace warnings
- Added `signals` field to `FlyerParseItem` type for detailed product signals
- Enhanced Flutter admin panel to display product names in AI trace warnings
- Added new `AdminAiTraceResponse` DTO for AI trace data structure
2026-05-24 20:55:14 +02:00
Nils-Johan Gynther 7713eb2fa7 ci(deploy): standardize docker compose env file usage across scripts
Test Suite / backend-pr-quick (push) Has been skipped
Test Suite / quick-import-pr-quick (push) Has been skipped
Test Suite / backend-full (push) Successful in 2m33s
Test Suite / flutter-quality (push) Failing after 1m16s
- Added --env-file .env to all docker compose commands for consistent environment variable loading
- Added MISTRAL_API_KEY validation in deploy.sh with fatal error if missing
- Added --force-recreate flag to recipe-api restarts in disable/enable receipt trace scripts
- Added .env file existence check in rebuild_flutter.sh
- Added API service recreation logic in deploy.sh when backend/importer services are updated
2026-05-24 20:30:28 +02:00
Nils-Johan Gynther 26c217e0eb chore(backend): address deprecated dependencies in test stack
Test Suite / backend-pr-quick (push) Has been skipped
Test Suite / quick-import-pr-quick (push) Has been skipped
Test Suite / backend-full (push) Successful in 6m16s
Test Suite / flutter-quality (push) Failing after 1m15s
- Updated jest ecosystem to v30.x in backend/package.json
- Added controlled overrides for test-exclude to remove inflight@1.0.6 and glob@7.2.3
- Updated README.md with deprecation handling procedures and debugging steps
- Modified .kilo/plans/1779643125894-lucky-island.md to document systematic approach for dependency management
2026-05-24 19:47:24 +02:00
Nils-Johan Gynther e6e9e11b18 chore(deps): enforce npm package release age policy
Test Suite / backend-pr-quick (push) Has been skipped
Test Suite / quick-import-pr-quick (push) Has been skipped
Test Suite / backend-full (push) Successful in 2m40s
Test Suite / flutter-quality (push) Failing after 1m9s
- Added `min-release-age=1` to backend/.npmrc to require packages to be at least 1 day old
- GitHub Actions backend jobs already use `npm ci` in backend/ directory, automatically enforcing the policy
- Updated README.md with supply-chain protection policy and operational procedures

BREAKING CHANGE: npm install/npm ci will now fail for packages younger than 1 day unless policy is temporarily overridden
2026-05-24 19:40:48 +02:00
Nils-Johan Gynther b04d157915 feat(flyer-import): add detailed product signals and display names
Test Suite / backend-pr-quick (push) Has been skipped
Test Suite / quick-import-pr-quick (push) Has been skipped
Test Suite / backend-full (push) Successful in 5m12s
Test Suite / flutter-quality (push) Failing after 2m8s
- Added `signals` and `displayNameDetailed` fields to FlyerItem model in Prisma schema
- Introduced `FlyerImportSignals` type with origin countries, labels, quality flags, variant, and packaging
- Added `displayNameDetailed` field to FlyerImportItem DTO and Flutter model
- Implemented utility functions for signal extraction and display name building
- Updated flyer import service to persist and return signals/category data
- Enhanced Flutter UI to display detailed product information including badges for signals
- Added new test coverage for signals persistence and display name generation
- Added new import-common module for shared import utilities
- Created database migration for new fields
- Added Kilo plan for feature development
2026-05-24 19:32:13 +02:00
Nils-Johan Gynther d9f992ca9a feat(ai): enhance AI trace warnings and reason codes system
Test Suite / backend-pr-quick (push) Has been skipped
Test Suite / quick-import-pr-quick (push) Has been skipped
Test Suite / backend-full (push) Successful in 4m21s
Test Suite / flutter-quality (push) Failing after 1m38s
- Added structured warning system with `AdminAiWarning` type in backend and Flutter
- Implemented detailed reason descriptors with `FlyerReasonDescriptor` for parse and match operations
- Added `legacyWarnings` field to maintain backward compatibility
- Enhanced AI trace service to collect and format warnings with item-level context
- Updated flyer import services to include detailed reason descriptions in responses
- Added Swedish diacritic preservation for cheese variants (Prästost, Herrgårdsost, Grevéost)
- Implemented UTF-8 content validation for AI responses
- Added new reason code definitions in `reason-codes.ts`
- Updated Flutter UI to display structured warnings with severity indicators
- Added error report generation and copy functionality in admin panel
- Added comprehensive test coverage for new warning system and cheese normalization

BREAKING CHANGE: AI trace warnings are now structured objects instead of simple strings
2026-05-23 21:11:46 +02:00
Nils-Johan Gynther 0fb507f247 ci(caddy): update Content-Security-Policy for Google Fonts and scripts
Test Suite / backend-pr-quick (push) Has been skipped
Test Suite / quick-import-pr-quick (push) Has been skipped
Test Suite / backend-full (push) Successful in 3m26s
Test Suite / flutter-quality (push) Failing after 1m32s
Updated the Content-Security-Policy header to include Google Fonts and Google Analytics domains:
- Added `https://www.gstatic.com` to `script-src`, `script-src-elem`, `img-src`, and `font-src` directives
- Removed duplicate `script-src` and `style-src` entries in the policy
- Ensured all relevant directives properly include the new domains
2026-05-23 20:14:04 +02:00
Nils-Johan Gynther a240bce8fc chore(backend): update test execution strategy in Dockerfile
Test Suite / backend-pr-quick (push) Has been skipped
Test Suite / quick-import-pr-quick (push) Has been skipped
Test Suite / backend-full (push) Successful in 3m10s
Test Suite / flutter-quality (push) Failing after 1m22s
Update Jest test runner configuration to use `--runInBand` flag in Docker build process for more reliable test execution in containerized environment.
2026-05-23 19:50:12 +02:00
Nils-Johan Gynther 69bcc3e342 feat(web): improve web build configuration and accessibility
Test Suite / backend-pr-quick (push) Has been skipped
Test Suite / quick-import-pr-quick (push) Has been skipped
Test Suite / backend-full (push) Successful in 14m6s
Test Suite / flutter-quality (push) Failing after 4m44s
- Add source maps and web renderer build arguments with defaults
- Configure Caddy with CSP headers, cache policies, and service worker handling
- Defer loading of import screen for performance optimization
- Add semantic labels to icons for accessibility
- Update web index.html with Swedish language, meta tags, and description
- Add robots.txt and lighthouse configuration
- Add new planning documents and archive entries
2026-05-23 18:04:27 +02:00
Nils-Johan Gynther 30d27d6b8a feat(localization): update English and Swedish translations for admin features and profile actions
Test Suite / backend-pr-quick (push) Has been skipped
Test Suite / quick-import-pr-quick (push) Has been skipped
Test Suite / backend-full (push) Successful in 4m10s
Test Suite / flutter-quality (push) Failing after 1m19s
2026-05-21 22:46:33 +02:00
Nils-Johan Gynther 9dd49c5014 bug-fix
Test Suite / backend-pr-quick (push) Has been skipped
Test Suite / quick-import-pr-quick (push) Has been skipped
Test Suite / backend-full (push) Successful in 4m7s
Test Suite / flutter-quality (push) Failing after 57s
2026-05-21 22:36:28 +02:00
Nils-Johan Gynther 8c9da36312 feat(profile): implement user-initiated GDPR-compliant profile deletion
Test Suite / backend-pr-quick (push) Has been skipped
Test Suite / quick-import-pr-quick (push) Has been skipped
Test Suite / backend-full (push) Failing after 4m36s
Test Suite / flutter-quality (push) Failing after 40s
- Add DELETE /users/me endpoint with cascading data removal
- Implement frontend confirmation dialog and deletion flow
- Add audit logging for deletion requests
- Update localization files for new UI strings
- Add scheduled cleanup service for AI traces
- Document GDPR compliance in technical specification

BREAKING CHANGE: Users can now permanently delete their profiles and associated data
2026-05-21 22:19:50 +02:00
Nils-Johan Gynther 6ddb58dc7c docs: update release checklist for AI trace and flyer import
Test Suite / backend-pr-quick (push) Has been skipped
Test Suite / quick-import-pr-quick (push) Has been skipped
Test Suite / backend-full (push) Successful in 2m19s
Test Suite / flutter-quality (push) Failing after 58s
- Replace outdated security and data protection section with current requirements
- Add new checklist items for authentication migration to httpOnly cookies
- Include AI trace retention policy and penetration testing requirements
- Update target audience and document status to 2026-05-21
- Add E2E testing requirements for flyer and receipt imports
- Document GDPR compliance processes
2026-05-21 19:26:22 +02:00
Nils-Johan Gynther e079758f1d docs(release): update documentation for AI trace and flyer import improvements
Test Suite / backend-pr-quick (push) Has been skipped
Test Suite / quick-import-pr-quick (push) Has been skipped
Test Suite / backend-full (push) Successful in 1m49s
Test Suite / flutter-quality (push) Failing after 57s
- Update NEXT_STEPS.md with completed AI trace persistence and admin panel enhancements
- Add new section for executed steps (2026-05-21) documenting AI trace integration
- Update README.md with new AI trace features and improvements
- Enhance TEKNISK_BESKRIVNING.md with detailed technical description of AI trace implementation
- Document backend flow, persistence, masking, and UI improvements
- Add next steps including retention policy and API filtering for AiTrace
2026-05-21 19:16:06 +02:00
Nils-Johan Gynther 026323b72a refactor(ai): enhance AI trace integration and OCR normalization
Test Suite / backend-pr-quick (push) Has been skipped
Test Suite / quick-import-pr-quick (push) Has been skipped
Test Suite / backend-full (push) Successful in 3m54s
Test Suite / flutter-quality (push) Failing after 1m29s
- Add FlyerTraceSupplement type for AI trace metadata
- Implement getFlyerTraceSupplements method to fetch trace supplements
- Update AiTraceService to include prompt/rawOutput and counters in flyer traces
- Add persistFlyerTrace method to FlyerImportService for trace persistence
- Enhance AiFlyerParserService to return structured trace data with prompts and retries
- Update FlyerNormalizerService with OCR typo fixes for cheese variants and spröd bakad firre
- Improve Flutter admin panel with selectable text, warnings display, and tooltips
- Add comprehensive tests for AI trace supplements and normalization rules
2026-05-21 19:11:54 +02:00
Nils-Johan Gynther 67a7590525 feat(ai): add AI trace tracking and admin panel
Test Suite / backend-pr-quick (push) Has been skipped
Test Suite / quick-import-pr-quick (push) Has been skipped
Test Suite / backend-full (push) Successful in 12m45s
Test Suite / flutter-quality (push) Failing after 7m24s
- Add AiTrace model to Prisma schema with relations to User
- Implement AiTraceService with CRUD operations for AI traces
- Add new admin panel for AI traces with filtering and detail views
- Integrate trace persistence in receipt import flow
- Add API endpoints for listing and retrieving AI traces
- Update Flutter admin UI with new AI tab and navigation
- Add new domain models for AI traces and details
- Add migration for AiTrace table creation

BREAKING CHANGE: None
2026-05-21 17:33:21 +02:00
Nils-Johan Gynther c3520b5ad4 test(products): increase test timeout for category update
Test Suite / backend-pr-quick (push) Has been skipped
Test Suite / quick-import-pr-quick (push) Has been skipped
Test Suite / backend-full (push) Successful in 2m0s
Test Suite / flutter-quality (push) Failing after 1m23s
- Set Jest timeout to 15 seconds for products.update-category.http.spec.ts
- Ensures reliable execution of category update integration tests

BREAKING CHANGE: None
2026-05-21 15:03:44 +02:00
Nils-Johan Gynther 2d94a83e73 feat(flyer-import): add cheese variant splitting and normalization rules
Test Suite / backend-pr-quick (push) Has been skipped
Test Suite / quick-import-pr-quick (push) Has been skipped
Test Suite / backend-full (push) Successful in 5m48s
Test Suite / flutter-quality (push) Failing after 1m9s
- Add logic to split Swedish cheese variants (Präst, Herrgård, Grevé) into separate products
- Implement brand normalization for "Arla Ko" and category assignment to "Hårdost"
- Update flyer parser with detailed rules for bundle/group announcements
- Add unit tests for variant splitting and brand/category normalization
- Replace single-item return with flatMap for expanded product lists
2026-05-21 14:44:37 +02:00
Nils-Johan Gynther 47c89c9915 docs: update deployment workflow documentation
Test Suite / backend-pr-quick (push) Has been skipped
Test Suite / quick-import-pr-quick (push) Has been skipped
Test Suite / backend-full (push) Successful in 1m49s
Test Suite / flutter-quality (push) Failing after 1m14s
- Replace references to removed `--migrate` flag with new migration behavior
- Document automatic migration execution during `--clean-database` deployments
- Add details about Prisma Client generation output visibility in deploy logs
- Correct maintenance SQL file usage and table references
2026-05-21 14:30:28 +02:00
Nils-Johan Gynther f9bf3156eb chore(deploy): refactor database cleanup and migration workflow in deploy.sh
Test Suite / backend-pr-quick (push) Has been skipped
Test Suite / quick-import-pr-quick (push) Has been skipped
Test Suite / backend-full (push) Successful in 2m29s
Test Suite / flutter-quality (push) Failing after 1m23s
- Remove deprecated --migrate flag and related RUN_MIGRATE logic
- Simplify database cleanup workflow to always run migrations before cleaning
- Add run_prisma_generate() function to update Prisma Client after migrations
- Update documentation to reflect new workflow where --clean-database implies migration
- Remove conditional migration logic that could cause double execution
2026-05-21 14:29:10 +02:00
Nils-Johan Gynther 0ebb39150f ci(deploy): add migration control and deployment improvements
Test Suite / backend-pr-quick (push) Has been skipped
Test Suite / quick-import-pr-quick (push) Has been skipped
Test Suite / backend-full (push) Successful in 2m11s
Test Suite / flutter-quality (push) Failing after 1m20s
- Add SKIP_MIGRATION environment variable to control automatic Prisma migrations in backend
- Update Dockerfile to conditionally run migrations based on SKIP_MIGRATION flag
- Enhance deploy.sh with:
  - Better error handling using set -euo pipefail
  - New --skip-migration flag to bypass automatic migration startup
  - Improved documentation and help text
  - New helper functions for waiting on services and database
  - Better status reporting for migration results
- Update compose.yml to include SKIP_MIGRATION environment variable
2026-05-21 14:13:54 +02:00
Nils-Johan Gynther 67c3170067 feat(flyer-import): add bundle support and new product fields
Test Suite / backend-pr-quick (push) Has been skipped
Test Suite / quick-import-pr-quick (push) Has been skipped
Test Suite / backend-full (push) Successful in 3m43s
Test Suite / flutter-quality (push) Failing after 1m51s
- Add bundle support with isBundle, bundleWeight, and bundleItems fields
- Add brand, weight, and comparisonUnit fields to FlyerItem model
- Update AI flyer parser to extract bundle information
- Add sanitization for bundle items in FlyerNormalizerService
- Update DTOs and interfaces to include new fields
- Add migration for new database fields
- Update tests to cover bundle item handling
2026-05-21 13:26:50 +02:00
Nils-Johan Gynther 7bbb5a63b5 changed model to: ministral-8b-2512
Test Suite / backend-pr-quick (push) Has been skipped
Test Suite / quick-import-pr-quick (push) Has been skipped
Test Suite / backend-full (push) Successful in 2m47s
Test Suite / flutter-quality (push) Failing after 1m27s
2026-05-21 09:56:10 +02:00
Nils-Johan Gynther 505339aa33 Changed Mistral LLM to mistral-8b-2512. Changed timeout for LMM in .env
Test Suite / backend-pr-quick (push) Has been skipped
Test Suite / quick-import-pr-quick (push) Has been skipped
Test Suite / backend-full (push) Successful in 3m4s
Test Suite / flutter-quality (push) Failing after 1m24s
2026-05-21 09:45:39 +02:00
Nils-Johan Gynther 740e8e5897 chore(docs): update project documentation with deployment and maintenance changes
Test Suite / backend-pr-quick (push) Has been skipped
Test Suite / quick-import-pr-quick (push) Has been skipped
Test Suite / backend-full (push) Successful in 1m33s
Test Suite / flutter-quality (push) Failing after 1m24s
Updated three documentation files to reflect recent deployment and maintenance improvements:

- NEXT_STEPS.md: Added completed steps for 2026-05-20 including deploy script updates, Prisma integration, standardized database cleaning, and production fixes
- README.md: Added new deployment flags and maintenance file information for 2026-05-20 release
- TEKNISK_BESKRIVNING.md: Added technical details about new deploy flow, database cleaning process, and production hotfix resolution
2026-05-20 21:34:08 +02:00
Nils-Johan Gynther e491a6c67f docs: add new documentation files for project planning and development
Test Suite / backend-pr-quick (push) Has been skipped
Test Suite / quick-import-pr-quick (push) Has been skipped
Test Suite / backend-full (push) Successful in 1m32s
Test Suite / flutter-quality (push) Failing after 1m12s
Added two new documentation files:
- .kilo/plans/1779256422838-glowing-knight.md: Contains development plan for the "Glowing Knight" feature
- plan-dokumentation.md: Provides overview of project planning structure and documentation standards
2026-05-20 21:28:10 +02:00
Nils-Johan Gynther ff179430aa fix(db): update clean-database.sql with correct table names and structure
Test Suite / backend-pr-quick (push) Has been skipped
Test Suite / quick-import-pr-quick (push) Has been skipped
Test Suite / backend-full (push) Successful in 2m57s
Test Suite / flutter-quality (push) Failing after 1m24s
2026-05-20 21:27:45 +02:00
Nils-Johan Gynther 6c38101e5c chore(deploy): add database maintenance and migration options
Test Suite / backend-pr-quick (push) Has been skipped
Test Suite / quick-import-pr-quick (push) Has been skipped
Test Suite / backend-full (push) Successful in 7m44s
Test Suite / flutter-quality (push) Failing after 8m15s
Added new deployment options to deploy.sh:
- --migrate: Runs Prisma migration deploy command
- --clean-database: Executes maintenance SQL to clean data while preserving categories

Added new maintenance directory backend/prisma/maintenance/ containing:
- clean-database.sql: SQL script for database cleaning operations

Updated deployment script to:
- Include new command-line flags in help text
- Add conditional blocks for running migrations and database cleaning
- Implement container readiness checks before running Prisma commands
- Preserve existing seed functionality while adding new maintenance features
2026-05-20 20:58:36 +02:00
Nils-Johan Gynther a1a2c33427 feat(shopping-list): add shopping list feature with flyer integration
Test Suite / backend-pr-quick (push) Has been skipped
Test Suite / quick-import-pr-quick (push) Has been skipped
Test Suite / backend-full (push) Successful in 5m8s
Test Suite / flutter-quality (push) Failing after 1m41s
This commit introduces a comprehensive shopping list feature with the following key changes:

Backend:
- Added ShoppingListItem model with relations to User, Product, and Category
- Added new fields to FlyerSession for source file metadata
- Added categoryId field to FlyerItem model
- Implemented session source file retrieval endpoint
- Added endpoint for updating flyer session items with category assignment
- Added endpoint for planning flyer selections to shopping list
- Implemented backfillCategoriesMine for AI-assisted category assignment
- Added ShoppingListModule and integrated with FlyerSelectionModule

Frontend:
- Added ShoppingListScreen and navigation route
- Implemented API paths and client methods for shopping list operations
- Added category tree loading for shopping list item creation
- Integrated shopping list functionality in flyer import tab
- Added category backfill trigger in inventory screen
- Updated FlyerImportItem model with categoryId support
- Added methods for updating flyer session items and retrieving source files

Database:
- Added new Prisma migration for flyer source metadata and shopping list items
- Updated schema with new relations and indexes

The shopping list feature allows users to:
1. Plan flyer selections directly to their shopping list
2. View and manage their shopping list items
3. Update flyer session items with proper categorization
4. Retrieve original flyer source files
5. Automatically backfill categories for uncategorized products
2026-05-20 09:07:30 +02:00
Nils-Johan Gynther 996f0d774b Updatede documentation
Test Suite / backend-pr-quick (push) Has been skipped
Test Suite / quick-import-pr-quick (push) Has been skipped
Test Suite / backend-full (push) Successful in 3m35s
Test Suite / flutter-quality (push) Failing after 1m29s
2026-05-19 22:01:44 +02:00
Nils-Johan Gynther 6cd5b80adb feat(flyer-import): add session management and retrieval endpoints
Test Suite / backend-pr-quick (push) Has been skipped
Test Suite / quick-import-pr-quick (push) Has been skipped
Test Suite / backend-full (push) Successful in 2m15s
Test Suite / flutter-quality (push) Failing after 1m25s
- Add new API endpoints for retrieving flyer import sessions:
  - GET /flyer-import/sessions/latest - Retrieve latest session for user
  - GET /flyer-import/sessions/:sessionId - Retrieve specific session
- Implement session persistence and restoration in Flutter UI
- Add toJson() methods to FlyerImportItem and FlyerImportResult for serialization
- Add new FlyerImportSession domain model for local session management
- Add unit test file for FlyerImportService
- Update FlyerImportController with new endpoints and user ID extraction
- Update FlyerImportService with session retrieval logic and response mapping
- Update API paths in Flutter client
- Add session restoration on widget init in FlyerImportTab
2026-05-19 21:55:55 +02:00
Nils-Johan Gynther 8b8f8b7b6f Update flyerimport. flutter timeout 300 sek
Test Suite / backend-pr-quick (push) Has been skipped
Test Suite / quick-import-pr-quick (push) Has been skipped
Test Suite / backend-full (push) Successful in 4m58s
Test Suite / flutter-quality (push) Failing after 1m41s
2026-05-19 20:53:39 +02:00
Nils-Johan Gynther 33190bd8e0 refactor(ai-parser): implement chunk-based text processing for flyer parsing
Test Suite / backend-pr-quick (push) Has been skipped
Test Suite / quick-import-pr-quick (push) Has been skipped
Test Suite / backend-full (push) Successful in 2m28s
Test Suite / flutter-quality (push) Failing after 1m14s
- Replace fixed text window retry strategy with dynamic chunk-based approach
- Add configurable chunk size, overlap, and maximum chunks via environment variables
- Implement chunk splitting with overlap handling for context preservation
- Add chunk processing with retry logic per chunk
- Include deduplication of items across chunks
- Update logging to reflect chunk-based processing

BREAKING CHANGE: Changes the retry strategy from fixed text windows to dynamic chunk-based processing. Existing configurations may need adjustment for FLYER_AI_CHUNK_SIZE_CHARS, FLYER_AI_CHUNK_OVERLAP_CHARS, and FLYER_AI_MAX_CHUNKS.
2026-05-19 20:24:03 +02:00
Nils-Johan Gynther 4d2942a8e5 chore(infra): add AI flyer parsing configuration and retry logic
Test Suite / backend-pr-quick (push) Has been skipped
Test Suite / quick-import-pr-quick (push) Has been skipped
Test Suite / backend-full (push) Successful in 2m9s
Test Suite / flutter-quality (push) Failing after 1m19s
- Add FLYER_AI_TIMEOUT_MS and FLYER_AI_RETRIES environment variables
- Configure timeout and retry settings in compose.yml
- Update AiFlyerParserService with configurable timeout and retry logic
- Add text window reduction strategy for retry attempts
- Update documentation in TEKNISK_BESKRIVNING.md
- Fix ESLint configuration in app.security.spec.ts
2026-05-19 20:13:59 +02:00
Nils-Johan Gynther 187d0283a5 feat(flyer-import): integrate AI-based flyer parsing with image support
Test Suite / quick-import-pr-quick (push) Has been skipped
Test Suite / backend-full (push) Successful in 2m31s
Test Suite / flutter-quality (push) Failing after 3m48s
Test Suite / backend-pr-quick (push) Failing after 13m57s
- Add support for PNG, JPEG, and WebP image formats in flyer import
- Replace external importer service with internal AI-based parsing pipeline
- Add new services: TextExtractorService, AiFlyerParserService, FlyerNormalizerService
- Integrate Mistral AI, pdf-parse, and tesseract.js dependencies
- Add quality confidence indicators and warning panels in Flutter UI
- Update package.json with new dependencies and transform ignore patterns
- Add documentation for flyer importer system
- Add Kilo AI planning file for Happy Island project

BREAKING CHANGE: Flyer import now uses internal AI parsing instead of external importer service
2026-05-19 19:57:54 +02:00
Nils-Johan Gynther 0ce1db5471 chore: update flyer import features and resources
Test Suite / backend-pr-quick (push) Has been skipped
Test Suite / quick-import-pr-quick (push) Has been skipped
Test Suite / backend-full (push) Successful in 2m43s
Test Suite / flutter-quality (push) Failing after 1m33s
- Remove outdated Willys flyer PDF (0001-0008_WIL_V21_ED1pdf.pdf)
- Add new Willys flyer PDF (willys_reklamblad.pdf)
- Improve offer detection logic in backend flyer-import service
- Add offer limit text extraction and sanitization in Flutter UI
- Fix Swedish character encoding issues in UI text
2026-05-18 23:40:05 +02:00
Nils-Johan Gynther c720f611ea Merge branch 'main' of ssh://gitea.gynther.se:2222/nilsjohan/recipe-app
Test Suite / backend-pr-quick (push) Has been skipped
Test Suite / quick-import-pr-quick (push) Has been skipped
Test Suite / backend-full (push) Successful in 1m36s
Test Suite / flutter-quality (push) Failing after 1m19s
2026-05-18 23:27:31 +02:00
Nils-Johan Gynther e658f2e6f1 chore(ci): update project documentation and flyer import features
Update project documentation with recent CI improvements and flyer import enhancements:

- Add ESLint configuration for backend and Dart lints for Flutter
- Document Prisma query logging via PRISMA_LOG_QUERIES environment variable
- Update NEXT_STEPS.md, README.md, and TEKNISK_BESKRIVNING.md with new features
- Add isOffer, offerLimitText, comparisonPrice, comparisonUnit, parseConfidence, and parseReasons fields to FlyerImportItem
- Update FlyerImportResponse type to include new fields
- Extend file picker to support image formats (png, jpg, jpeg, webp)
- Add offer badge display and price formatting in Flutter UI
- Implement PDF preview functionality for flyer import
2026-05-18 23:27:20 +02:00
nilsjohan a5cd49284a willys-reklamblad
Test Suite / backend-pr-quick (push) Has been skipped
Test Suite / quick-import-pr-quick (push) Has been skipped
Test Suite / backend-full (push) Successful in 2m11s
Test Suite / flutter-quality (push) Failing after 1m13s
Exempel på reklamblad från willys
2026-05-18 23:26:00 +02:00
Nils-Johan Gynther 3f242f9a6d docs: add Prisma query logging configuration guide
Test Suite / backend-pr-quick (push) Has been skipped
Test Suite / quick-import-pr-quick (push) Has been skipped
Test Suite / backend-full (push) Successful in 1m30s
Test Suite / flutter-quality (push) Failing after 1m17s
Add documentation for enabling Prisma query logging in test environments via PRISMA_LOG_QUERIES environment variable.

Include:
- Step-by-step instructions for configuration in Docker Compose
- Environment setup requirements
- Performance and security considerations
- Warning against production use
2026-05-18 23:08:29 +02:00
Nils-Johan Gynther f6ccdd859f ci(github): add linting and improve CI workflow
Test Suite / backend-pr-quick (push) Has been skipped
Test Suite / quick-import-pr-quick (push) Has been skipped
Test Suite / backend-full (push) Successful in 5m14s
Test Suite / flutter-quality (push) Failing after 1m36s
- Add ESLint configuration for backend TypeScript code
- Include linting step in backend quality checks
- Add linting step to GitHub Actions CI workflow
- Enable configurable Prisma query logging via PRISMA_LOG_QUERIES environment variable
- Update PrismaService to support dynamic log levels based on PRISMA_LOG_QUERIES
- Replace BadRequestException with UnauthorizedException in receipt import security tests
2026-05-18 23:01:29 +02:00
Nils-Johan Gynther d5f903db98 chore(import): improve error handling and add flyer integration
Test Suite / backend-pr-quick (push) Has been skipped
Test Suite / quick-import-pr-quick (push) Has been skipped
Test Suite / backend-full (push) Failing after 3m41s
Test Suite / flutter-quality (push) Successful in 2m3s
- Replace BadRequestException with UnauthorizedException for authentication failures in flyer-import and flyer-selection controllers
- Add bulk selection endpoint in FlyerSelectionController for creating multiple selections in one request
- Update FlyerSelectionModule to include new FlyerSelectionMatcherService and FlyerSelectionSyncController
- Extend FlyerSelectionService with createMany method for bulk operations
- Add new DTOs for bulk selection and receipt matching functionality
- Update ReceiptImportService to accept FlyerSelectionService dependency and track successful rows
- Extend SaveReceiptResponse with flyerAutoSync field for receipt-to-flyer matching results
- Add new API paths for flyer import and selection endpoints
- Update Flutter UI to include Flyer import tab and adjust tab controller length
- Add new domain models and repository methods for flyer import functionality
- Update test files to include new FlyerSelectionService dependency
- Modify .kilo plan documentation to reflect current system architecture
2026-05-18 22:51:27 +02:00
Nils-Johan Gynther 24a96c3da1 feat(flyer): add flyer session and selection system
Test Suite / backend-pr-quick (push) Has been skipped
Test Suite / quick-import-pr-quick (push) Has been skipped
Test Suite / backend-full (push) Successful in 2m49s
Test Suite / flutter-quality (push) Successful in 2m0s
- Add FlyerSession, FlyerItem, and FlyerSelection models to Prisma schema
- Implement session persistence with weekly key generation in FlyerImportService
- Add FlyerSelectionModule to AppModule
- Extend FlyerImportResponse with sessionId and flyerItemId fields
- Create new flyer-selection module directory structure
- Add migration for flyer session and selection tables

BREAKING CHANGE: Flyer import now persists data to FlyerSession and FlyerItem tables
2026-05-18 19:02:32 +02:00
Nils-Johan Gynther a31aff7c35 test: update admin aliases panel tests for product model changes
Test Suite / backend-pr-quick (push) Has been skipped
Test Suite / quick-import-pr-quick (push) Has been skipped
Test Suite / backend-full (push) Successful in 2m28s
Test Suite / flutter-quality (push) Successful in 2m21s
- Add imports for new domain models
- Update AdminProduct initialization to include canonicalName
- Update ReceiptAlias initialization to include ownerId and productName
- Replace Switch widget checks with SwitchListTile for global alias test
- Update test data to match new model structure
2026-05-18 18:48:26 +02:00
Nils-Johan Gynther f42132ed5b chore: add flyer import module and configuration
Test Suite / backend-pr-quick (push) Has been skipped
Test Suite / quick-import-pr-quick (push) Has been skipped
Test Suite / backend-full (push) Successful in 3m57s
Test Suite / flutter-quality (push) Failing after 1m19s
- Added FlyerImportModule to AppModule imports
- Created new flyer-import module directory
- Added .kilo/ configuration directory
2026-05-18 18:40:25 +02:00
Nils-Johan Gynther e6961fc593 created kilo.json
Test Suite / backend-pr-quick (push) Has been skipped
Test Suite / quick-import-pr-quick (push) Has been skipped
Test Suite / backend-full (push) Successful in 2m33s
Test Suite / flutter-quality (push) Failing after 1m22s
2026-05-16 09:24:29 +02:00
Nils-Johan Gynther 50816a6844 feat: add central help text system, Prisma migration, and Flutter help button
Test Suite / backend-pr-quick (push) Has been skipped
Test Suite / quick-import-pr-quick (push) Has been skipped
Test Suite / backend-full (push) Successful in 1m2s
Test Suite / flutter-quality (push) Successful in 1m40s
2026-05-13 17:04:44 +02:00
Nils-Johan Gynther bb7a4c1ff2 feat: add Copilot instructions for database command style and credential handling
Test Suite / backend-pr-quick (push) Has been skipped
Test Suite / quick-import-pr-quick (push) Has been skipped
Test Suite / backend-full (push) Successful in 1m2s
Test Suite / flutter-quality (push) Successful in 2m46s
2026-05-13 16:37:35 +02:00
Nils-Johan Gynther 3d9b124766 feat: add HelpText model, service, and controller for dynamic help text management
Test Suite / backend-pr-quick (push) Has been skipped
Test Suite / quick-import-pr-quick (push) Has been skipped
Test Suite / backend-full (push) Successful in 2m27s
Test Suite / flutter-quality (push) Successful in 1m47s
2026-05-13 16:20:04 +02:00
Nils-Johan Gynther 0da4bbf4cf feat: implement alias-scope management for admin, enhance category chip handling, and upgrade dependencies
Test Suite / backend-pr-quick (push) Has been skipped
Test Suite / quick-import-pr-quick (push) Has been skipped
Test Suite / backend-full (push) Successful in 1m40s
Test Suite / flutter-quality (push) Successful in 1m44s
2026-05-12 22:24:26 +02:00
Nils-Johan Gynther 4492d7aa1c feat: enhance receipt alias management with global scope support and update validation
Test Suite / backend-pr-quick (push) Has been skipped
Test Suite / quick-import-pr-quick (push) Has been skipped
Test Suite / backend-full (push) Successful in 50s
Test Suite / flutter-quality (push) Successful in 50s
2026-05-12 22:20:48 +02:00
Nils-Johan Gynther 4471e344eb chore(deps): upgrade NestJS and related dependencies to version 11.x
Test Suite / backend-pr-quick (push) Has been skipped
Test Suite / quick-import-pr-quick (push) Has been skipped
Test Suite / backend-full (push) Successful in 32s
Test Suite / flutter-quality (push) Successful in 51s
fix(auth): ensure JWT_SECRET is set in JwtStrategy constructor
2026-05-12 22:00:29 +02:00
Nils-Johan Gynther 621ced0e43 refactor: streamline alias editing and improve category path handling in admin panel
Test Suite / backend-pr-quick (push) Has been skipped
Test Suite / quick-import-pr-quick (push) Has been skipped
Test Suite / backend-full (push) Failing after 21s
Test Suite / flutter-quality (push) Successful in 57s
2026-05-12 21:53:19 +02:00
Nils-Johan Gynther a4d16cdbae feat: add workspace checkout step to CI workflow for improved reliability
Test Suite / backend-pr-quick (push) Has been skipped
Test Suite / quick-import-pr-quick (push) Has been skipped
Test Suite / backend-full (push) Failing after 21s
Test Suite / flutter-quality (push) Successful in 53s
2026-05-12 21:38:28 +02:00
Nils-Johan Gynther 6ff58ed013 refactor: simplify CI workflow by removing Node.js setup steps and verifying toolchain
Test Suite / backend-pr-quick (push) Has been skipped
Test Suite / quick-import-pr-quick (push) Has been skipped
Test Suite / backend-full (push) Failing after 1s
Test Suite / flutter-quality (push) Failing after 1s
2026-05-12 21:30:39 +02:00
Nils-Johan Gynther 37f931fa0b refactor: streamline Flutter setup in CI workflow by removing unnecessary steps
Test Suite / backend-pr-quick (push) Has been skipped
Test Suite / quick-import-pr-quick (push) Has been skipped
Test Suite / backend-full (push) Failing after 22s
Test Suite / flutter-quality (push) Failing after 1s
2026-05-12 21:28:34 +02:00
Nils-Johan Gynther 8184fbd7e9 refactor: simplify Node.js setup in CI workflows by removing matrix strategy
Test Suite / backend-pr-quick (push) Has been skipped
Test Suite / quick-import-pr-quick (push) Has been skipped
Test Suite / backend-full (push) Failing after 33s
Test Suite / flutter-quality (push) Failing after 5s
2026-05-12 21:26:21 +02:00
Nils-Johan Gynther 46b9be4791 feat: implement update functionality for receipt aliases and add corresponding tests
Test Suite / backend-pr-quick (24.15.0) (push) Has been skipped
Test Suite / quick-import-pr-quick (24.15.0) (push) Has been skipped
Test Suite / backend-full (24.15.0) (push) Failing after 22s
Test Suite / flutter-quality (push) Failing after 4s
2026-05-12 21:25:48 +02:00
Nils-Johan Gynther fb6b371fb7 feat: enhance error dialogs with delete functionality and improve documentation
Test Suite / backend-pr-quick (24.15.0) (push) Has been skipped
Test Suite / quick-import-pr-quick (24.15.0) (push) Has been skipped
Test Suite / backend-full (24.15.0) (push) Failing after 26s
Test Suite / flutter-quality (push) Failing after 4s
2026-05-12 21:11:54 +02:00
Nils-Johan Gynther 0784c1a032 feat: add tests for QuickImportService and ReceiptImportService parse flow
Test Suite / backend-pr-quick (24.15.0) (push) Has been skipped
Test Suite / quick-import-pr-quick (24.15.0) (push) Has been skipped
Test Suite / backend-full (24.15.0) (push) Failing after 27s
Test Suite / flutter-quality (push) Failing after 4s
2026-05-12 20:56:13 +02:00
Nils-Johan Gynther 44ea3cdd7e feat: add unit tests for ReceiptImportService.saveReceipt method
Test Suite / backend-pr-quick (24.15.0) (push) Has been skipped
Test Suite / backend-full (24.15.0) (push) Failing after 21s
Test Suite / flutter-quality (push) Failing after 4s
2026-05-12 20:01:30 +02:00
Nils-Johan Gynther 320a646950 feat: add microservice folders to workspace configuration
Test Suite / backend-pr-quick (24.15.0) (push) Has been skipped
Test Suite / backend-full (24.15.0) (push) Failing after 21s
Test Suite / flutter-quality (push) Failing after 4s
2026-05-12 19:54:03 +02:00
Nils-Johan Gynther acb1e56506 Merge branch 'main' of ssh://gitea.gynther.se:2222/nilsjohan/recipe-app
Test Suite / backend-pr-quick (24.15.0) (push) Has been skipped
Test Suite / backend-full (24.15.0) (push) Failing after 22s
Test Suite / flutter-quality (push) Failing after 4s
2026-05-12 19:18:18 +02:00
Nils-Johan Gynther 2a230a6994 feat: add GitHub Actions workflow for testing backend and Flutter components 2026-05-12 19:18:10 +02:00
194 changed files with 22711 additions and 3343 deletions
+4
View File
@@ -19,3 +19,7 @@ SEED_USER2_PASSWORD=Test-Anv2-FBG
AUTH_SECRET=WheqAss4F/al9yRZRqepJEBs6TzPsN3brX0iBiF4Oww=
JWT_SECRET=uK9yRQpyyWOcHYcYbpAdsJ7NJcEsyCYZcgF82OnBz2k=
MISTRAL_API_KEY=JGPjLuNnzaLSYMxKbexLZohUOegrSLye
FLYER_AI_TIMEOUT_MS=60000
FLYER_AI_RETRIES=2
FLYER_AI_DEBUG=1
FLYER_AI_DEBUG_DIR=/app/debug
+45 -40
View File
@@ -1,40 +1,45 @@
# Kopiera till .env och fyll i riktiga värden
# cp .env.example .env
# MariaDB
MARIADB_ROOT_PASSWORD=byt-ut-mig
MARIADB_DATABASE=recipe_app
MARIADB_USER=recipe_user
MARIADB_PASSWORD=byt-ut-mig
# Auth.js / NextAuth
# Generera med: openssl rand -base64 32
AUTH_SECRET=byt-ut-mig
# JWT (NestJS backend)
# Generera med: openssl rand -base64 32
# OBS: Appen vägrar starta om detta saknas.
JWT_SECRET=byt-ut-mig
# Mistral AI
# Hämtas från: https://console.mistral.ai/
MISTRAL_API_KEY=
# Publik URL (används av frontend)
NEXT_PUBLIC_APP_URL=https://recept.gynther.se
NEXT_PUBLIC_API_URL=https://recept.gynther.se
# CORS — tillåtna origins för backend-API (normalt samma som APP_URL)
ALLOWED_ORIGIN=https://recept.gynther.se
# Importer integration
IMPORTER_SERVICE_URL=http://importer-api:3001
RECEIPT_TRACE_DECISIONS=0
# Optional webhook hardening
GITEA_WEBHOOK_SECRET=
# Bootstrap-användare (skapas/uppdateras vid appstart)
ADMIN_NADMIN_PASSWORD=byt-ut-mig
ADMIN_PADMIN_PASSWORD=byt-ut-mig
SEED_USER1_PASSWORD=byt-ut-mig
SEED_USER2_PASSWORD=byt-ut-mig
# Kopiera till .env och fyll i riktiga värden
# cp .env.example .env
# MariaDB
MARIADB_ROOT_PASSWORD=byt-ut-mig
MARIADB_DATABASE=recipe_app
MARIADB_USER=recipe_user
MARIADB_PASSWORD=byt-ut-mig
# Auth.js / NextAuth
# Generera med: openssl rand -base64 32
AUTH_SECRET=byt-ut-mig
# JWT (NestJS backend)
# Generera med: openssl rand -base64 32
# OBS: Appen vägrar starta om detta saknas.
JWT_SECRET=byt-ut-mig
# Mistral AI
# Hämtas från: https://console.mistral.ai/
MISTRAL_API_KEY=
FLYER_AI_TIMEOUT_MS=45000
FLYER_AI_RETRIES=2
FLYER_AI_DEBUG=0
# Linux-container: /app/debug, lokalt: ./debug
FLYER_AI_DEBUG_DIR=/app/debug
# Publik URL (används av frontend)
NEXT_PUBLIC_APP_URL=https://recept.gynther.se
NEXT_PUBLIC_API_URL=https://recept.gynther.se
# CORS — tillåtna origins för backend-API (normalt samma som APP_URL)
ALLOWED_ORIGIN=https://recept.gynther.se
# Importer integration
IMPORTER_SERVICE_URL=http://importer-api:3001
RECEIPT_TRACE_DECISIONS=0
# Optional webhook hardening
GITEA_WEBHOOK_SECRET=
# Bootstrap-användare (skapas/uppdateras vid appstart)
ADMIN_NADMIN_PASSWORD=byt-ut-mig
ADMIN_PADMIN_PASSWORD=byt-ut-mig
SEED_USER1_PASSWORD=byt-ut-mig
SEED_USER2_PASSWORD=byt-ut-mig
+162 -39
View File
@@ -8,21 +8,39 @@ on:
jobs:
backend-pr-quick:
if: github.event_name == 'pull_request'
if: gitea.event_name == 'pull_request'
runs-on: backend-node24
strategy:
matrix:
node-version: [24.15.0]
env:
DATABASE_URL: mysql://ci:ci@127.0.0.1:3306/recipe_app
JWT_SECRET: ci-test-secret
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Verify Node toolchain on runner
run: |
node --version
npm --version
- name: Setup Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- name: Ensure workspace checkout
shell: bash
run: |
set -euo pipefail
ws="${GITHUB_WORKSPACE:-$PWD}"
if [ -d "$ws/backend" ]; then
echo "Workspace already prepared at $ws"
exit 0
fi
mkdir -p "$ws"
repo_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
if [ -n "${GITHUB_TOKEN:-}" ]; then
repo_url="$(echo "$repo_url" | sed "s#https://#https://oauth2:${GITHUB_TOKEN}@#")"
fi
git clone --depth 1 "$repo_url" "$ws"
if [ -n "${GITHUB_SHA:-}" ]; then
git -C "$ws" fetch --depth 1 origin "$GITHUB_SHA" || true
git -C "$ws" checkout -f "$GITHUB_SHA" || true
fi
- name: Install dependencies (backend)
working-directory: ./backend
@@ -30,7 +48,7 @@ jobs:
- name: Prisma schema validate
working-directory: ./backend
run: npx prisma validate --schema prisma/schema.prisma
run: npm run prisma:validate
- name: Generate Prisma Client
working-directory: ./backend
@@ -44,26 +62,95 @@ jobs:
exit 1
fi
- name: Run receipt-import focused tests (PR quick)
working-directory: ./backend
run: npx jest src/receipt-import/receipt-import.service.spec.ts src/receipt-import/receipt-import.parse-flow.spec.ts src/receipt-import/receipt-import.save.spec.ts --no-coverage
- name: Typecheck backend
working-directory: ./backend
run: npm run typecheck
- name: Build NestJS app
working-directory: ./backend
run: npm run build
backend-full:
if: github.event_name == 'push'
quick-import-pr-quick:
if: gitea.event_name == 'pull_request'
runs-on: backend-node24
strategy:
matrix:
node-version: [24.15.0]
env:
DATABASE_URL: mysql://ci:ci@127.0.0.1:3306/recipe_app
JWT_SECRET: ci-test-secret
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Verify Node toolchain on runner
run: |
node --version
npm --version
- name: Setup Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- name: Ensure workspace checkout
shell: bash
run: |
set -euo pipefail
ws="${GITHUB_WORKSPACE:-$PWD}"
if [ -d "$ws/backend" ]; then
echo "Workspace already prepared at $ws"
exit 0
fi
mkdir -p "$ws"
repo_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
if [ -n "${GITHUB_TOKEN:-}" ]; then
repo_url="$(echo "$repo_url" | sed "s#https://#https://oauth2:${GITHUB_TOKEN}@#")"
fi
git clone --depth 1 "$repo_url" "$ws"
if [ -n "${GITHUB_SHA:-}" ]; then
git -C "$ws" fetch --depth 1 origin "$GITHUB_SHA" || true
git -C "$ws" checkout -f "$GITHUB_SHA" || true
fi
- name: Install dependencies (backend)
working-directory: ./backend
run: npm ci
- name: Run quick-import focused tests (PR quick)
working-directory: ./backend
run: npx jest src/quick-import/quick-import.service.spec.ts --no-coverage
backend-full:
if: gitea.event_name == 'push'
runs-on: backend-node24
env:
DATABASE_URL: mysql://ci:ci@127.0.0.1:3306/recipe_app
JWT_SECRET: ci-test-secret
steps:
- name: Verify Node toolchain on runner
run: |
node --version
npm --version
- name: Ensure workspace checkout
shell: bash
run: |
set -euo pipefail
ws="${GITHUB_WORKSPACE:-$PWD}"
if [ -d "$ws/backend" ]; then
echo "Workspace already prepared at $ws"
exit 0
fi
mkdir -p "$ws"
repo_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
if [ -n "${GITHUB_TOKEN:-}" ]; then
repo_url="$(echo "$repo_url" | sed "s#https://#https://oauth2:${GITHUB_TOKEN}@#")"
fi
git clone --depth 1 "$repo_url" "$ws"
if [ -n "${GITHUB_SHA:-}" ]; then
git -C "$ws" fetch --depth 1 origin "$GITHUB_SHA" || true
git -C "$ws" checkout -f "$GITHUB_SHA" || true
fi
- name: Install dependencies (backend)
working-directory: ./backend
@@ -71,7 +158,7 @@ jobs:
- name: Prisma schema validate
working-directory: ./backend
run: npx prisma validate --schema prisma/schema.prisma
run: npm run prisma:validate
- name: Generate Prisma Client
working-directory: ./backend
@@ -87,7 +174,11 @@ jobs:
- name: Dependency audit (high+critical)
working-directory: ./backend
run: npm audit --audit-level=high
run: npm run audit:high
- name: Typecheck backend
working-directory: ./backend
run: npm run typecheck
- name: Run tests (backend)
working-directory: ./backend
@@ -101,31 +192,63 @@ jobs:
runs-on: flutter-3-41
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Verify Flutter toolchain on runner
run: flutter --version
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: '3.41.9'
- name: Ensure workspace checkout
shell: bash
run: |
set -euo pipefail
ws="${GITHUB_WORKSPACE:-$PWD}"
mkdir -p "$ws"
repo_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
if [ -n "${GITHUB_TOKEN:-}" ]; then
repo_url="$(echo "$repo_url" | sed "s#https://#https://oauth2:${GITHUB_TOKEN}@#")"
fi
if [ ! -d "$ws/.git" ]; then
git -C "$ws" init
git -C "$ws" remote add origin "$repo_url"
else
git -C "$ws" remote set-url origin "$repo_url"
fi
ref="${GITHUB_SHA:-${GITHUB_REF_NAME:-main}}"
git -C "$ws" fetch --depth 1 origin "$ref"
git -C "$ws" checkout -f FETCH_HEAD
if [ ! -d "$ws/flutter" ]; then
echo "Flutter directory missing after checkout: $ws/flutter"
ls -la "$ws"
exit 1
fi
- name: Install dependencies (flutter)
working-directory: ./flutter
run: flutter pub get
shell: bash
run: |
set -euo pipefail
cd "${GITHUB_WORKSPACE:-$PWD}/flutter"
flutter pub get
- name: Analyze Flutter code
working-directory: ./flutter
run: flutter analyze
shell: bash
run: |
set -euo pipefail
cd "${GITHUB_WORKSPACE:-$PWD}/flutter"
flutter analyze
- name: Set Flutter test mode
shell: bash
run: |
if [ "${{ github.event_name }}" = "pull_request" ]; then
if [ "${{ gitea.event_name }}" = "pull_request" ]; then
echo "FLUTTER_TEST_CMD=flutter test --reporter=compact" >> "$GITHUB_ENV"
else
echo "FLUTTER_TEST_CMD=flutter test" >> "$GITHUB_ENV"
fi
- name: Run Flutter tests
working-directory: ./flutter
run: ${{ env.FLUTTER_TEST_CMD }}
shell: bash
run: |
set -euo pipefail
cd "${GITHUB_WORKSPACE:-$PWD}/flutter"
${FLUTTER_TEST_CMD}
+18
View File
@@ -0,0 +1,18 @@
# Copilot Instructions
## Database Command Style
When suggesting database commands in this repository, always use credentials loaded from `.env` inline in the command.
Required pattern:
- Read `MARIADB_ROOT_PASSWORD` from `.env` with `grep` and sanitize quotes/CRLF.
- Read `MARIADB_DATABASE` from `.env` with `grep` and sanitize quotes/CRLF.
- Pass both values directly to `mariadb` command invocations.
Preferred one-liner style:
```bash
docker exec -i recipe-db mariadb -uroot -p"$(grep -E '^[[:space:]]*MARIADB_ROOT_PASSWORD[[:space:]]*=' .env | tail -n1 | sed -E 's/^[^=]*=[[:space:]]*//; s/[[:space:]]+$//; s/^["'\''']|["'\''']$//g' | tr -d '\r')" "$(grep -E '^[[:space:]]*MARIADB_DATABASE[[:space:]]*=' .env | tail -n1 | sed -E 's/^[^=]*=[[:space:]]*//; s/[[:space:]]+$//; s/^["'\''']|["'\''']$//g' | tr -d '\r')" -e "SELECT 1;"
```
Do not suggest hardcoded passwords for MariaDB commands.
+22 -14
View File
@@ -37,13 +37,17 @@ jobs:
working-directory: ./backend
run: npm run prisma:generate
- name: Verify generated Prisma client is typed
working-directory: ./backend
run: |
if ! grep -q "export \* from '.prisma/client/default'" node_modules/@prisma/client/index.d.ts; then
echo "Prisma client export is unexpected";
exit 1;
fi
- name: Verify generated Prisma client is typed
working-directory: ./backend
run: |
if ! grep -q "export \* from '.prisma/client/default'" node_modules/@prisma/client/index.d.ts; then
echo "Prisma client export is unexpected";
exit 1;
fi
- name: Lint backend
working-directory: ./backend
run: npm run lint
- name: Build NestJS app
working-directory: ./backend
@@ -79,13 +83,17 @@ jobs:
working-directory: ./backend
run: npm run prisma:generate
- name: Verify generated Prisma client is typed
working-directory: ./backend
run: |
if ! grep -q "export \* from '.prisma/client/default'" node_modules/@prisma/client/index.d.ts; then
echo "Prisma client export is unexpected";
exit 1;
fi
- name: Verify generated Prisma client is typed
working-directory: ./backend
run: |
if ! grep -q "export \* from '.prisma/client/default'" node_modules/@prisma/client/index.d.ts; then
echo "Prisma client export is unexpected";
exit 1;
fi
- name: Lint backend
working-directory: ./backend
run: npm run lint
- name: Dependency audit (high+critical)
working-directory: ./backend
+27
View File
@@ -0,0 +1,27 @@
# Dokumentationsagent
**Roll**: Hjälper till att uppdatera och förbättra dokumentation, men **modifierar aldrig filer utan godkännande**.
## Regler
- **Läs endast**: Använd `read` och `grep` för att analysera dokumentation.
- **Föreslå ändringar**: Använd `suggest` för att visa förändringar innan de appliceras.
- **Förbjudna åtgärder**:
- Modifiera `teknisk_beskrivning.md` eller `docs/` direkt.
- Köra `bash`-kommandon som påverkar filer (t.ex. `rm`, `echo >`).
## Arbetsflöde
1. Läs befintlig dokumentation med `read`.
2. Föreslå ändringar via `/local-review-uncommitted`.
3. Vänta på explicit godkännande innan modifieringar görs.
4. Om användaren ber om en ändring, visa **alltid** en diff först.
## Exempel på tillåtna åtgärder
- Söka efter föråldrad information i `teknisk_beskrivning.md`.
- Föreslå nya avsnitt eller korrigeringar.
- Sammanfatta befintligt innehåll.
- Lägga till referenser eller länkar till relevant information.
- lägga till information i `docs/` efter godkännande.
## Exempel på förbjudna åtgärder
- Redigera `teknisk_beskrivning.md` direkt.
- Köra `git add` eller `git commit` utan begäran.
- Ta bort eller flytta filer i `docs/`.
+575
View File
@@ -0,0 +1,575 @@
Du är en senior utvecklare och säkerhetsexpert. Analysera commit-kandidater i detta fullstack-projekt (backend: NestJS + Prisma, frontend: Next.js/Flutter, databas: MariaDB).
Syfte:
- Detta är en pre-commit quality gate innan commit.
- Leverera ett tydligt gate-beslut: `PASS`, `PASS_WITH_WARNINGS` eller `BLOCK`.
- Vid `BLOCK`: lista exakta blockerare och fixordning.
---
## 0. Deterministiska gate-regler (källa till sanning)
### 0.1 Filurval (delta-first)
1. Primärt: analysera alla staged filer.
2. Om inga staged filer finns: analysera commit-kandidater i working tree (modified + untracked).
3. Exkludera alltid: `node_modules`, `.git`, build/cache-artifacts, binärfiler, genererade filer som inte ska committas.
4. Fokusera blockerande bedömning på förändrad kod (delta). Legacy-problem i opåverkade delar rapporteras som teknisk skuld (ej blockerande i denna gate).
### 0.2 Severity och beslut
- **Critical**: säkerhetshål/scope-brist med hög impact (t.ex. IDOR, auth bypass, PII-läckage, injection).
- **High**: allvarlig korrektness-/driftsrisk i produktion.
- **Medium/Low**: informativa förbättringar (blockerar inte).
**Beslutslogik (deterministisk):**
- `BLOCK` om minst 1 `Critical`.
- `BLOCK` om 2 eller fler `High`.
- `PASS_WITH_WARNINGS` om exakt 1 `High` utan `Critical`.
- `PASS` om inga `Critical`/`High`.
### 0.3 Evidenskrav för blockerande fynd
Varje `Critical`/`High` måste ha:
- `Evidence`: `code`, `test`, eller `runtime`.
- Fil + radreferens.
- Konkreta fixsteg.
Fynd med endast antagande märks `Needs verification` och får inte ensamt orsaka `BLOCK`, om inte risken är uppenbart kritisk.
### 0.4 Stop-early-regel (effektivitet)
- Vid första tydliga `Critical`: sätt preliminärt `BLOCK`, identifiera max 3 ytterligare blockerare, avsluta sedan djupanalys.
### 0.5 Rapportbudget
- Rapportera max 5 informativa fynd (`Medium/Low`), prioriterade efter högst nytta/lägst kostnad.
---
## 1. Analysfokus
### 1.1 Allmän kodkvalitet
- Läsbarhet/underhållbarhet: namngivning, modularisering, komplexitet.
- TypeScript/Flutter best practices.
- Kommentarer för icke-obvious logik.
### 1.2 Performance-optimeringar (informational)
- Algoritmisk effektivitet.
- Onödiga kopior/serialiseringar.
- Databasfrågor, N+1-risk, ineffektiva `include/select`.
### 1.3 Säkerhetsanalys
Fokusera på **NestJS/Prisma/Next.js-specifika säkerhetsrisker** med automatiserad detektion.
#### 1.3.1 Vanliga säkerhetsrisker (med exempel)
| Risk | Exempel i kod | PowerShell för att hitta | Åtgärd |
|-------------------------------|-------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------|
| **SQL-injection (Prisma)** | `prisma.$queryRaw\`SELECT * FROM users WHERE name = ${userInput}\`` | `Select-String -Path "src\*" -Pattern "\$queryRaw.*\`\$\{"` | Använd Prisma-parametrar: `prisma.$queryRaw\`SELECT * FROM users WHERE name = ${userInput}\`` |
| **XSS (Next.js)** | `<div dangerouslySetInnerHTML={{ __html: userInput }} />` | `Select-String -Path "src\*" -Pattern "dangerouslySetInnerHTML"` | Använd `DOMPurify` eller `react-dom/server`. |
| **Secrets i kod** | `const apiKey = "sk_123456789";` | `Select-String -Path "src\*" -Pattern "apiKey|password|secret" -CaseSensitive` | Flytta till `.env` och använd `@nestjs/config`. |
| **CSRF (NestJS)** | Saknad `@UseGuards(CsrfGuard)` i `main.ts` | `Select-String -Path "src\main.ts" -Pattern "UseGuards.*Csrf" -NotMatch` | Lägg till `app.use(csurf())` i `main.ts`. |
| **Insecure Deserialization** | `JSON.parse(userInput)` utan validering | `Select-String -Path "src\*" -Pattern "JSON\.parse\("` | Använd `zod` eller `class-validator`. |
#### 1.3.2 Automatiserad säkerhetssökning (PowerShell)
Kör dessa kommandon för att hitta säkerhetsproblem:
```powershell
# 1. Sök efter hårdkodade secrets
Select-String -Path "src\*" -Pattern "apiKey|password|secret" -CaseSensitive |
ForEach-Object {
Write-Warning "Potentiell secret läcka i $($_.Path):$($_.LineNumber) - $($_.Line)"
}
# 2. Sök efter SQL-injection-risker i Prisma
Select-String -Path "src\*" -Pattern "\$queryRaw.*\`\$\{" |
ForEach-Object {
Write-Warning "Potentiell SQL-injection i $($_.Path):$($_.LineNumber)"
}
# 3. Kör npm audit för sårbara beroenden
npm audit --json | Out-File "npm_audit.json"
if ((Get-Content "npm_audit.json" | ConvertFrom-Json).metadata.vulnerabilities.high) {
Write-Error "Kritiska sårbarheter hittade! Kör `npm audit fix`."
}
```
#### 1.3.3 Verktygsintegration
- **`npm audit`**: Upptäcker sårbara `node_modules`.
```powershell
npm audit --json | ConvertFrom-Json | Select-Object -ExpandProperty metadata
```
- **`eslint-plugin-security`**: Installera och kör:
```powershell
npm install --save-dev eslint-plugin-security
npx eslint --plugin security src/
```
- **`trivy`** (för container-säkerhet, om relevant):
```powershell
trivy fs --security-checks vuln .
```
#### 1.3.4 Prisma/NestJS-specifika risker
- **Prisma-raw-queries**: Undvik dynamiska SQL-strängar.
```ts
// OSAKER:
prisma.$queryRaw\`SELECT * FROM users WHERE name = ${userInput}\`;
// SÄKER:
prisma.$queryRaw\`SELECT * FROM users WHERE name = ${Prisma.sql\`${userInput}\`}\`;
```
- **NestJS-guards**: Validera alltid `roles` i `@UseGuards()`:
```ts
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('admin')
@Get('admin')
adminOnly() {}
```
### 1.4 Backend-specifik kontroll (NestJS + Prisma)
Kontrollera **NestJS/Prisma-specifika mönster** med automatiserade verktyg.
#### 1.4.1 Vanliga backend-risker (med exempel)
| Risk | Exempel i kod | PowerShell för att hitta | Åtgärd |
|-------------------------------|-------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------|
| **Saknad DTO-validering** | `@Post() create(@Body() data: any)` | `Select-String -Path "src\*" -Pattern "@Body\(\)\.*any"` | Använd `class-validator`: `@Body() data: CreateUserDto` |
| **IDOR (Insecure Direct Object Reference)** | `@Get(':id') findOne(@Param('id') id: string)` utan scope-validering | `Select-String -Path "src\*" -Pattern "@Get\('.*'\)\.*@Param.*id.*string" -NotMatch "UseGuards"` | Lägg till `@UseGuards(OwnershipGuard)`. |
| **Saknad transaktion (Prisma)** | `await prisma.user.create({ data }); await prisma.log.create({ data })` | `Select-String -Path "src\*" -Pattern "await prisma\..*\.create.*await prisma"` | Använd `prisma.$transaction`: `await prisma.$transaction([...])` |
| **N+1-problem (Prisma)** | `users.map(user => prisma.posts.findMany({ where: { userId: user.id } }))` | `Select-String -Path "src\*" -Pattern "\.map.*prisma\..*\.findMany"` | Använd `include`: `prisma.user.findMany({ include: { posts: true } })` |
| **Saknad rate limiting** | `@Post('login')` utan begränsning | `Select-String -Path "src\*" -Pattern "@Post.*login" -NotMatch "ThrottlerGuard"` | Lägg till `@UseGuards(ThrottlerGuard)`. |
#### 1.4.2 Automatiserad validering (PowerShell)
Kör dessa kommandon för att hitta backend-problem:
```powershell
# 1. Sök efter saknad DTO-validering
Select-String -Path "src\*" -Pattern "@Body\(\)\.*any" |
ForEach-Object {
Write-Warning "Saknad DTO-validering i $($_.Path):$($_.LineNumber)"
}
# 2. Sök efter IDOR-risker
Select-String -Path "src\*" -Pattern "@Get\('.*'\)\.*@Param.*id.*string" -NotMatch "UseGuards" |
ForEach-Object {
Write-Warning "Potentiell IDOR-risk i $($_.Path):$($_.LineNumber)"
}
# 3. Sök efter saknade Prisma-transaktioner
Select-String -Path "src\*" -Pattern "await prisma\..*\.create.*await prisma" |
ForEach-Object {
Write-Warning "Saknad transaktion i $($_.Path):$($_.LineNumber)"
}
```
#### 1.4.3 Rekommenderade bibliotek
- **DTO-validering**: [`class-validator`](https://github.com/typestack/class-validator)
```powershell
npm install class-validator class-transformer
```
- **Auktorisation**: [`@nestjs/passport`](https://docs.nestjs.com/security/authentication)
```powershell
npm install @nestjs/passport passport passport-jwt
```
- **Rate limiting**: [`@nestjs/throttler`](https://docs.nestjs.com/security/rate-limiting)
```powershell
npm install @nestjs/throttler
```
- **Prisma-optimeringar**:
- Använd `include` för att undvika N+1:
```ts
prisma.user.findMany({ include: { posts: true } });
```
- Använd `select` för att minimera data:
```ts
prisma.user.findMany({ select: { id: true, name: true } });
```
#### 1.4.4 Prisma-specifika optimeringar
- **Indexering**: Lägg till `@@index` i Prisma-schemat för frekventa frågor:
```prisma
model User {
id Int @id @default(autoincrement())
email String @unique
name String
@@index([email]) // Optimerar sökningar på email
}
```
- **Batch-operations**: Använd `createMany` istället för loopar:
```ts
await prisma.user.createMany({ data: users });
```
- **Timeout-hantering**: Använd `PrismaClient.$extends` för att lägga till timeout:
```ts
const prisma = new PrismaClient().$extends({
query: {
async $allOperations({ operation, model, args, query }) {
return query({ timeout: 10000 }); // 10s timeout
},
},
});
```
---
## 2. Krav på varje fynd
Använd följande mall:
- **Severity**: `Critical|High|Medium|Low`
- **Evidence**: `code|test|runtime|Needs verification`
- **Delsystem**: `backend|frontend|db|infra`
- **Fil**: `<path:line>`
- **Risk**: kort riskbeskrivning
- **Varför**: varför detta är ett problem
- **Åtgärd**: konkret, realistisk fix
- **Verifiering**: kommando/test för att bekräfta fix
Blocking-fynd (`Critical/High`) listas först, därefter informational (`Medium/Low`).
---
## 3. Obligatoriskt outputformat
Returnera exakt i denna ordning:
1. `Scope`
- Urvalsregel: `staged` eller `working-tree`
- Analyserade filer (exakt lista)
- Exkluderade filer (med orsak)
2. `Gate-beslut`
- `PASS|PASS_WITH_WARNINGS|BLOCK`
- Antal per severity: `Critical`, `High`, `Medium`, `Low`
- Kort motivering
3. `Blocking Findings (Critical/High)`
- Om inga finns: skriv `Inga blockerande fynd`.
4. `Informational Findings (Medium/Low)`
- Max 5 fynd.
5. `Fixplan (vid BLOCK eller PASS_WITH_WARNINGS)`
- Numrerad ordning, konkreta steg.
6. `Sammanfattning`
- Topp 3 åtgärder efter risk/vinst
- Tidsestimat
- Rekommenderade automatiserade kontroller
---
## 4. Konsistenskontroller (måste uppfyllas)
- Om `Gate-beslut=PASS` får inga `Critical/High` listas.
- Om `Gate-beslut=BLOCK` måste `Fixplan` innehålla minst 1 konkret blockerande åtgärd.
- Om `PASS_WITH_WARNINGS` används måste exakt 1 `High` finnas och 0 `Critical`.
---
## 5. Fallback: inget att analysera
Om inga relevanta filer hittas:
- Skriv `Inget att analysera` och varför (t.ex. tom staged + tom working tree).
- Ge nästa konkreta steg:
- `git add <filer>`
- `git diff --cached --name-only`
- Kör analysen igen.
---
## 6. Kontext för projektet
- Backend: NestJS + Prisma + MariaDB (Docker).
- Frontend: Next.js + TypeScript + Flutter.
- Mål: produktion, låg teknisk skuld, säkrad hantering av känslig data.
---
## 7. CI-koppling
- Detta är lokalt pre-commit-steg.
- Samma kvalitetskrav bör speglas i CI (push/PR) för att minska miljöskillnader.
---
## 8. TypeScript Syntaxanalys (PowerShell)
### 8.1 Automatiserad Felupptäckt
#### 8.1.1 Förberedelser
Kontrollera att TypeScript och Node.js är installerade:
```powershell
node --version
tsc --version
```
#### 8.1.2 Hämta alla TypeScript-fel
Kör TypeScript-compilern i **noEmit-läge** för att lista fel **utan att generera filer**:
```powershell
tsc --noEmit --pretty | Out-File -FilePath "ts_errors.log" -Encoding utf8
```
#### 8.1.3 Sök efter specifika felkoder (PowerShell)
Använd `Select-String` (PowerShells motsvarighet till `grep`) för att filtrera fel:
```powershell
# Sök efter "Cannot find name" (TS2304)
Select-String -Path "ts_errors.log" -Pattern "TS2304" | Format-Table -AutoSize
# Sök efter "Property does not exist" (TS2339/TS2551)
Select-String -Path "ts_errors.log" -Pattern "TS2339|TS2551" | Format-Table -AutoSize
# Sök efter "implicitly has 'any' type" (TS7006)
Select-String -Path "ts_errors.log" -Pattern "TS7006" | Format-Table -AutoSize
```
#### 8.1.4 Analysera specifika filer
Fokusera på ändrade filer i `git` (pre-commit):
```powershell
# Hämta alla ändrade TypeScript-filer (staged + unstaged)
$changedFiles = git diff --name-only --diff-filter=d | Where-Object { $_ -match '\.tsx?$' }
if ($changedFiles) {
tsc --noEmit $changedFiles | Out-File -FilePath "ts_errors_staged.log" -Encoding utf8
} else {
Write-Host "Inga ändrade TypeScript-filer hittades."
}
```
### 8.2 Vanliga TypeScript-Syntaxfel (Windows)
| Felkod | Beskrivning | PowerShell för att hitta fel | Åtgärd |
|--------------|---------------------------------------|--------------------------------------------------|------------------------------------------------------------------------|
| **TS2304** | Okänd variabel/import | `Select-String -Pattern "TS2304"` | Lägg till import eller deklarera variabeln. |
| **TS2339** | Egenskap saknas på objekt | `Select-String -Pattern "TS2339"` | Utöka interfacet eller använd `as`-typning. |
| **TS2551** | Felaktig egenskapsåtkomst | `Select-String -Pattern "TS2551"` | Kontrollera typdefinitionen. |
| **TS1128** | Saknad deklaration eller statement | `Select-String -Pattern "TS1128"` | Lägg till kodblock eller semikolon. |
| **TS7006** | Parameter saknar typ | `Select-String -Pattern "TS7006"` | Lägg till typannotation (t.ex. `: string`). |
| **TS1005** | Saknad semikolon | `Select-String -Pattern "TS1005"` | Lägg till semikolon om `tsconfig.json` kräver det. |
### 8.3 Åtgärdsförslag (PowerShell-exempel)
#### 8.3.1 TS2304: "Cannot find name"
**Orsak**: Variabel, funktion eller import saknas.
**Lösningar**:
1. **Lägg till import**:
```powershell
# Exempel: Saknad import för `axios`
Get-Content "src\api\service.ts" | Select-String "axios"
```
```diff
- fetchData();
+ import axios from 'axios';
+ axios.get('/api/data');
```
2. **Installera saknade typer**:
```powershell
npm install --save-dev @types/node
```
#### 8.3.2 TS2339/TS2551: "Property does not exist"
**Orsak**: Egenskap saknas i typdefinitionen.
**Lösningar**:
1. **Utöka interfacet**:
```diff
- interface User { name: string; }
+ interface User { name: string; address?: { city: string; }; }
```
2. **Använd `as`-typning (temporärt)**:
```ts
const city = (user as { address: { city: string } }).address.city;
```
#### 8.3.3 TS1128: "Declaration or statement expected"
**Orsak**: Ogiltig syntax (t.ex. saknat kodblock).
**Lösningar**:
1. **Lägg till kodblock**:
```diff
- function foo() return 5;
+ function foo() { return 5; }
```
2. **Kontrollera semikolon** (om `tsconfig.json` kräver det):
```diff
- const x = 5
+ const x = 5;
```
#### 8.3.4 TS7006: "Parameter implicitly has 'any' type"
**Orsak**: Parameter saknar typ.
**Lösningar**:
1. **Lägg till typannotation**:
```diff
- function greet(name) { ... }
+ function greet(name: string) { ... }
```
2. **Stäng av `noImplicitAny`** (ej rekommenderat):
```json
{
"compilerOptions": {
"noImplicitAny": false
}
}
```
### 8.4 Exempel på Korrigeringar (PowerShell)
#### 8.4.1 Fixa saknad import (TS2304)
**Felaktig kod**:
```ts
getUser(); // TS2304: Cannot find name 'getUser'
```
**Fix**:
```powershell
# Kontrollera om funktionen existerar i projektet
Get-ChildItem -Recurse -Include *.ts | Select-String -Pattern "function getUser"
```
```ts
import { getUser } from './api'; // Lägg till import
getUser();
```
#### 8.4.2 Fixa ogiltig egenskap (TS2551)
**Felaktig kod**:
```ts
interface User { name: string; }
const user: User = { name: 'Alice' };
console.log(user.age); // TS2551: Property 'age' does not exist
```
**Fix**:
```ts
interface User { name: string; age?: number; } // Gör 'age' valfritt
const user: User = { name: 'Alice' };
console.log(user.age); // OK (kan vara `undefined`)
```
### 8.5 PowerShell-specifika Tips
#### 8.5.1 Sökvägar och Filtrering
- **Använd dubbla citattecken (`"`) för sökvägar**:
```powershell
Get-ChildItem -Path "C:\Users\Nils-JohanGynther\dev\recipe-app\src\*.ts"
```
- **Escape specialtecken** med backtick (`\`):
```powershell
Select-String -Pattern "Property\`|does not exist"
```
#### 8.5.2 Kör `tsc` med full sökväg (om nödvändigt)
```powershell
& "C:\Users\Nils-JohanGynther\dev\recipe-app\node_modules\.bin\tsc" --noEmit
```
#### 8.5.3 Spara felutdata till fil
```powershell
tsc --noEmit | Out-File -FilePath "ts_errors.log" -Encoding utf8
```
### 8.6 Rekommenderat Arbetsflöde (PowerShell)
1. **Lista alla TypeScript-fel**:
```powershell
tsc --noEmit --pretty | Out-File -FilePath "ts_errors.log" -Encoding utf8
```
2. **Filtrera efter felkod**:
```powershell
Select-String -Path "ts_errors.log" -Pattern "TS2304|TS2551" | Format-Table -AutoSize
```
3. **Åtgärda felen** enligt tabellen ovan.
4. **Validera fixar**:
```powershell
tsc --noEmit
```
### 8.7 Vanliga PowerShell-kommandon
| Syfte | PowerShell-kommando |
|--------------------------------|-------------------------------------------------------|
| Lista TypeScript-filer | `Get-ChildItem -Recurse -Include *.ts,*.tsx` |
| Sök efter text i filer | `Select-String -Path "src\*.ts" -Pattern "TS2304"` |
| Kör TypeScript-compilern | `tsc --noEmit` |
| Spara felutdata till fil | `tsc --noEmit | Out-File "errors.log"` |
| Visa innehåll i en fil | `Get-Content "src\app.ts"` |
| Kontrollera git-status | `git status` |
| Lista ändrade filer | `git diff --name-only --diff-filter=d` |
### 8.8 Output-format (för pre-commit)
När du kör analysen, returnera resultatet i detta format:
```markdown
### Scope
- **Analyserade filer**:
- `src\api\service.ts` (2 TS2304-fel)
- `src\models\user.ts` (1 TS2551-fel)
- **Exkluderade filer**:
- `node_modules\` (ignorerad)
- `dist\` (genererad kod)
### Gate-beslut: `PASS_WITH_WARNINGS`
- **Critical**: 0
- **High**: 1 (TS2551 i `user.ts`)
- **Medium**: 2
- **Low**: 0
### Blocking Findings (Critical/High)
1. **TS2551** i `src\models\user.ts:15`
- **Risk**: Egenskapen `address` saknas i `User`-interfacet.
- **Åtgärd**: Utöka interfacet eller använd `as`-typning.
- **Verifiering**: Kör `tsc --noEmit` efter fix.
### Informational Findings (Medium/Low)
1. **TS2304** i `src\api\service.ts:8`
- **Förbättring**: Lägg till import för `axios`.
- **Kommando**: `npm install axios --save`
2. **TS7006** i `src\utils\helpers.ts:3`
- **Förbättring**: Lägg till typ för parametern `data`.
- **Exempel**: `function parse(data: string) { ... }`
### Fixplan
1. Åtgärda `TS2551` i `user.ts` (blockerande).
2. Lägg till saknade imports i `service.ts`.
3. Typa parametrar i `helpers.ts`.
### Sammanfattning
- **Topp 3 åtgärder**:
1. Fixa `User`-interfacet (5 min).
2. Installera `axios` (2 min).
3. Lägg till typer i `helpers.ts` (3 min).
- **Tidsestimat**: 10 minuter.
- **Rekommenderade automatiserade kontroller**:
- Lägg till `tsc --noEmit` i `pre-commit`-hook.
- Konfigurera ESLint för TypeScript (`@typescript-eslint`).
```
### 8.9 Felhantering i PowerShell
Använd `try/catch` för att fånga fel i PowerShell-skript:
```powershell
try {
tsc --noEmit | Out-File -FilePath "ts_errors.log" -Encoding utf8 -ErrorAction Stop
} catch {
Write-Error "TypeScript-compilering misslyckades: $_"
exit 1
}
```
### 8.10 Dynamiska sökvägar och validering
#### 8.10.1 Använd dynamiska sökvägar
Ersätt hårdkodade sökvägar med variabler för bättre portabilitet:
```powershell
$projectRoot = Resolve-Path "."
$tsconfigPath = Join-Path $projectRoot "tsconfig.json"
tsc --project $tsconfigPath --noEmit
```
#### 8.10.2 Validera TypeScript-installation
Kontrollera att `tsc` är tillgängligt innan analys:
```powershell
if (-not (Get-Command tsc -ErrorAction SilentlyContinue)) {
Write-Error "TypeScript är inte installerat. Kör: npm install -g typescript"
exit 1
}
```
#### 8.10.3 Förbättrad git-integration
Hämta ändrade filer med fullständiga sökvägar:
```powershell
$changedFiles = git diff --name-only --diff-filter=d | ForEach-Object {
$filePath = Resolve-Path $_ -ErrorAction SilentlyContinue
if ($filePath -and $filePath -match '\.tsx?$') { $filePath }
}
if ($changedFiles) {
tsc --noEmit $changedFiles | Out-File -FilePath "ts_errors_staged.log" -Encoding utf8
}
```
### 8.11 Stöd för PowerShell Core (pwsh)
> **Notera**: Alla kommandon i denna guide är testade och fungerar i både:
> - **Windows PowerShell 5.1** (standard i Windows 10/11)
> - **PowerShell Core 7+** (`pwsh`)
För bästa resultat, använd PowerShell Core (`pwsh`) för:
- Snabbare exekvering.
- Bättre Unicode-stöd (t.ex. emojis i felmeddelanden).
- Kompatibilitet med multiplattforms-projekt.
Installera PowerShell Core:
```powershell
winget install --id Microsoft.Powershell --source winget
```
+3
View File
@@ -0,0 +1,3 @@
{
"$schema": "https://app.kilo.ai/config.json"
}
+225
View File
@@ -0,0 +1,225 @@
# Plan: Fortsatt implementation av FlyerImport (autoflöde + UX i `/import` Flutter)
## Målbild
Implementera ett komplett flyer-flöde med så få klick som möjligt där:
- användaren markerar planerade inköp i `FlyerImportTab`
- kvittoimporten matchar automatiskt mot öppna `FlyerSelection` och sätter `status=bought`
- användaren bara behöver ingripa vid osäker matchning
Detta följer båda underlagen:
1) ingen befintlig flyer-UX i `/import` ännu (behöver byggas)
2) maximal automation i punkt 3 (sync med kvittoimport)
---
## Principer för “så få klick som möjligt”
- Default är automation: matcha och uppdatera utan extra dialoger.
- UI ska vara “review first, edit only when needed”.
- Endast två manuella åtgärder i happy path:
1. markera planerade flyer-varor
2. importera kvitto
- Manuell override ska finnas men inte blockera flödet.
---
## Fas 1: Backend-kontrakt för auto-sync
### 1.1 Nya/utökade endpoints
Implementera i `backend/src/flyer-selection`:
- `POST /flyer-selections/receipt-match-preview`
- Input: kvittorader (normaliserad struktur), `weekKey` (optional), `sessionId` (optional)
- Output: matchförslag per kvittorad + confidence + reasonCodes + kandidat-selection
- Används för transparent UI-annotering före commit
- `POST /flyer-selections/receipt-match-commit`
- Input: samma payload + optional overrides
- Output: antal uppdaterade selections, listor över `bought`, `unmatched`, `ambiguous`
- Utför transaktionell statusuppdatering (`planned -> bought`)
- `GET /flyer-selections/open`
- Query: `weekKey`, `retailer`, pagination
- Returnerar öppna selections (`status=planned`) för snabb klienthämtning
Notering: Behåll befintliga CRUD-rutter under `/flyer-sessions/:sessionId/selections`.
### 1.2 Matchningsmotor (service-nivå)
I `FlyerSelectionService` lägg till en intern matcher med prioriterad strategi:
1. `productId` exact (högsta prio)
2. normalized name exact
3. alias/ordöverlap (token)
4. quantity/unit-stöd som förstärkning (inte ensam källa)
Regler:
- En `FlyerSelection` kan bara konsumeras en gång per commit.
- Confidence-trösklar:
- `>=0.90`: auto-commit-kandidat
- `0.70-0.89`: ambiguous (kräver override för commit)
- `<0.70`: unmatched
- Returnera alltid `matchedVia`, `confidence`, `reasonCodes`.
### 1.3 Datamodell-justeringar (om behövs)
Nuvarande schema räcker i stort, men planera följande icke-blockerande förbättringar:
- `FlyerSelection`:
- `boughtAt DateTime?`
- `boughtSource String?` (t.ex. `receipt_auto`, `receipt_manual`)
- `receiptImportBatchId String?` för spårbarhet
- Index:
- `(userId, status, updatedAt)` för snabb hämtning av öppna poster
Migration i separat steg efter kod.
### 1.4 Säkerhet och robusthet
- Validera alltid user-scope i alla nya endpoints.
- Rate-limit på match-endpoints (liknande befintlig throttle-nivå).
- Transaktion (`prisma.$transaction`) för commit så statusuppdatering blir atomisk.
- Idempotens: commit med samma `receiptImportBatchId` ska inte dubbeluppdatera.
---
## Fas 2: Flutter UX i `/import` (ny flyer-tab)
### 2.1 Lägg till `FlyerImportTab`
Uppdatera `ImportScreen` så tabbar blir:
1. Recept
2. Kvitto
3. Flyer
Skapa:
- `flutter/lib/features/import/presentation/flyer_import_tab.dart`
- `flutter/lib/features/import/data/flyer_import_repository.dart`
- `flutter/lib/features/import/data/flyer_import_providers.dart`
- `flutter/lib/features/import/domain/flyer_item.dart`
- `flutter/lib/features/import/domain/flyer_selection.dart`
### 2.2 Flyer-tabens minimalklick-flöde
Steg i UI:
1. Upload flyerfil
2. Visa parserader med förvald checkbox för matchade varor
3. Primär CTA: `Planera markerade` (bulk-create/upsert)
4. Direkt visning av statuschips (`planned/bought/skipped`)
Klickoptimering:
- Förifyll `plannedQuantity/plannedUnit` från flyerdata.
- Batch-upsert selections i ett enda API-anrop.
- Visa inline varningar istället för modaler där möjligt.
### 2.3 Statusöversikt
I flyer-tabben visa sektioner:
- `Planerade` (öppna)
- `Nyligen köpta` (autouppdaterade från kvitto)
- `Ej matchade vid senaste kvitto` (för snabb manuell hantering)
---
## Fas 3: Integrera auto-sync i `ReceiptImportTab`
### 3.1 Hook efter receipt parse
I befintlig `_submit()` i `receipt_import_tab.dart`:
1. importera kvitto som idag
2. anropa `receipt-match-preview`
3. annotera UI-rader med matchstatus (icon/chip)
4. vid `Lägg till markerade`: anropa `receipt-match-commit` parallellt/sekventiellt med saveReceipt
### 3.2 Zero-click commit i happy path
Defaultbeteende vid `Lägg till markerade`:
- auto-committa alla matcher med confidence `>=0.90`
- lämna ambiguous som `planned`
- visa en kompakt snackbar:
- `2 planerade markerades som köpta`
- `1 kräver manuell kontroll`
Ingen extra dialog i standardfall.
### 3.3 Manuell override (endast vid behov)
Lägg till valfri expandrad i resultatlistan:
- “Föreslagen flyer-match” + knapp `Bekräfta ändå`
- används endast för ambiguous fall
---
## Fas 4: API- och UI-detaljer för låg friktion
### 4.1 Payload-standard (receipt -> matcher)
Standardisera kvittorad till:
- `rowId` (lokalt index eller UUID)
- `rawName`
- `normalizedName`
- `productId` (om redan mappad)
- `quantity`
- `unit`
- `price`
### 4.2 UX-copy
Konsekventa texter i UI:
- `Automatchad mot flyer`
- `Osäker matchning`
- `Ej matchad`
### 4.3 WeekKey-hantering
Fallback-ordning vid matchning:
1. explicit `sessionId`
2. explicit `weekKey`
3. server beräknar aktuell `weekKey`
Detta minimerar klientlogik och fel.
---
## Fas 5: Test och kvalitetssäkring
### 5.1 Backend
- Unit-tester för matcher-regler och confidence-nivåer
- Service-tester för commit-idempotens
- Controller-e2e för user-scope + throttling + felkoder
- Prisma-transaktionsscenarion (dubbelklick/duplicerat commit)
### 5.2 Flutter
- Widget-tester för:
- `FlyerImportTab` listning/bulk-planering
- kvitto-rad med automatch-chip
- Integrationstester för `ReceiptImportTab` + auto-sync callback
### 5.3 Acceptanskriterier (måste uppfyllas)
- Happy path kräver max 2 aktiva klick från planering till auto-bought.
- Minst 90% av high-confidence-matchningar autouppdateras korrekt i test-fixtures.
- Inga writes till `Product/Inventory` sker i flyer-planeringsfas.
---
## Fas 6: Gradvis lansering
1. Backend-endpoints bakom feature flag: `flyerReceiptAutoSyncEnabled`
2. Aktivera för intern/test-användare först
3. Mät:
- andel auto-match
- andel ambiguous
- manuell override-frekvens
4. Finjustera thresholds innan full rollout
---
## Implementeringsordning (konkret)
1. Backend: matcher + preview/commit endpoints
2. Backend: idempotens + spårbarhetsfält + migration
3. Flutter: ny `FlyerImportTab` + repository/providers
4. Flutter: integrera preview/commit i `ReceiptImportTab`
5. Tester backend + Flutter
6. Feature flag rollout
---
## Risker och mitigering
- Felmatchningar: håll konservativ tröskel och auto-commit bara vid hög confidence.
- Dubbla commits: idempotensnyckel + transaktion.
- UX-brus: visa detaljer först vid ambiguous, inte i happy path.
- Prestanda: batcha matchning och undvik N+1-frågor via prefetch av öppna selections.
---
## Definition of Done
- `/import` har en fungerande `FlyerImportTab`.
- Kvittoimport auto-synkar mot `FlyerSelection` med minimal friktion.
- `planned -> bought` uppdateras automatiskt för high-confidence.
- Ambiguous fall kan hanteras manuellt utan att blockera flödet.
- Tester gröna och feature flag klar för kontrollerad utrullning.
+125
View File
@@ -0,0 +1,125 @@
# Plan: Omgjord flyerimport (pdf-parse + tesseract + Mistral Tiny)
## Mål
Ersätta nuvarande flyerimportflöde (som idag delegerar till `importer-api`) med en robust pipeline som:
1) extraherar text från flyer-PDF (primärt `pdf-parse`, fallback OCR via Tesseract),
2) skickar normaliserad text till Mistral Tiny,
3) returnerar strikt strukturerad JSON,
4) behåller befintlig matchning/planeringsflöde i backend + Flutter men förbättrar UX kring importresultat och fel.
## Nulägesanalys (projektanpassad)
- Backend endpoint finns: `POST /flyer-import/parse` i `backend/src/flyer-import/flyer-import.controller.ts`.
- Nuvarande backendlogik i `backend/src/flyer-import/flyer-import.service.ts` anropar extern tjänst via `IMPORTER_SERVICE_URL` (`/api/flyer/parse`).
- Flutter har redan komplett flyerflik i `flutter/lib/features/import/presentation/flyer_import_tab.dart`:
- filval, importknapp, preview, radrendering med checkboxar, bulk-planering.
- Datamodell för sessions/items finns redan i Prisma (`FlyerSession`, `FlyerItem`, `FlyerSelection`) och stödjer parse+match-metadata.
- `flyerimporter.md` beskriver rätt riktning men är generisk; projektet behöver NestJS-integration och kompatibilitet med befintliga DTO/Flutter-modeller.
## Föreslagen arkitektur (ersättning av dagens lösning)
### 1) Ny intern parser i recipe-api (NestJS)
- Ersätt `parseViaImporter(...)` i `FlyerImportService` med lokal pipeline:
- `extractFlyerText(file)`
- PDF/text-extraktion via `pdf-parse`.
- Fallback OCR via Tesseract för sidor/underlag utan användbar text.
- `parseFlyerWithMistral(text)`
- Mistral Tiny-anrop med strikt JSON-schema-prompt.
- `normalizeFlyerItems(aiJson)`
- validering, typkonvertering, enhetsnormalisering, confidence/reasonCodes.
- Behåll resten av tjänsten intakt (matchning, sessionpersistens, selections-kompatibilitet).
### 2) AI-kontrakt (strikt JSON)
- Introducera explicit schema för AI-svar (intern typ + runtime-validering):
- `rawName`, `normalizedName`, `category`, `price`, `priceUnit`, `comparisonPrice`, `comparisonUnit`, `offerText`, `confidence`, `reasonCodes`.
- Promptdesign:
- svensk flyer-kontext,
- tydlig enhets- och prisnormalisering,
- "returnera ENDAST JSON" + exempel,
- fallback vid saknade fält (`null`, tomma listor).
- Robust parsing av modelloutput:
- ta bort ev. markdown fences,
- fail-fast med tydligt felmeddelande om ogiltigt JSON.
### 3) OCR-strategi
- Primärväg: `pdf-parse` (snabb, billig).
- OCR-fallback: bara när extraherad text är tom/under tröskel.
- Preprocess för OCR (vid behov): sidvis rasterisering + språk `swe` (ev. `swe+eng`).
- Timeout/guardrails per steg för att undvika låsta importer.
### 4) API/infra-anpassning
- Controller (`flyer-import.controller.ts`):
- uppdatera tillåtna MIME-typer så de matchar Flutter-filtyper (PDF + bilder om vi ska stödja bildflyers).
- `compose.yml`/env:
- gör `IMPORTER_SERVICE_URL` optional eller avveckla för flyerflödet.
- säkerställ `MISTRAL_API_KEY` används av `recipe-api` för flyer.
- Dokumentation:
- uppdatera teknisk beskrivning så flyerimport inte längre kräver extern flyer-parser.
## UX-analys Flutter (nuvarande) och planerade förbättringar
### Nuvarande UX (bra att bygga vidare på)
- Enkel 3-stegsinteraktion: välj fil -> importera -> markera/planera.
- Förhandsvisning finns och passar arbetsflödet.
- Offer-badge + pris/jämförpris + matchvisning ger snabb scanning.
### UX-gap att täppa till i denna implementation
- Ingen tydlig visning av parserwarnings från backend (fältet `warnings` finns i modellen).
- Ingen kvalitetssignal i UI trots att `parseConfidence/matchConfidence` finns.
- Felmeddelanden är relativt råa; saknar råd per feltyp (timeout, ogiltig fil, AI-svar oformaterat).
### Föreslagna UX-förbättringar (inkrementella, kompatibla)
1. Visa `warnings` över resultatlistan i en kompakt varningspanel.
2. Lägg till "kvalitetsindikator" per rad (t.ex. låg/medel/hög) baserat på `parseConfidence` + `matchConfidence`.
3. Lägg till filterchips: `Endast erbjudanden`, `Saknar matchning`, `Låg kvalitet`.
4. Förbättra loading-state med stegnära text ("Extraherar text", "Tolkar med AI", "Matchar produkter").
5. Felmappning till användarvänliga meddelanden i `showErrorDialog` (teknisk detalj i kopierbar sekundärtext).
## Implementationsplan (ordning)
### Fas A - Backend kärna
1. Lägg till dependencies i `backend/package.json` för PDF/OCR/Mistral-klient.
2. Skapa intern flyer-parser service i `backend/src/flyer-import/` (text extraction + AI parse).
3. Byt `parseViaImporter` till intern implementation i `FlyerImportService`.
4. Lägg till runtime-validering och normalisering av AI-svar.
### Fas B - Kontrakt och robusthet
5. Säkerställ att response-format fortsatt matchar `FlyerImportResponse` (ingen breaking change mot Flutter).
6. Förbättra controller MIME-regler så de stämmer med faktiska stödda format.
7. Lägg till tydliga felkoder/meddelanden för:
- tom/oläsbar flyer,
- AI-parsefel,
- timeout/service unavailable.
### Fas C - Flutter UX på befintlig skärm
8. Visa backend `warnings` i `flyer_import_tab.dart`.
9. Lägg till kvalitetsindikator + minimala filterchips.
10. Förfina loading/feltexter utan att ändra grundlayouten.
### Fas D - Verifiering
11. Backendtester för intern flyer-parser (happy path + fallback + felbanor).
12. Uppdatera/addera Flutter widgettester för warnings/indikator/filter.
13. Manuell E2E: PDF med text, PDF med skannade sidor, bildflyer, trasig fil.
## Filer som sannolikt berörs vid implementation
- `backend/src/flyer-import/flyer-import.service.ts`
- `backend/src/flyer-import/flyer-import.controller.ts`
- `backend/src/flyer-import/dto/flyer-import.response.ts` (endast om extra metadata behövs)
- `backend/package.json`
- `flutter/lib/features/import/presentation/flyer_import_tab.dart`
- Ev. `flutter/lib/features/import/domain/flyer_import_item.dart` (om ny UI-metadata exponeras)
- Dokumentation: `TEKNISK_BESKRIVNING.md` (kort uppdatering av arkitektur)
## Risker och mitigering
- OCR-prestanda/latens: använd fallback-only och timeout.
- Mistral kan ge semistrukturerat svar: strikt schema + robust JSON-sanitizing + validering.
- Kostnad/kvot på AI-anrop: minimera promptstorlek, trunkera brus, återanvänd normalisering.
- Driftöverraskningar: behåll endpoint-kontrakt oförändrat mot Flutter.
## Acceptance criteria
- Flyerimport fungerar utan beroende av extern `/api/flyer/parse` i importer-api.
- Minst en PDF med inbäddad text och en skannad PDF importeras framgångsrikt.
- Backend returnerar valid `FlyerImportResponse` och befintlig planeringsfunktion fortsätter fungera.
- Flutter visar warnings och gör det tydligare vilka rader som behöver manuell granskning.
## Fastställt beslut
- Första leveransen ska stödja **PDF + bildfiler** (`png/jpg/webp`) fullt ut.
+152
View File
@@ -0,0 +1,152 @@
# Åtgärdsplan utifrån E2E-fynd (Flyer + Inventory)
## Mål
- Göra flyerflödet praktiskt användbart för jämförelse, redigering och planering till inköpslista.
- Säkerställa att inventory/pantry visar korrekta kategorier i stället för att allt hamnar i `Övrigt`.
## Scope (utifrån dina punkter)
1. Flyer-vy:
- Visa importerad flyer-PDF för jämförelse mot extraherade rader.
- Redigera poster (namn + kategori) med samma kategorikälla som products/pantry.
- `Planera X markerade` ska skapa en faktisk inköpslista i en egen flik.
2. Inventory:
- Felsöka och åtgärda varför poster visas under `Övrigt`.
---
## Nulägesbild (från kodbasen)
- Flutter har redan `FlyerImportTab` med:
- filval/import,
- checkboxar,
- `Planera X markerade` -> `POST /flyer-sessions/:id/selections/bulk`.
- PDF-visning finns idag bara för aktuell uppladdad fil i minnet (`_pickedFile.bytes`) och kan inte återöppnas säkert vid återställd session/app-omstart.
- Flyer-rader kan ännu inte redigeras i UI (ingen inline edit-dialog för flyer-item).
- Meal Plan har en shopping-sektion, men ingen dedikerad flik för flyer-planerade köp.
- Kategori-träd finns redan och används i flera vyer via `/categories/tree`.
- Inventory läser kategori via `product.categoryRef`; null blir `Övrigt` i UI.
---
## Genomförandeplan
### 1) Flyer-PDF: beständig förhandsvisning per session
**Backend**
- Lägg till lagring av originalfil för flyer-session (MVP: lokal filstore eller DB blob beroende på befintligt mönster i projektet).
- Utöka `flyer_session` metadata med filreferens (filnamn, mime, storlek, storageKey).
- Ny endpoint: `GET /flyer-import/sessions/:sessionId/source` (auth + ägarskap) som streamar PDF/bild.
**Flutter**
- I `FlyerImportTab`:
- använd befintlig local preview direkt efter uppladdning,
- när session återställs: hämta source-endpoint och visa `Visa flyer` även då.
- Behåll fallback-meddelande för plattformar som inte kan öppna PDF direkt.
**Klart-kriterium**
- Samma importerade flyer kan öppnas efter tab-byte och app-omstart för samma användare.
### 2) Redigering av flyer-poster (namn + kategori)
**Backend**
- Lägg till endpoint för uppdatering av flyer-item i session, t.ex.
- `PATCH /flyer-import/sessions/:sessionId/items/:itemId`
- fält: `rawName` (eller `displayName`) och `categoryId` (ev. `categoryHintPath` för visning).
- Validera att kategori finns i samma kategoriträd (`categories`).
- Ägarskapskontroll via sessionens `userId`.
**Flutter**
- I listan i `FlyerImportTab`: lägg till `Redigera`-action per rad.
- Edit-dialog:
- textfält för namn,
- kategori-väljare baserad på samma träd/komponentmönster som inventory/pantry/admin.
- Spara uppdatering till backend och uppdatera lokal/session state.
**Klart-kriterium**
- Användaren kan ändra namn och kategori på en flyer-rad och ser ändringen direkt i listan.
### 3) Flyer -> Inköpslista i egen flik
**Backend**
- Definiera enkel shopping-list-resurs för MVP (user-scoped):
- tabell t.ex. `shopping_list_item` (name/productId/categoryId/quantity/unit/source/status/userId).
- Ny endpoint för att skapa inköpsrader från flyer-selections:
- `POST /flyer-sessions/:sessionId/selections/plan-to-shopping-list`
- alternativt återanvänd bulk-create i shopping-modul.
- Deduplicering/regler:
- om samma `productId+unit` finns öppet: summera eller hoppa över (bestäms i implementation; rekommenderat: summera).
**Flutter**
- Lägg till ny flik/skärm `Inköpslista` i app-shell.
- `Planera X markerade` i flyer-vyn ska:
1) skapa/uppdatera flyer selections,
2) trigga backend-mappning till shopping-list,
3) visa snackbar med antal tillagda/uppdaterade rader.
- Inköpslista-vyn (MVP): lista rader + enkel check/avprickning.
**Klart-kriterium**
- Klick på `Planera X markerade` flyttar markerade flyer-produkter till Inköpslista-fliken.
### 4) Inventory-fel: allt i `Övrigt`
**Felsökning**
- Verifiera varför `product.categoryId/categoryRef` blir null i aktuella poster:
- skapade produkter utan kategori,
- importflöden som inte persistar vald kategori,
- äldre data utan backfill.
**Åtgärd**
- Säkerställ att produktskapande från import/edit alltid skickar/sätter kategori när sådan är vald.
- Lägg skydd i backend så kategori inte tappas vid update-flöden.
- Engångs-backfill för befintliga produkter utan kategori:
- använd befintlig kategoriseringslogik (regel/AI) + fallback till rimlig underkategori.
- Kör re-fetch/invalidations i Flutter inventory/pantry efter backfill.
**Klart-kriterium**
- Inventory/pantry visar blandade korrekta kategorier; endast okända poster ligger kvar i `Övrigt`.
---
## Testplan
### Backend
- Nya tester för:
- auth/ownership på source-endpoint och item-edit endpoint,
- validering av kategori-id,
- plan-to-shopping-list (antal skapade, dedupe, idempotens).
### Flutter widget/integration
- Flyer:
- render av `Visa flyer` efter restore,
- edit-dialog uppdaterar rad,
- `Planera X markerade` ger förväntad feedback.
- Inköpslista:
- ny flik syns,
- mottar rader från flyer.
- Inventory regression:
- kategori visas från product category path när satt,
- `Övrigt` endast fallback.
### E2E-checklista
- Importera flyer PDF -> öppna PDF -> redigera 2 rader -> planera markerade -> verifiera Inköpslista-fliken.
- Starta om app -> återöppna samma flyer-PDF -> verifiera ändringar kvar.
- Kontrollera inventory/pantry-kategorier efter backfill.
---
## Leveransordning (rekommenderad)
1. Inventory-kategori bugfix + backfill (snabbt värde, hög påverkan).
2. Flyer item-redigering (namn/kategori).
3. Inköpslista-flik + backend-mappning från flyer.
4. Beständig flyer-PDF source-visning (kan byggas parallellt med 2/3 om backendkapacitet finns).
---
## Risker och mitigering
- **Datamigrering/backfill-risk**: kör först mot staging + logga träffsäkerhet och antal fallback till `Övrigt`.
- **Dubbelposter i inköpslista**: inför tydlig dedupe-regel och testfall.
- **PDF-hantering per plattform**: behåll web-first öppning och tydligt fallback-meddelande där native viewer saknas.
- **Prestanda vid stora flyers**: paginera/virtuallista om UI blir tungt.
---
## Definition of Done
- Flyer-vyn har fungerande: PDF-visning, redigering av namn/kategori, planering till inköpslista.
- Inköpslista finns som separat flik och visar planerade flyer-rader.
- Inventory/pantry kategoriserar korrekt och `Övrigt` används endast som verklig fallback.
- Nya backend- och Flutter-tester gröna.
+369
View File
@@ -0,0 +1,369 @@
# Plan: Förbättrad dokumentationsstruktur för Recipe-app
## Bakgrund
Projektet har idag **20+ .md-filer** spridda över olika mappar, inklusive:
- **Aktiva filer** i rotmappen (t.ex. `TEKNISK_BESKRIVNING.md`, `NEXT_STEPS.md`).
- **Arkiverade filer** i `_archive/docs/` (t.ex. session-specifika anteckningar, gamla refaktoringsplaner).
- **Fragmenterade guider** (t.ex. `flyerimporter.md`, `filanalys.md`).
- **GitHub-specifika filer** (t.ex. `.github/copilot-instructions.md`).
**Problem**:
- **Svårt att hitta information**: Relaterat innehåll är splittrat (t.ex. Flutter-dokumentation i `_archive/docs/flutter/` vs. backend-dokumentation i rotmappen).
- **Föråldrat innehåll**: Arkiverade filer blandas med aktiva (t.ex. `SESSION_CHECKPOINT_2026-05-12.md`).
- **Icke-optimerat för språkmodeller**: Saknar tydlig hierarki och kontextuella länkar.
- **Duplicering**: Samma koncept (t.ex. "kategoriträd") dokumenteras på flera ställen.
---
## Mål med ny struktur
1. **Användarvänligt för utvecklare**:
- Logisk gruppering av innehåll (t.ex. "Backend", "Flutter", "Deploy").
- Tydliga **steg-för-steg-guider** för vanliga arbetsflöden.
- **Sökbart** med tydliga rubriker och nyckelord.
2. **Optimerat för språkmodeller (LLMs):
- **Kontextuell sammanhang**: Varje fil innehåller tillräcklig bakgrund för att förstå dess syfte.
- **Länkat innehåll**: Korsreferenser mellan filer för att ge fullständig bild.
- **Strukturera data**: Använd tabeller, listor och diagram för att göra informationen maskinläsbar.
3. **Underhållbart**:
- **Versionerat**: Tydlig separation mellan aktiv dokumentation och arkiv.
- **Modulärt**: Uppdateringar i en modul (t.ex. "Databas") påverkar inte andra.
- **Automatiseringsvänligt**: Filer som `CONTRIBUTING.md` och `README.md` kan genereras delvis från andra källor.
---
## Föreslagen struktur
```
/
├── docs/ # **Huvudkatalog för ALL aktiv dokumentation**
│ ├── 01-overview/ # Övergripande projektbeskrivning
│ │ ├── README.md # Huvudsaklig README (ersätter rot-README.md)
│ │ ├── ARCHITECTURE.md # Systemarkitektur (flytta från TEKNISK_BESKRIVNING.md)
│ │ └── GLOSSARY.md # Termer och definitioner (t.ex. "kategoriträd", "flyer session")
│ │
│ ├── 02-setup/ # Installation och konfiguration
│ │ ├── INSTALL.md # Miljökrav, beroenden, första uppstart
│ │ ├── CONFIG.md # Konfigurationsfiler (.env, Docker, etc.)
│ │ └── TROUBLESHOOTING.md # Vanliga problem och lösningar
│ │
│ ├── 03-development/ # Utvecklingsguider
│ │ ├── CONTRIBUTING.md # Bidragsregler, kodstandard, PR-process
│ │ ├── WORKFLOWS.md # Git-flöden, branch-strategi, CI/CD
│ │ ├── DATABASE.md # Schema, migrationer, seedning (flytta från TEKNISK_BESKRIVNING.md)
│ │ ├── API.md # Backend-API:er, Swagger-länkar, exempelanrop
│ │ ├── FLUTTER.md # Flutter-specifik dokumentation (flytta från _archive/docs/flutter/)
│ │ └── MICROSERVICES.md # Importer-AI, Todo-microservice, etc.
│ │
│ ├── 04-deploy/ # Driftsättning och underhåll
│ │ ├── DEPLOY.md # Steg-för-steg deploy (ersätter delar av TEKNISK_BESKRIVNING.md)
│ │ ├── MAINTENANCE.md # Underhållsskript, backup, monitorering
│ │ └── SCALING.md # Prestanda, skalning, lasttest
│ │
│ ├── 05-features/ # Djupdyk i funktioner
│ │ ├── RECIPE_IMPORT.md # Kvittosimport (ersätter flyerimporter.md)
│ │ ├── CATEGORY_TREE.md # Kategorihantering och L3-integration
│ │ ├── SHOPPING_LIST.md # Inköpslistor och flyer-integration
│ │ └── ... # Övriga funktioner (t.ex. måltidsplanering)
│ │
│ └── 06-archive/ # **Arkiverade dokument** (flytta hit från _archive/)
│ ├── sessions/ # Gamla sessionsanteckningar
│ ├── legacy/ # Föråldrade planer (t.ex. RECIPE_IMPORT_REFACTOR_PLAN.md)
│ └── ...
├── .github/ # GitHub-specifika filer
│ ├── COPILOT_INSTRUCTIONS.md # Flytta hit från .github/copilot-instructions.md
│ └── ...
└── ... # Övriga projektfiler (backend/, flutter/, etc.)
```
---
## Detaljerad filstruktur och innehåll
### 1. `docs/01-overview/`
| Fil | Syfte | Källor (befintliga filer) |
|-------------------|-----------------------------------------------------------------------|---------------------------------------------------|
| `README.md` | **Huvudsaklig ingress**: Projektbeskrivning, mål, snabbstart, länkar. | `README.md`, delar av `TEKNISK_BESKRIVNING.md` |
| `ARCHITECTURE.md` | **Systemarkitektur**: Komponentdiagram, databasrelationer, flöden. | `TEKNISK_BESKRIVNING.md` (avsnitt om arkitektur) |
| `GLOSSARY.md` | **Ordförklaringar**: Definitioner av projekt-specifika termer. | Ny fil |
**Exempelinnehåll för `ARCHITECTURE.md`**:
```markdown
# Systemarkitektur
```mermaid
graph TD
A[Flutter App] -->|HTTP/REST| B[Backend API]
B -->|Prisma Client| C[MariaDB]
B -->|gRPC| D[Importer Microservice]
D -->|HTTP| E[Externa API:er]
C -->|Seed| F[Initial Data]
```
## Komponenter
1. **Flutter App**:
- State management: Riverpod
- UI: Material Design + anpassade widgets
- Integreringar: Kamera (kvittoscan), PDF-rendering
2. **Backend API**:
- Ramverk: NestJS
- Databas: Prisma + MariaDB
- Autentisering: JWT
3. **Microservices**:
- Importer: Node.js + Puppeteer (för kvittosimport)
- AI: Python + Mistral (för kategorisering)
```
---
### 2. `docs/02-setup/`
| Fil | Syfte | Källor |
|-------------------|-----------------------------------------------------------------------|----------------------------------------------|
| `INSTALL.md` | **Miljösetup**: Krav, beroenden, första körning. | Delar av `README.md` och `TEKNISK_BESKRIVNING.md` |
| `CONFIG.md` | **Konfiguration**: `.env`-variabler, Docker-compose, nätverk. | `TEKNISK_BESKRIVNING.md` (avsnitt om miljö) |
| `TROUBLESHOOTING.md` | **Felsökning**: Vanliga fel, lösningar, debug-tips. | Ny fil |
**Exempelinnehåll för `INSTALL.md`**:
```markdown
# Installation
## Krav
| Komponent | Version | Notering |
|-----------------|---------------|-----------------------------------|
| Node.js | 24.15.0 | LTS-version rekommenderas |
| Docker | 24.x | Krävs för databas och microservices|
| Flutter | 3.41.9 | Kanal: stable |
| MariaDB | 11.x | Inkluderas via Docker-compose |
## Steg-för-steg
1. **Klona repo**:
```bash
git clone https://github.com/recipe-app/recipe-app.git
cd recipe-app
```
2. **Konfigurera miljö**:
```bash
cp .env.example .env
# Redigera .env (se CONFIG.md för detaljer)
```
3. **Starta tjänster**:
```bash
docker compose up -d
```
```
---
### 3. `docs/03-development/`
| Fil | Syfte | Källor |
|-------------------|-----------------------------------------------------------------------|----------------------------------------------|
| `CONTRIBUTING.md` | **Bidragsregler**: Kodstandard, PR-process, testkrav. | Ny fil (inspirerad av GitHub-standard) |
| `WORKFLOWS.md` | **Arbetsflöden**: Git-strategi, CI/CD, release-process. | `TEKNISK_BESKRIVNING.md` (avsnitt om Git) |
| `DATABASE.md` | **Databas**: Schema, migrationer, seedning, underhållsskript. | `TEKNISK_BESKRIVNING.md` + nya avsnitt |
| `API.md` | **Backend-API**: Endpoints, autentisering, exempelanrop. | Ny fil |
| `FLUTTER.md` | **Flutter-utveckling**: Widget-träd, state management, teman. | `_archive/docs/flutter/` |
| `MICROSERVICES.md`| **Microservices**: Importer, AI, kommunikation med backend. | Ny fil |
**Exempelinnehåll för `DATABASE.md`**:
```markdown
# Databas
## Schema
```mermaid
erDiagram
User ||--o{ Recipe : creates
User ||--o{ InventoryItem : owns
Category ||--o{ Product : "L3"
FlyerSession ||--o{ FlyerItem : contains
```
## Migrationer
### Standardflöde
1. Uppdatera `prisma/schema.prisma`.
2. Skapa migration:
```bash
npx prisma migrate dev --name add_feature_x
```
3. Testa lokalt:
```bash
npx prisma migrate reset
npx prisma db seed
```
### Underhållsskript
- **Rensa databas** (behåll kategorier):
```bash
./deploy.sh --clean-database
```
> Obs! Uppdatera `prisma/maintenance/clean-database.sql` när nya tabeller läggs till.
## Seedning
- **Initial data**: Laddas från `db/seeds/seed_all.sql`.
- **Kör seed**:
```bash
./deploy.sh --seed
```
```
---
### 4. `docs/04-deploy/`
| Fil | Syfte | Källor |
|-------------------|-----------------------------------------------------------------------|----------------------------------------------|
| `DEPLOY.md` | **Deploy-guider**: Steg-för-steg för staging/produktion. | `TEKNISK_BESKRIVNING.md` (avsnitt om deploy) |
| `MAINTENANCE.md` | **Underhåll**: Backup, monitorering, logghantering. | Ny fil |
| `SCALING.md` | **Skalning**: Prestandaoptimering, lasttest, caching. | Ny fil |
**Exempelinnehåll för `DEPLOY.md`**:
```markdown
# Driftsättning
## Miljöer
| Miljö | Domän | Syfte |
|-------------|---------------------|--------------------------------|
| Lokal | localhost:8080 | Utveckling |
| Staging | staging.app.com | Test перед prod |
| Produktion | app.recipe.com | Live-trafik |
## Steg-för-steg
1. **Bygg och pusha images**:
```bash
docker compose build
docker compose push
```
2. **Kör migrationer**:
```bash
./deploy.sh --backend --migrate
```
3. **Starta tjänster**:
```bash
docker compose up -d
```
## Vanliga flaggor
| Flagga | Beskrivning |
|-------------------|----------------------------------------------|
| `--migrate` | Kör Prisma-migrationer. |
| `--clean-database`| Rensar data (behåller kategorier). |
| `--seed` | Laddar initial data. |
```
---
### 5. `docs/05-features/`
| Fil | Syfte | Källor |
|-------------------|-----------------------------------------------------------------------|----------------------------------------------|
| `RECIPE_IMPORT.md`| **Kvittosimport**: Flyer-parsing, AI-kategorisering, lagring. | `flyerimporter.md` |
| `CATEGORY_TREE.md`| **Kategoriträd**: L3-integration, hierarki, synkronisering. | Delar av `TEKNISK_BESKRIVNING.md` |
| `SHOPPING_LIST.md`| **Inköpslistor**: Flyer-integration, kvantitetsberäkning, delning. | Ny fil |
**Exempelinnehåll för `RECIPE_IMPORT.md`**:
```markdown
# Kvittosimport
## Flöde
```mermaid
flowchart TD
A[Ladda upp PDF] --> B[Extrahera text]
B --> C[AI-kategorisering]
C --> D[Spara som FlyerSession]
D --> E[Mappa till inköpslista]
```
## API-endpoints
| Endpoint | Metod | Beskrivning |
|-----------------------------------|-------|--------------------------------------|
| `/api/flyer-sessions` | POST | Ladda upp och parsa PDF. |
| `/api/flyer-sessions/:id/items` | PATCH | Uppdatera produktnamn/kategori. |
| `/api/shopping-list/from-flyer` | POST | Konvertera flyer till inköpslista. |
## Underhåll
- **Uppdatera AI-modell**: Se `MICROSERVICES.md`.
- **Lägg till nya butiker**: Uppdatera `src/flyer-import/parsers/`.
```
---
### 6. `docs/06-archive/`
- **Syfte**: Flytta alla föråldrade filer hit, organiserade efter typ:
```
docs/06-archive/
├── sessions/ # Gamla sessionscheckpoints
│ ├── SESSION_2026-05-09_RECEIPT_IMPORT.md
│ └── ...
├── legacy/ # Föråldrade planer
│ ├── RECIPE_IMPORT_REFACTOR_PLAN.md
│ └── ...
└── flutter_legacy/ # Gamla Flutter-dokument
├── teknisk_beskrivning_flutter.md
└── ...
```
---
## Migrationsplan
### Steg 1: Rensa och organisera befintliga filer
| Åtgärd | Källfil(er) | Målfil |
|---------------------------------|---------------------------------------------|-----------------------------------------|
| Flytta och uppdatera | `TEKNISK_BESKRIVNING.md` | `docs/01-overview/ARCHITECTURE.md` |
| | `TEKNISK_BESKRIVNING.md` (deploy-avsnitt) | `docs/04-deploy/DEPLOY.md` |
| | `TEKNISK_BESKRIVNING.md` (databas-avsnitt) | `docs/03-development/DATABASE.md` |
| Flytta och slå samman | `flyerimporter.md` | `docs/05-features/RECIPE_IMPORT.md` |
| | `_archive/docs/flutter/*` | `docs/03-development/FLUTTER.md` |
| Arkivera | `_archive/docs/SESSION_*.md` | `docs/06-archive/sessions/` |
| | `MVP_CHECKLISTA.md` | `docs/06-archive/legacy/` |
| Uppdatera och flytta | `README.md` | `docs/01-overview/README.md` |
| | `.github/copilot-instructions.md` | `.github/COPILOT_INSTRUCTIONS.md` |
### Steg 2: Skapa nya filer
| Fil | Syfte |
|-----------------------------|-----------------------------------------------------------------------|
| `docs/03-development/CONTRIBUTING.md` | Standardiserade bidragsregler (branches, PR, code review). |
| `docs/03-development/API.md` | Dokumentation av backend-API:er (OpenAPI/Swagger-länkar). |
| `docs/03-development/MICROSERVICES.md` | Beskrivning av microservices (Importer, AI). |
| `docs/05-features/CATEGORY_TREE.md` | Djupdyk i kategorihantering och L3-integration. |
| `docs/04-deploy/MAINTENANCE.md` | Backup, monitorering, logghantering. |
### Steg 3: Lägg till kontext för språkmodeller
- **Länka filer**: Korsreferenser mellan relaterade ämnen (t.ex. länka från `DATABASE.md` till `DEPLOY.md` för migrationssteg).
- **Metadatarubriker**: Lägg till `## Syfte` och `## Målgrupp` i varje fil.
- **Diagram**: Använd Mermaid för att visualisera flöden och relationer.
- **Exempel**: Inkludera kopierbara kodblock för vanliga operationer.
---
## Fördelar med den nya strukturen
### För utvecklare
✅ **Lätt att hitta**: Logisk gruppering (t.ex. all Flutter-dokumentation på ett ställe).
✅ **Uppdaterad**: Arkiverade filer separerade från aktiv dokumentation.
✅ **Steg-för-steg**: Guider med tydliga exempel (t.ex. `INSTALL.md`).
### För språkmodeller (LLMs)
🤖 **Kontextuell förståelse**:
- Varje fil har ett tydligt syfte och målgrupp.
- Korsreferenser ger fullständig bild (t.ex. länka från `ARCHITECTURE.md` till `DATABASE.md`).
- Diagram och tabeller gör informationen maskinläsbar.
📚 **Sökbarhet**:
- Hierarkisk struktur (`01-overview/`, `02-setup/`, etc.) underlättar navigering.
- Nyckelord i rubriker (t.ex. "Flutter State Management" i `FLUTTER.md`).
🔗 **Länkat innehåll**:
- Relaterade ämnen länkas explicit (t.ex. "Se [DATABASE.md](../03-development/DATABASE.md) för schema").
---
## Nästa steg
1. **Godkänn planen**: Bekräfta att den föreslagna strukturen uppfyller kraven.
2. **Prioritera filer**: Vilka filer ska migreras först? (t.ex. `TEKNISK_BESKRIVNING.md` → flera nya filer).
3. **Implementera**: Skapa den nya strukturen och flytta innehåll stegvis.
+135
View File
@@ -0,0 +1,135 @@
# Plan: Separat Admin-AI huvudtabb + AI-insyn för Kvitto/Flyer
## Mål
- Flytta ut `AI` från `Admin > Databas` till en egen huvudtabb i admin (samma nivå som `Users` och `Database`).
- På nya `AI`-fliken visa två underflikar:
- `Kvitto` (AI-funktioner för receipt-import)
- `Flyer` (AI-funktioner för flyer-import)
- Ge admin insyn i:
- Prompt (vad modellen fick)
- Output (vad modellen returnerade)
- Per import/sessionshistorik
## Nuvarande läge (verifierat)
- Huvudtabbar i admin definieras i:
- `flutter/lib/features/admin/presentation/admin_screen.dart`
- `flutter/lib/core/ui/app_shell.dart`
- `AI` ligger idag som intern databas-tab i:
- `flutter/lib/features/admin/presentation/admin_database_panel.dart`
- Nuvarande `AdminAiPanel` visar bara modellinfo:
- `flutter/lib/features/admin/presentation/admin_ai_panel.dart`
- Flyer har sessions-API och sparar parsat resultat, men ingen API-yta för prompt/output:
- `backend/src/flyer-import/flyer-import.controller.ts`
- `backend/src/flyer-import/dto/flyer-import.response.ts`
- Receipt-import har ingen sessionhistorik i recipe-api och ingen prompt/output i svar:
- `backend/src/receipt-import/receipt-import.controller.ts`
- `backend/src/receipt-import/dto/parsed-receipt-item.dto.ts`
## UX-förslag (enkelt och snyggt)
- Ny huvudflik `AI` i admin med tydlig struktur:
- Topp: två chips/segmenterade knappar `Kvitto` och `Flyer`
- Innehåll: 2-kolumnslayout på desktop, enkel stack på mobil
- Vänster: lista med senaste importer (tid, användare, fil, status)
- Höger: detaljer för vald import
- Detaljvyn har tre kort:
1. **Prompt** (monospace, copy-knapp, expand/collapse)
2. **Model Output** (formatterad JSON, copy-knapp)
3. **Sammanfattning** (modell, duration, chunk/retry, warnings)
- Filter högst upp:
- Källa: Kvitto/Flyer
- Period: senaste 24h / 7d / 30d
- Endast fel
- Default-beteende:
- Välj senaste import automatiskt
- Visa läs-only data (ingen redigering av prompt i denna iteration)
## Föreslagen implementation
### 1) Flutter: ny Admin-huvudtabb AI
1. Utöka enum och query-hantering:
- `AdminViewTab` får `ai`
- query-stöd: `?tab=ai`
2. Uppdatera admin-title chips i `AppShell`:
- Lägg till `AI` bredvid `Användare` och `Databas`
3. Rendra ny panel i `AdminScreen`:
- `AdminAiPanel` blir panel för huvudtabben
4. Ta bort AI från `AdminDatabasePanel` interna tabs
Berörda filer:
- `flutter/lib/features/admin/presentation/admin_screen.dart`
- `flutter/lib/core/ui/app_shell.dart`
- `flutter/lib/features/admin/presentation/admin_database_panel.dart`
### 2) Flutter: bygg om `AdminAiPanel` till AI-observability
1. Inför underflikar `Kvitto` / `Flyer`
2. Lägg till vänster lista + höger detalj
3. Lägg till komponenter:
- `PromptCard` (text/copy)
- `OutputJsonCard` (pretty JSON/copy)
- `TraceMetaCard` (modell, tid, status)
4. Lägg till `adminRepository`-metoder för att hämta traces
Berörda filer:
- `flutter/lib/features/admin/presentation/admin_ai_panel.dart`
- `flutter/lib/features/admin/data/admin_repository.dart`
- `flutter/lib/core/api/api_paths.dart`
- ev. nya domänmodeller under `flutter/lib/features/admin/domain/`
### 3) Backend: exponera AI trace-data (admin-only)
1. Lägg till admin-endpoints för trace-lista + detalj
2. Returnera prompt/output samt metadata
3. Begränsa åtkomst till admin
Föreslagen API-yta:
- `GET /ai/traces?source=receipt|flyer&limit=...&cursor=...`
- `GET /ai/traces/:id`
### 4) Datalagring för prompt/output
För stabil UX behövs persistens, inte bara loggar.
Föreslagen modell:
- Ny Prisma-tabell `AiTrace`
- `id`, `source` (`receipt`/`flyer`), `userId`, `sessionId?`, `model`, `prompt`, `rawOutput`, `normalizedOutput`, `status`, `error`, `durationMs`, `createdAt`
Integration:
- Flyer: skapa trace i `AiFlyerParserService` vid varje AI-anrop/chunk (sammanfatta till en sessionsrad eller flera child-rader)
- Receipt: recipe-api behöver trace från importer-service eller egen instrumentation av prompt/output
## Viktig teknisk avgränsning (receipt)
- I nuvarande repo byggs flyer-prompt i `recipe-api` och är enkel att visa.
- Receipt-AI sker i importer-flöde; prompt/output finns sannolikt inte i recipe-api idag.
- Därför två realistiska steg:
1. **Steg 1 (snabbt):** full trace-visning för Flyer + modellinfo för Kvitto
2. **Steg 2:** utöka receipt/importer så prompt/output skickas eller lagras som trace och visas i samma UI
## Säkerhet
- Endast admin får läsa traces.
- Prompt/output kan innehålla känslig text från uppladdade filer:
- visa som read-only
- möjlighet till maskning av persondata i senare steg
- paginering + kort retention (t.ex. 30 dagar) rekommenderas
## Testplan
- Flutter widget-test:
- Ny huvudtabb `AI` syns och route `?tab=ai` fungerar
- Underflikar `Kvitto`/`Flyer` växlar korrekt
- Prompt/output renderas och copy-knappar fungerar
- Backend tester:
- admin-only behörighet på trace-endpoints
- lista/detalj svarar korrekt
- trace skapas för flyer parse-flöde
- Regression:
- Admin `Users`/`Database` fungerar oförändrat
## Genomförandeordning
1. Flytta AI till huvudtabb i Flutter (utan backend-ändring först)
2. Bygg ny AI-panelstruktur med underflikar
3. Inför backend trace-endpoints + Prisma-migration
4. Koppla Flyer trace end-to-end
5. Koppla Receipt trace (beroende på importer-instrumentation)
6. Sluttest + docs
## Rekommenderat beslut inför implementation
- Implementera i två faser för låg risk:
- Fas A: Ny UX + full Flyer-insyn direkt
- Fas B: Receipt prompt/output när importer-trace är tillgänglig
+154
View File
@@ -0,0 +1,154 @@
# Plan: Implementera automatiserad datarensning för AiTrace
## Mål
Implementera en automatiserad rensning av gamla `AiTrace`-poster för att säkerställa att känsliga data inte lagras längre än nödvändigt och för att följa GDPR-krav.
## Bakgrund
- `AiTrace`-tabellen lagrar känsliga data (t.ex. maskerade promptar och AI-svar) utan någon retention-policy.
- Risk för att data ackumuleras obehindrat, vilket kan leda till lagringsproblem och GDPR-brott.
- Enligt GDPR bör känsliga data rensas efter en viss tid om de inte längre behövs för felsökning eller analys.
## Krav
1. Rensa `AiTrace`-poster äldre än 30 dagar.
2. Schemalägg rensningen att köra dagligen vid midnatt.
3. Logga antalet rensade poster för övervakning.
4. Säkerställa att rensningen inte påverkar systemets prestanda.
## Implementeringsplan
### Steg 1: Installera beroenden
Installera `NestJS Schedule`-modulen för att möjliggöra schemalagda jobb.
**Kommando:**
```bash
npm install @nestjs/schedule
```
### Steg 2: Konfigurera ScheduleModule
Lägg till `ScheduleModule` i `AppModule` för att aktivera schemalagda jobb.
**Fil:** `backend/src/app.module.ts`
**Ändring:**
```typescript
import { ScheduleModule } from '@nestjs/schedule';
@Module({
imports: [ScheduleModule.forRoot()],
})
export class AppModule {}
```
### Steg 3: Skapa AiTraceCleanupService
Skapa en ny tjänst för att hantera rensningen av `AiTrace`-poster.
**Fil:** `backend/src/ai/ai-trace-cleanup.service.ts`
**Innehåll:**
```typescript
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class AiTraceCleanupService {
private readonly logger = new Logger(AiTraceCleanupService.name);
constructor(private readonly prisma: PrismaService) {}
@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
async cleanupOldTraces() {
this.logger.log('Starting cleanup of old AiTrace records...');
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
const result = await this.prisma.aiTrace.deleteMany({
where: {
createdAt: {
lt: thirtyDaysAgo,
},
},
});
this.logger.log(`Cleaned up ${result.count} old AiTrace records.`);
}
}
```
### Steg 4: Registrera AiTraceCleanupService
Lägg till `AiTraceCleanupService` i `AiTraceModule` för att aktivera tjänsten.
**Fil:** `backend/src/ai/ai-trace.module.ts`
**Ändring:**
```typescript
import { AiTraceCleanupService } from './ai-trace-cleanup.service';
@Module({
providers: [AiTraceService, AiTraceCleanupService],
})
export class AiTraceModule {}
```
### Steg 5: Testa rensningen manuellt
Skapa en testmetod för att manuellt köra rensningen och verifiera att den fungerar som förväntat.
**Fil:** `backend/src/ai/ai-trace.controller.ts`
**Ändring:**
```typescript
import { AiTraceCleanupService } from './ai-trace-cleanup.service';
@Controller('ai/traces')
export class AiTraceController {
constructor(
private readonly aiTraceService: AiTraceService,
private readonly aiTraceCleanupService: AiTraceCleanupService,
) {}
@Post('cleanup')
@Roles('admin')
async manualCleanup() {
await this.aiTraceCleanupService.cleanupOldTraces();
return { success: true, message: 'Manual cleanup completed.' };
}
}
```
### Steg 6: Verifiera implementationen
1. Skapa några testposter i `AiTrace`-tabellen med olika `createdAt`-datum.
2. Kör den manuella rensningen via `POST /api/ai/traces/cleanup`.
3. Verifiera att endast poster äldre än 30 dagar har rensats.
4. Kontrollera loggarna för att säkerställa att antalet rensade poster loggas korrekt.
### Steg 7: Dokumentera ändringarna
Uppdatera `TEKNISK_BESKRIVNING.md` med information om den automatiserade rensningen.
**Fil:** `TEKNISK_BESKRIVNING.md`
**Ändring:**
```markdown
## Automatiserad datarensning för AiTrace
För att säkerställa att känsliga data inte lagras längre än nödvändigt, har en automatiserad rensning implementerats:
- **Rensningsintervall**: Dagligen vid midnatt.
- **Retention-period**: 30 dagar.
- **Loggning**: Antalet rensade poster loggas för övervakning.
### Implementation
- **Tjänst**: `AiTraceCleanupService` i `backend/src/ai/ai-trace-cleanup.service.ts`.
- **Schemaläggning**: Använder `@nestjs/schedule` för att köra rensningen dagligen.
- **Manuell rensning**: Tillgänglig via `POST /api/ai/traces/cleanup` (kräver admin-behörighet).
### GDPR-efterlevnad
- Rensningen säkerställer att känsliga data raderas efter 30 dagar, vilket uppfyller GDPR-krav på dataminimering.
- Användare kan begära att deras data raderas tidigare via admin-panelen.
```
## Frågor och överväganden
1. **Retention-period**: Är 30 dagar en lämplig period, eller bör den justeras baserat påelsökningsbehov?
2. **Loggning**: Bör loggarna för rensningen sparas längre för revisionsändamål?
3. **Prestanda**: Bör rensningen köra under lågtrafikperioder för att minimera påverkan på systemet?
## Nästa steg
1. Implementera planen enligt ovan.
2. Testa rensningen i en testmiljö.
3. Verifiera att loggarna är korrekta och att rensningen fungerar som förväntat.
4. Dokumentera ändringarna i `TEKNISK_BESKRIVNING.md`.
5. Informera teamet om den nya funktionaliteten och dess påverkan på GDPR-efterlevnaden.
+215
View File
@@ -0,0 +1,215 @@
# Plan: Implementera användarinitierad radering av personuppgifter
## Mål
Skapa en tydlig och stegvis plan för hur användare kan ta bort sina personuppgifter från sin profil på plattformen, inklusive teknisk implementation och användarflöde.
## Bakgrund
- GDPR kräver att användare ska kunna begära radering av sina personuppgifter.
- Nuvarande plattform saknar ett tydligt användarflöde för att initiera radering av personuppgifter.
- Användare bör kunna ta bort sin profil och associerade data på ett säkert och transparent sätt.
## Krav
1. Lägg till en "Ta bort min profil"-knapp i användarens profilinställningar.
2. Implementera en bekräftelsedialog för att förhindra oavsiktlig radering.
3. Skapa en backend-endpoint för att hantera raderingsbegäran.
4. Se till att all användardata (profil, produkter, recept, etc.) raderas eller anonymiseras.
5. Logga raderingsbegäran för revisionsändamål.
6. Skicka ett bekräftelsemeddelande till användaren efter radering.
## Implementeringsplan
### Steg 1: Lägg till "Ta bort min profil"-knapp i Flutter UI
Lägg till en knapp i användarens profilinställningar som låter användaren initiera radering av sin profil.
**Fil:** `flutter/lib/features/profile/presentation/profile_screen.dart`
**Ändring:**
```dart
ElevatedButton(
onPressed: () {
_showDeleteProfileConfirmation();
},
child: Text('Ta bort min profil'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
),
)
```
### Steg 2: Implementera bekräftelsedialog
Skapa en dialog som kräver att användaren bekräftar raderingen.
**Fil:** `flutter/lib/features/profile/presentation/profile_screen.dart`
**Ändring:**
```dart
Future<void> _showDeleteProfileConfirmation() async {
return showDialog<void>(
context: context,
barrierDismissible: false,
builder: (BuildContext context) {
return AlertDialog(
title: Text('Bekräfta radering'),
content: SingleChildScrollView(
child: ListBody(
children: <Widget>[
Text('Är du säker på att du vill ta bort din profil?'),
Text('Alla dina data kommer att raderas permanent.'),
],
),
),
actions: <Widget>[
TextButton(
child: Text('Avbryt'),
onPressed: () {
Navigator.of(context).pop();
},
),
TextButton(
child: Text('Ta bort'),
onPressed: () {
_deleteProfile();
Navigator.of(context).pop();
},
),
],
);
},
);
}
```
### Steg 3: Skapa backend-endpoint för radering
Implementera en endpoint som hanterar raderingsbegäran och raderar all associerad data.
**Fil:** `backend/src/users/users.controller.ts`
**Ändring:**
```typescript
@Delete('me')
@UseGuards(JwtAuthGuard)
async deleteProfile(@CurrentUser() user: UserEntity) {
await this.usersService.deleteUserAndData(user.id);
return { success: true, message: 'Din profil och data har tagits bort.' };
}
```
### Steg 4: Implementera raderingslogik i UsersService
Skapa en metod som raderar användaren och all associerad data.
**Fil:** `backend/src/users/users.service.ts`
**Ändring:**
```typescript
async deleteUserAndData(userId: number) {
await this.prisma.$transaction([
this.prisma.product.deleteMany({ where: { ownerId: userId } }),
this.prisma.recipe.deleteMany({ where: { ownerId: userId } }),
this.prisma.inventoryItem.deleteMany({ where: { userId } }),
this.prisma.mealPlanEntry.deleteMany({ where: { userId } }),
this.prisma.user.delete({ where: { id: userId } }),
]);
this.logger.log(`User ${userId} and associated data deleted.`);
}
```
### Steg 5: Logga raderingsbegäran
Lägg till loggning för att spåra raderingsbegäran för revisionsändamål.
**Fil:** `backend/src/users/users.service.ts`
**Ändring:**
```typescript
async logDeletionRequest(userId: number, userEmail: string) {
await this.prisma.auditLog.create({
data: {
action: 'USER_DELETION',
userId,
email: userEmail,
metadata: { message: 'User initiated deletion of their profile and data.' },
},
});
}
```
### Steg 6: Skicka bekräftelsemeddelande
Skicka ett e-postmeddelande till användaren för att bekräfta raderingen.
**Fil:** `backend/src/users/users.service.ts`
**Ändring:**
```typescript
async sendDeletionConfirmationEmail(email: string) {
await this.emailService.sendEmail({
to: email,
subject: 'Bekräftelse på radering av din profil',
text: 'Din profil och alla associerade data har tagits bort från vår plattform.',
});
}
```
### Steg 7: Uppdatera Flutter för att anropa backend-endpoint
Implementera metoden för att anropa backend-endpoint för radering.
**Fil:** `flutter/lib/features/profile/presentation/profile_screen.dart`
**Ändring:**
```dart
Future<void> _deleteProfile() async {
try {
final response = await ApiService.delete('/users/me');
if (response.statusCode == 200) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Din profil har tagits bort.'));
);
Navigator.of(context).pushNamedAndRemoveUntil('/login', (route) => false);
}
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Ett fel uppstod vid radering av din profil.'));
);
}
}
```
### Steg 8: Testa implementeringen
1. Skapa en testanvändare i systemet.
2. Navigera till profilinställningar och klicka på "Ta bort min profil".
3. Bekräfta raderingen i dialogrutan.
4. Verifiera att användaren och all associerad data har tagits bort från databasen.
5. Kontrollera att ett bekräftelsemeddelande har skickats till användarens e-post.
6. Verifiera att raderingsbegäran har loggats i `AuditLog`.
### Steg 9: Dokumentera ändringarna
Uppdatera `TEKNISK_BESKRIVNING.md` med information om den nya funktionaliteten.
**Fil:** `TEKNISK_BESKRIVNING.md`
**Ändring:**
```markdown
## Användarinitierad radering av personuppgifter
För att uppfylla GDPR-krav har en funktion implementerats som låter användare ta bort sin profil och associerade data:
- **Användarflöde**: Användaren kan initiera radering via en knapp i profilinställningarna.
- **Bekräftelse**: En dialog kräver bekräftelse för att förhindra oavsiktlig radering.
- **Backend-endpoint**: `DELETE /users/me` hanterar raderingsbegäran.
- **Data som raderas**: Profil, produkter, recept, inventarieposter och matplaner.
- **Loggning**: Raderingsbegäran loggas i `AuditLog` för revisionsändamål.
- **Bekräftelse**: Ett e-postmeddelande skickas till användaren efter radering.
### Implementation
- **Frontend**: `flutter/lib/features/profile/presentation/profile_screen.dart`
- **Backend**: `backend/src/users/users.controller.ts` och `backend/src/users/users.service.ts`
- **Loggning**: `AuditLog`-poster skapas för varje raderingsbegäran.
- **E-postbekräftelse**: Skickas via `EmailService`.
### GDPR-efterlevnad
- Användare har full kontroll över sina personuppgifter.
- Raderingsprocessen är transparent och dokumenterad.
- All data raderas eller anonymiseras enligt GDPR-krav.
```
## Frågor och överväganden
1. **Dataretention**: Bör vissa data (t.ex. transaktionshistorik) sparas för revisionsändamål även efter radering?
2. **Anonymisering**: Bör vi anonymisera data istället för att radera dem helt?
3. **Ångerperiod**: Bör vi implementera en ångerperiod där användaren kan återställa sin profil inom en viss tid?
## Nästa steg
1. Implementera planen enligt ovan.
2. Testa funktionaliteten i en testmiljö.
3. Verifiera att all data raderas korrekt och att loggning fungerar.
4. Dokumentera ändringarna i `TEKNISK_BESKRIVNING.md`.
5. Informera teamet om den nya funktionaliteten och dess påverkan på GDPR-efterlevnaden.
@@ -0,0 +1,146 @@
# Plan: Projektanpassad Lighthouse-plan for Flutter-web
## Mål
Höja Lighthouse-resultaten för Flutter-webklienten i detta repo utan att bryta befintlig Docker/Caddy-deploy, med fokus på mätbar förbättring av prestanda, tillgänglighet och grundläggande SEO.
## Kontext och nuläge (verifierat i projektet)
- Flutter-web byggs redan i release-läge i `flutter/Dockerfile` via `flutter build web --release`.
- Webb klient körs via Caddy i `flutter/Caddyfile` och har redan `encode gzip`.
- `flutter/web/index.html` är minimal och innehåller redan korrekt viewport utan `user-scalable=no`.
- `flutter/web/` innehåller idag endast `index.html` (ingen `robots.txt`/`sitemap.xml` i Flutter-mappen).
- API-basurl injiceras redan korrekt med `--dart-define=API_BASE_URL=/api` via `compose.flutter.yml`.
## Problem i befintlig flutter-lighthouse.md som inte passar repo exakt
- Planen utgår delvis från Nginx/Apache, men projektet använder Caddy för Flutter-spåret.
- Påståendet om `user-scalable=no` gäller inte nuvarande `flutter/web/index.html`.
- Påståendet om ogiltig `robots.txt` kan inte verifieras i nuvarande Flutter-webmapp.
- Förslag om tvingad HTML-renderer är för grovt; bör vara experiment med mätning och rollback-kriterier.
## Strategi
Arbeta i tre iterationer med baseline -> lågriskoptimeringar -> tyngre optimeringar, och låt varje steg vara datadrivet.
---
## Fas 1: Baseline och mätprotokoll (dag 1)
1. Skapa en reproducerbar mätbaseline för både lokal container och produktionsdomän.
2. Kör Lighthouse minst 3 gånger per miljö och ta median för:
- Performance score
- LCP
- TBT
- INP (om rapporteras)
- Transfer size / antal requests
3. Dokumentera nuläge och tröskelvärden före ändringar.
### Acceptanskriterier Fas 1
- Baseline-tabell finns med mätvärden från 3 körningar per miljö.
- Samma URL, samma nätprofil och samma emulering används konsekvent.
---
## Fas 2: Lågriskfixar med hög nytta (dag 1-2)
### 2.1 Index och metadata (`flutter/web/index.html`)
- Lägg till `lang="sv"``<html>`.
- Lägg till relevant `meta description` för appens huvudsakliga nytta.
- Behåll `viewport` som den är (ingen ändring behövs kring zoom-blockering).
### 2.2 Caddy-headerhygien (`flutter/Caddyfile`)
- Behåll `encode gzip`.
- Lägg till explicita cache-headers för hashade statiska assets (js/wasm/fonts) med lång TTL och immutable.
- Lägg till kortare/konservativ cache för `index.html` så nya deploys slår igenom snabbt.
- Lägg till säkerhetsheaders som är kompatibla med Flutter-web (minst grundnivå: `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`).
### 2.3 Byggoptimering (`flutter/Dockerfile`)
- Utvärdera `--no-source-maps` i produktionsbuild för mindre artifact-storlek.
- Säkerställ att eventuella ändringar inte påverkar felsökning i miljö där sourcemaps behövs.
### Acceptanskriterier Fas 2
- Lighthouse visar förbättring i minst två nyckelmått (t.ex. LCP/TBT).
- Ingen regress i appstart, routning eller API-proxy `/api/*`.
- Build pipeline passerar oförändrat i Docker.
---
## Fas 3: Prestandaexperiment med tydlig rollback (dag 2-4)
### 3.1 Rendering-strategi (CanvasKit vs HTML/Skwasm)
- Kör A/B-test av rendererstrategi i en separat branch/buildvariant.
- Mät skillnad i initial transfer size, LCP och renderingkvalitet på kritiska vyer.
- Beslut endast baserat på mätdata + visuell/regressionskontroll.
### 3.2 Deferred loading i tunga featureflöden
- Identifiera kandidater för deferred imports (exempel: import/admin-vyer med hög kodvikt).
- Introducera gradvis och validera att navigation inte blir ryckig.
### 3.3 Bootstrap-laddning
- Behåll asynkron laddning i `index.html` om mätning visar bäst resultat.
- Undvik manuella hacks som injicerar canvaskit-script ad hoc utan evidens i mätning.
### Acceptanskriterier Fas 3
- Minst 15-25% förbättring i TBT eller tydlig minskning i JS-exekveringstid jämfört med baseline.
- Ingen funktionell regress i kärnflöden (login, inventarie, recept, import).
---
## Fas 4: Tillgänglighet (A11y) med repo-fokus (dag 3-5)
1. Inventera ikonknappar och actions i Flutter-kod och säkra `tooltip`/`semanticsLabel` där det saknas.
2. Lägg till/justera `Semantics` för centrala actions (import, spara, ta bort, navigering).
3. Verifiera tangentbordsnavigering i webbläsare för huvudflöden.
### Acceptanskriterier Fas 4
- Lighthouse Accessibility förbättras mätbart.
- Inga nya fokusfällor eller förlorad keyboard-navigering introduceras.
---
## Fas 5: SEO-minimum för app-shell (dag 4-5)
1. Säkerställ att titel och meta description är korrekta för startsidan.
2. Besluta om `robots.txt` och `sitemap.xml` ska hanteras i Flutter-web, Caddy eller upstream-domänkonfiguration (inte antagande).
3. Implementera endast den väg som matchar faktisk domänrouting i drift.
### Acceptanskriterier Fas 5
- Lighthouse SEO får förbättring från baseline.
- Robots/sitemap-lösning är verifierad mot faktisk driftarkitektur.
---
## Fas 6: Säkerhet och CSP (dag 5)
1. Introducera en pragmatisk CSP för Flutter-web i Caddy med minsta nödvändiga undantag.
2. Testa särskilt att Flutter bootstrap, API-anrop och ev. externa resurser fungerar.
3. Strama åt policyn iterativt istället för en aggressiv engångspolicy.
### Acceptanskriterier Fas 6
- Säkerhetsheaders levereras korrekt från Flutter-Caddy.
- Ingen blockerad kärnfunktion pga CSP.
---
## Prioriterad genomförandeordning
1. Fas 1 (baseline)
2. Fas 2 (lågriskfixar)
3. Re-mätning
4. Fas 3 (prestandaexperiment)
5. Fas 4 (tillgänglighet)
6. Fas 5 (SEO-minimum)
7. Fas 6 (CSP hardening)
8. Slutlig Lighthouse-jämförelse och dokumentation
## Definition of Done
- Reproducerbar före/efter-mätning finns dokumenterad.
- Performance, Accessibility och SEO har förbättrats jämfört med baseline.
- Inga regressioner i Docker/Caddy-flödet eller appens kärnflöden.
- Åtgärderna är anpassade till faktisk stack (Flutter + Caddy), inte generiska Nginx-råd.
## Konkreta filer som sannolikt berörs vid implementation
- `flutter/web/index.html`
- `flutter/Caddyfile`
- `flutter/Dockerfile`
- ev. flera Flutter-vyer med knapp-/ikonsemantik under `flutter/lib/**`
## Risker och motåtgärder
- **Risk:** För aggressiv CSP bryter Flutter-bootstrap.
**Motåtgärd:** Iterativ policy + verifiering efter varje ändring.
- **Risk:** Rendererbyte förbättrar vikt men försämrar visual fidelity.
**Motåtgärd:** A/B-test med tydliga rollback-kriterier.
- **Risk:** Cache-policy gör deploys "stale".
**Motåtgärd:** Lång cache endast för fingerprintade assets, kort cache för `index.html`.
+346
View File
@@ -0,0 +1,346 @@
# Plan: Flyerimport - specialtecken, beskrivande felmeddelanden, synlig prompt
## Mål
Tre sammanhängande förbättringar av flyerimport-pipelinen så att användaren får begripligt feedback och korrekt data:
1. Säkerställ att svenska tecken (ä, å, ö, é) bevaras i produktnamn som "Prästost", "Herrgårdsost", "Grevéost".
2. Ersätt opaka koder som `parse:ai_parsed` och `match:no_match` med människovänliga svenska förklaringar som beskriver "vad" som hände och "var" det hände.
3. Visa promptens innehåll (inte bara output) för operatörer/admins och vid behov i importflödet vid varningar.
Allt arbete ska respektera nuvarande arkitektur: NestJS backend (`backend/src/flyer-import`, `backend/src/ai`), Flutter web frontend (`flutter/lib/features/import`, `flutter/lib/features/admin`).
---
## Verifierad nulägesbild (källkodsbevis)
### Specialtecken
- `ai-flyer-parser.service.ts:189-293` skickar prompt utan diakritiska tecken (skriver "Prastost", "Herrgardsost", "Greveost", "ARLA KO", "ä" undviks medvetet i prompt-instruktionerna). Detta gör att modellen tränas/uppmuntras att returnera namnen utan ä/å/é.
- `ai-flyer-parser.service.ts:358-364` (`normalizeName`) tar bort allt som inte är `[a-zåäö0-9\s]` men `rawName` skickas vidare som det är, så ä/å bevaras tekniskt om AI returnerar dem.
- `flyer-normalizer.service.ts:26-30` har en hårdkodad mappning för cheese variants:
- `prast: 'Prästost'`
- `herrgard: 'Herrgårdsost'`
- `greve: 'Greveost'` (saknar `é`! Bör vara `Grevéost`)
- `flyer-normalizer.service.ts:196-227` (`expandCheeseVariants`) gör `stripDiacritics` på rawName innan den jämför med token-list. Detta funkar för matchning men producerar diakrit-fria varianter om mappningen inte är korrekt.
- `flyer-normalizer.service.ts:140-146` (`normalizeName`) fungerar med `[a-zåäö0-9\s]` så svenska tecken behålls i normaliserat namn när inputen har dem.
### "parse:ai_parsed" och "match:no_match"
- Konstanter genereras som "reason codes" i koden:
- `ai-flyer-parser.service.ts:354`: `reasonCodes: ['ai_parsed']` - sätts på alla AI-parsade items.
- `flyer-import.service.ts:496`: `reasons: ['no_match']` - sätts när inget produktnamn matchar.
- Reason codes prefixas med `parse:` eller `match:` av `ai-trace.service.ts:435-452` (`collectWarnings`):
```ts
warnings.add(`parse:${text}`);
warnings.add(`match:${text}`);
```
- Dessa visas i admin AI-traces-vyn (`admin_ai_panel.dart:355-362`, `_WarningsCard`) och möjligen i import-UI:t via `_buildWarningsPanel` (`flyer_import_tab.dart:497-539`) om `_result.warnings` innehåller dem.
- Slutsats: koderna är inte fel - de är obegripliga utan översättning.
### Promptens synlighet
- Admin AI-panelen (`admin_ai_panel.dart:444-508`, `_PromptCard`) visar redan prompt med expand/copy-knappar.
- Importflödet (`flyer_import_tab.dart`, `receipt_import_tab.dart`) visar idag bara `warnings`-listan. Ingen promptvisning, ingen mappning från reason codes till begripligt språk, ingen länk till AI-trace.
- AI-trace lagras alltid i DB (`flyer-import.service.ts:148-164`, `persistFlyerTrace`). Promptens innehåll finns alltså redan tillgängligt.
---
## Strategi och faser
Tre paralleliserbara delprojekt med varsin egen fas. Slutlig sammanflätning verifieras med befintliga tester och ny smoke-test.
---
## Fas A: Specialtecken i produktnamn (lågriskfix, hög nytta)
### A1. Korrigera hårdkodad cheese-mappning
- `backend/src/flyer-import/services/flyer-normalizer.service.ts:26-30`
- Ändra `greve: 'Greveost'` till `greve: 'Grevéost'`.
- Behåll `prast: 'Prästost'` och `herrgard: 'Herrgårdsost'`.
### A2. Skydda diakritiska tecken hela vägen från AI-svar till klient
- `backend/src/flyer-import/services/ai-flyer-parser.service.ts`
- Säkerställ att Mistralklientens response.choices[0].message.content tolkas som UTF-8. Logga hex-dump i debug-läge om tecken förvanskas.
- I `sanitizeJsonResponse`/`normalizeAiItem`: säkerställ att `rawName` inte normaliseras eller stripps innan `normalize`-steget.
- Uppdatera prompten:
- Lägg till explicit instruktion: `Behåll svenska diakritiska tecken (ä, å, ö, é) i produktnamn. Returnera "Prästost", "Herrgårdsost", "Grevéost" - inte ASCII-versioner.`
- Uppdatera exempel-utdata i prompten (rad 256-286) från "Prastost"/"Herrgardsost" till "Prästost"/"Herrgårdsost"/"Grevéost" så modellen tränas att returnera korrekt.
- Behåll fortfarande `prast/herrgard/greve` som tokens i instruktion 9 men kräv normalisering till diakrit-versionen i utdatan.
- `backend/src/flyer-import/services/flyer-normalizer.service.ts`
- I `expandCheeseVariants` (rad 196-227): efter `stripDiacritics`-tokenisering, mappa via `CHEESE_VARIANT_TO_NAME` så slutnamnet alltid har korrekta tecken.
- I `fixKnownOcrTypos` (rad 250-262): lägg till regel som korrigerar "Greveost" -> "Grevéost" (men bara när det är klart att det är ostnamnet, inte personnamn). Använd kontext: bara om `category` antyder hårdost/ost eller `rawName` slutar på `ost`.
### A3. Säkra Caddy/HTTP-respons för UTF-8
- Verifiera att `Content-Type: application/json; charset=utf-8` returneras från NestJS (default i `@nestjs/platform-express`, men kontrollera att ingen middleware skriver om).
- Caddy gör inte byteomvandling som standard, men dubbelkolla att `flutter/Caddyfile` inte har någon `replace`-direktiv (det har det inte i nuläget).
### A4. Test
- Utöka `flyer-normalizer.service.spec.ts` med:
- Test som matar in rawName "PRAST" och kontrollerar att outputens rawName blir "Prästost" (inte "Prastost").
- Test som matar in rawName "GREVE" och kontrollerar att outputens rawName blir "Grevéost".
- Test som verifierar att `é`-tecken bevaras i hela pipen.
- Snapshot-test för prompten i `ai-flyer-parser.service.spec.ts` så att ändringar i prompten är medvetna.
### Acceptanskriterier Fas A
- En flyer som innehåller "PRAST, HERRGARD, GREVE" producerar rader med rawName `Prästost`, `Herrgårdsost`, `Grevéost`.
- Ingen befintlig test går sönder.
- UI:t (Flutter) visar de korrekta tecknen utan encoding-artifacts.
### Risker
- Mistral kan ignorera diakritiska tecken i instruktioner. Motåtgärd: post-normalisering i `flyer-normalizer.service.ts` är hårda fallback-regeln.
---
## Fas B: Beskrivande felmeddelanden ersätter `parse:ai_parsed` och `match:no_match`
### B1. Centraliserad reason-code-katalog (backend)
- Skapa `backend/src/flyer-import/services/reason-codes.ts` (eller `backend/src/ai/reason-codes.ts` om det är delat med receipt) som exporterar:
- `ParseReasonCode = 'ai_parsed' | 'split_cheese_variants' | 'normalized' | 'low_confidence' | ...`
- `MatchReasonCode = 'no_match' | 'alias_exact' | 'normalized_exact' | 'token_overlap' | 'alias_points_to_missing_product' | 'empty_name'`
- Funktion `describeParseReason(code, context?): { title, message, severity, location? }`
- Funktion `describeMatchReason(code, context?): { title, message, severity }`
- Exempelmappning:
- `ai_parsed` -> `severity: 'info', title: 'AI-tolkad rad', message: 'Raden tolkades av AI utan att en deterministisk regel matchade.'`
- `split_cheese_variants` -> `severity: 'info', title: 'Variant-split', message: 'AI-svarade en gruppannons som expanderades till individuella ostvarianter.'`
- `low_confidence` -> `severity: 'warning', title: 'Låg parsningskvalitet', message: 'Modellens säkerhet är låg, granska raden manuellt.'`
- `no_match` -> `severity: 'warning', title: 'Ingen produktmatchning', message: 'Vi kunde inte hitta någon befintlig produkt som matchar texten på flyern.'`
- `alias_points_to_missing_product` -> `severity: 'error', title: 'Trasig alias-koppling', message: 'Ett alias pekar på en produkt som inte längre finns.'`
- `empty_name` -> `severity: 'error', title: 'Tomt produktnamn', message: 'Raden saknar tolkbart produktnamn.'`
- Inkludera `location` när relevant: t ex `'Steg: AI-parser, chunk N/M'` eller `'Steg: matchning mot dina produkter'`.
### B2. Returnera strukturerade reasons i API
- Utöka `backend/src/flyer-import/dto/flyer-import.response.ts`:
```ts
export type FlyerReasonDescriptor = {
code: string; // 'ai_parsed', 'no_match', ...
kind: 'parse' | 'match';
title: string; // Människovänlig titel
message: string; // Förklarande text
severity: 'info' | 'warning' | 'error';
location: string | null; // T ex 'Steg: matchning mot dina produkter'
};
```
- Lägg till på `FlyerImportItem`:
- `parseReasonsDetailed: FlyerReasonDescriptor[]`
- `matchReasonsDetailed: FlyerReasonDescriptor[]`
- Behåll befintliga `parseReasons: string[]` och `matchReasons: string[]` för bakåtkompatibilitet.
- I `flyer-import.service.ts:110-144` (där `FlyerImportItem` byggs): mappa via `describeParseReason`/`describeMatchReason`.
- Detsamma för session-läs-paths (`toFlyerImportItem` rad 721-799 och `toFlyerImportResponseFromSession`).
### B3. Uppdatera AI-trace warnings (admin)
- `backend/src/ai/ai-trace.service.ts:435-452` (`collectWarnings`):
- Ändra till att returnera strukturerade objekt istället för `parse:xxx`/`match:xxx`-strängar:
```ts
type AdminAiWarning = {
code: string;
kind: 'parse' | 'match';
title: string;
message: string;
severity: 'info' | 'warning' | 'error';
itemIndex?: number; // Pekar på vilken rad i sessionen
};
```
- Uppdatera `AdminAiTraceDetail.warnings` schema till strukturerat format.
- Bibehåll en `legacyWarnings: string[]` med gamla formatet ifall någon klient ännu inte uppdaterats.
### B4. Uppdatera Flutter-modeller och vyer
- `flutter/lib/features/import/domain/flyer_import_item.dart`:
- Lägg till `parseReasonsDetailed: List<FlyerReasonDescriptor>` och `matchReasonsDetailed: List<FlyerReasonDescriptor>` med `fromJson`/`toJson`.
- Skapa `flutter/lib/features/import/domain/flyer_reason_descriptor.dart`:
- `class FlyerReasonDescriptor { final String code; final String kind; final String title; final String message; final String severity; final String? location; ... }`
- `flutter/lib/features/import/presentation/flyer_import_tab.dart`:
- I `_buildWarningsPanel` (rad 497-539): visa enbart sessionens egentliga warnings (existerande) men gör dem klickbara så de kan kopieras.
- Per rad: visa en summerande badge "X varningar" som expanderar till en lista med titel + message istället för tekniska koder. Använd ikoner per severity (info/warning/error).
- Behåll befintlig kvalitetsbadge (Hög/Medel/Låg).
- `flutter/lib/features/admin/presentation/admin_ai_panel.dart`:
- `_WarningsCard` (rad 583+): byt ut SelectableText med rå-strängar mot strukturerad rendering: titel (fet), message (vanlig), severity-färg, eventuellt itemIndex som länk.
- Behåll copy-funktion - kopierar då en formaterad sträng `"[severity] title: message"`.
### B5. Test
- Backend: enhetstest för `describeParseReason`/`describeMatchReason` som täcker alla codes.
- Backend: integrationstest som verifierar att `FlyerImportResponse` innehåller `parseReasonsDetailed` med rätt fält.
- Flutter: widget-test för warnings-panel som verifierar att `parse:ai_parsed` ALDRIG visas, utan ersätts av "AI-tolkad rad".
- Uppdatera `flutter/test/features/admin/presentation/admin_ai_panel_test.dart` som idag verifierar `find.text('parse:low_confidence')` - testet ska istället leta efter "Låg parsningskvalitet".
### Acceptanskriterier Fas B
- Inga `parse:xxx`- eller `match:xxx`-strängar visas i UI:t (varken admin eller import).
- Varje warning har: titel, beskrivande text, severity-ikon, och om relevant en location ("Steg: ...").
- API:et returnerar både legacy- och nytt format, så ingen klient bryts.
### Risker
- Översättning av code -> human text måste hållas i sync mellan backend och Flutter. Motåtgärd: sätt textmappningen ENBART i backend och returnera färdig string. Flutter renderar bara.
---
## Fas C: Synlig prompt i import-flödet och vid varningar
### C1. Bestäm synlighetspolicy
- Operatörer/admins ska kunna se prompten direkt i admin AI-panelen (finns redan, fortsätter fungera).
- Vanliga användare ska kunna se prompten **efter behov** för att felsöka eller rapportera fel - men inte by default eftersom prompten är teknisk.
- Föreslagen UX: när en rad har varningar (parse eller match), visa knapp "Visa AI-detaljer" som öppnar modal med:
- Använd modell + retry/chunk-info
- Prompten (expanderbar)
- AI-svar (raw output, expanderbar)
- Lista över alla parse/match-reasons med deras `describeXxxReason`-output
- Promptvisning i adminpanelen redan komplett (`_PromptCard`) - bara verifiera att det fortsätter funka efter Fas B.
### C2. Backend-ändringar
- Ny endpoint `GET /api/flyer-import/sessions/:sessionId/ai-trace` som returnerar:
```ts
{
sessionId: number;
model: string;
prompt: string;
rawOutput: string;
chunkCount: number | null;
retryCount: number | null;
durationMs: number | null;
status: 'success' | 'warning' | 'error';
warnings: AdminAiWarning[];
}
```
- Återanvänd existerande `aiTrace`-tabellen. Auth: kräv att `userId` ägar sessionen (samma policy som `getSessionSource`).
- Eventuellt feature-flagga visa-prompt-för-användare (`FLYER_AI_USER_PROMPT_VISIBLE` env). Default: `true` för admin, `true` för användare som äger sessionen.
### C3. Flutter-ändringar
- `flutter/lib/features/import/data/import_repository.dart`: ny metod `getFlyerSessionAiTrace(sessionId, token)`.
- `flutter/lib/features/import/domain/`: ny modell `FlyerAiTrace`.
- `flutter/lib/features/import/presentation/flyer_import_tab.dart`:
- Lägg till en "AI-detaljer"-knapp i headern (efter Importera-knappen) eller i varje rads expanderingspanel.
- Visa prompt + rawOutput i en modal/expanderbar Card med samma look-and-feel som admin (`_PromptCard` + `_OutputJsonCard` kan extraheras till delad widget under `flutter/lib/features/import/presentation/widgets/ai_trace_view.dart`).
- Lägg till samma åtgärd för `receipt_import_tab.dart` om motsvarande backend-stöd finns (det finns - receipts har också AI-trace).
### C4. Säkerhet och PII
- Innan prompt visas till slutanvändare: kör `maskSensitiveText` (finns redan i `ai-trace.service.ts`).
- Alla loggade prompts/rawOutputs ska redan vara maskerade i nuvarande pipeline. Verifiera att det fortsätter gälla.
### C5. Test
- Backend: e2e-test för nya endpointen, inklusive 403 för icke-ägare och 200 för ägare.
- Flutter: widget-test för att modalen öppnas och visar prompten.
- Manuell QA: Importera en flyer, klicka "AI-detaljer", verifiera att prompten visas och att kopiera-knappen fungerar.
### Acceptanskriterier Fas C
- Användare kan se prompten som skickades vid sin egen flyerimport.
- Adminpanelen visar fortfarande prompten oförändrat.
- PII-mask appliceras innan prompten skickas till klienten.
### Risker
- Prompten är lång (>5000 tecken). Motåtgärd: använd existerande expand/collapse-mönster (`_PromptCard.expanded`).
- Prompten kan innehålla användardata. Motåtgärd: maskning + tydlig "Detta innehåller text från din flyer"-disclaimer.
---
## Prioriterad genomförandeordning
1. **Fas A** (specialtecken) - lågrisk, snabb seger för UX.
2. **Fas B** (mänskliga felmeddelanden) - medelarbete, hög UX-impact.
3. **Fas C** (prompt-synlighet) - mer komplex pga ny endpoint + UI, men oberoende av A och B.
Faserna kan implementeras i parallella PR:er om så önskas; Fas A och B berör delvis samma kodvägar i `flyer-normalizer.service.ts` och `flyer-import.service.ts`, och bör samordnas så att en PR mergas före nästa.
---
## Definition of Done
- "Prästost", "Herrgårdsost", "Grevéost" och `é`/`å`/`ä` syns korrekt i flyerimport-UI.
- Inga råa `parse:ai_parsed`/`match:no_match`-strängar visas för användare eller admin.
- Användare och admin kan se vilken prompt som skickades till AI.
- Befintliga tester passerar; nya tester täcker varje fas separat.
- Inga regressioner i Docker-bygget (`backend/Dockerfile` kör `npm test -- --runInBand`).
---
## Konkreta filer som berörs
Backend:
- `backend/src/flyer-import/services/flyer-normalizer.service.ts` (Fas A)
- `backend/src/flyer-import/services/ai-flyer-parser.service.ts` (Fas A, eventuellt B)
- `backend/src/flyer-import/services/reason-codes.ts` (ny, Fas B)
- `backend/src/flyer-import/dto/flyer-import.response.ts` (Fas B)
- `backend/src/flyer-import/flyer-import.service.ts` (Fas B, Fas C)
- `backend/src/flyer-import/flyer-import.controller.ts` (Fas C, ny endpoint)
- `backend/src/ai/ai-trace.service.ts` (Fas B)
- `backend/src/flyer-import/services/flyer-normalizer.service.spec.ts` (Fas A)
- `backend/src/flyer-import/services/ai-flyer-parser.service.spec.ts` (Fas A, B)
Flutter:
- `flutter/lib/features/import/domain/flyer_import_item.dart` (Fas B)
- `flutter/lib/features/import/domain/flyer_reason_descriptor.dart` (ny, Fas B)
- `flutter/lib/features/import/domain/flyer_ai_trace.dart` (ny, Fas C)
- `flutter/lib/features/import/data/import_repository.dart` (Fas C)
- `flutter/lib/features/import/presentation/flyer_import_tab.dart` (Fas A-C)
- `flutter/lib/features/import/presentation/widgets/ai_trace_view.dart` (ny, Fas C)
- `flutter/lib/features/admin/domain/admin_ai_trace_detail.dart` (Fas B)
- `flutter/lib/features/admin/presentation/admin_ai_panel.dart` (Fas B)
- `flutter/test/features/admin/presentation/admin_ai_panel_test.dart` (Fas B)
---
## Riskanalys och rollback
| Risk | Sannolikhet | Motåtgärd |
| --- | --- | --- |
| Mistral returnerar fortfarande ASCII-versioner | Medel | Hård post-normalisering i `flyer-normalizer.service.ts` (Fas A2) |
| Strukturerade reasons bryter befintlig admin-vy | Låg | Behåll legacy-format parallellt under en release |
| Promptens längd försämrar mobil-UX | Låg | Default collapsed med expand-knapp |
| AI-trace exponerar PII oavsiktligt | Medel | Återanvänd befintlig `maskSensitiveText` + tydlig disclaimer |
| Ändrade reason codes bryter andra konsumenter (t ex flyer-selection-matcher) | Låg | Sökning visar att `reasonCodes` används endast i flyer-import + ai-trace; uppdatera samtliga callsites i samma PR |
---
## Beslut tagna med dig
1. **Promptens synlighet**: endast admin. Vanliga användare ser inte prompten - bara översatta reasons. Detta förenklar Fas C avsevärt: ingen ny användarendpoint, ingen PII-mask för slutanvändare, ingen ny användar-UI.
2. **Reason-codes översätts i backend**: backend returnerar färdig svensk text (`title`, `message`) i `FlyerReasonDescriptor`. Frontend renderar bara strängarna utan översättning. Lang-parameter förbereds för framtida flerspråksstöd.
3. **Kopiera felrapport-knapp**: ja. Lägg till "Kopiera felrapport"-knapp i admin AI-panelens detail-vy som producerar formaterad text:
```
[AI-trace flyer-123]
Modell: ministral-8b-2512
Status: warning (3 varningar)
Tid: 2026-05-23T20:12:00
Varningar:
- [warning] Ingen produktmatchning (rad 5): Vi kunde inte hitta...
- [info] AI-tolkad rad (rad 7): Raden tolkades av AI...
Prompt:
...
Raw output:
...
```
4. **Stavning**: `Grevéost` med `é` (Arlas officiella stavning).
## Konsekvenser av besluten på faserna
### Fas A (oförändrad)
- `Grevéost` används i mappningen.
### Fas B (oförändrad)
- Backend översätter och returnerar färdig svensk text i `title`/`message`.
- Behåll `code`-fältet så frontend kan filtrera/rendera olika per typ.
### Fas C (förenklad)
- **Tas bort**: Ny användarendpoint `GET /api/flyer-import/sessions/:id/ai-trace`.
- **Tas bort**: Ny Flutter-modell `FlyerAiTrace` och repository-metod för användarvy.
- **Tas bort**: Ny widget `ai_trace_view.dart` för importflödet.
- **Behålls**: Adminpanelens promptvisning (`_PromptCard`) - finns redan, fungerar.
- **Läggs till**: "Kopiera felrapport"-knapp i adminpanelens detail-vy. Genererar formaterad text enligt mallen ovan.
- **Användarflöde uppdateras**: i flyer/receipt-import-tabben visa endast översatta reasons via Fas B; ingen prompt-knapp för användare.
## Uppdaterade konkreta filer (efter beslut)
Backend:
- `backend/src/flyer-import/services/flyer-normalizer.service.ts` (Fas A)
- `backend/src/flyer-import/services/ai-flyer-parser.service.ts` (Fas A)
- `backend/src/flyer-import/services/reason-codes.ts` (ny, Fas B)
- `backend/src/flyer-import/dto/flyer-import.response.ts` (Fas B)
- `backend/src/flyer-import/flyer-import.service.ts` (Fas B)
- `backend/src/ai/ai-trace.service.ts` (Fas B)
- `backend/src/flyer-import/services/flyer-normalizer.service.spec.ts` (Fas A)
- `backend/src/flyer-import/services/ai-flyer-parser.service.spec.ts` (Fas A)
- *Inte längre*: ny endpoint i `flyer-import.controller.ts` (utgår med beslut 1).
Flutter:
- `flutter/lib/features/import/domain/flyer_import_item.dart` (Fas B)
- `flutter/lib/features/import/domain/flyer_reason_descriptor.dart` (ny, Fas B)
- `flutter/lib/features/import/presentation/flyer_import_tab.dart` (Fas A-B)
- `flutter/lib/features/admin/domain/admin_ai_trace_detail.dart` (Fas B)
- `flutter/lib/features/admin/presentation/admin_ai_panel.dart` (Fas B + felrapportknapp)
- `flutter/test/features/admin/presentation/admin_ai_panel_test.dart` (Fas B)
- *Inte längre*: `flyer_ai_trace.dart`, `ai_trace_view.dart`, repository-metod (utgår med beslut 1).
+198
View File
@@ -0,0 +1,198 @@
# Plan: Harmonisera flyer-import och kvitto-import
## Mål
Implementera en gemensam importmodell och matchningspipeline så att flyer-import och kvitto-import beter sig så likt som möjligt, med fokus på:
- Automatisk strukturering av namn/brand/vikt samt bundle-detaljer
- Automatiskt kategoriupplösning (`categoryHint -> categoryId`)
- Matchning mot befintliga produkter via normaliserade namn + signaler
- Ingen automatisk skapning av produkter
- Förberedelse för framtida automation via strukturerade signaler (`signals` JSON)
## Icke-mål (denna implementation)
- Ingen auto-create av produkter i produktkatalog
- Ingen ändring av övergripande UI-flöde (manuell import/validering kvar)
- Ingen full omskrivning av receipt-import; vi extraherar och återanvänder delar stegvis
## Nuvarande gap (från kodbasen)
1. `FlyerItem.categoryId` sätts till `null` i parse-flödet trots `categoryHint`.
2. Flyer-matchning använder enklare strategi än receipt-import (färre regler/signalvikter).
3. Ingen strukturerad lagring av ursprung/etiketter (t.ex. Sverige, Eko) i flyer.
4. Bundleinformation finns men exponeras inte tydligt som detaljnamn i payload.
5. Receipt och flyer använder olika “kontrakt” för mellanrepresentation.
## Övergripande design
Inför en gemensam intern domänmodell för importerade rader (backend), och låt både flyer- och kvittoflöde mappa till den innan kategori/matchning.
### Gemensam intern modell (ny)
`ImportedItemCandidate` (internt, ej API-brytande initialt):
- `rawName`, `normalizedName`, `brand`
- `weight`, `bundleWeight`, `isBundle`, `bundleItems`
- `price`, `priceUnit`, `comparisonPrice`, `comparisonUnit`
- `categoryHint`, `categoryId`
- `matchedProductId`, `matchedProductName`, `matchedVia`, `matchConfidence`, `matchReasons`
- `signals` (JSON):
- `originCountries: string[]`
- `labels: string[]` (ekologisk, laktosfri, etc)
- `qualityFlags: string[]` (normaliserade flaggor, ex `eco`)
- `variant: string | null`
- `packaging: string | null`
- `displayNameDetailed` (beräknat fält, kan persistas eller beräknas vid response)
## Faser och implementation
## Fas 1: Datamodell och migration
1. Uppdatera `backend/prisma/schema.prisma`:
- Lägg till `signals Json?``FlyerItem`
- Lägg till `displayNameDetailed String?``FlyerItem`
2. Skapa Prisma-migration.
3. Säkerställ bakåtkompatibilitet:
- Nullabla fält
- Ingen ändring av befintliga constraints/index som bryter drift
4. (Valfritt i samma fas) indexera vanligt använda JSON-signaler senare först efter verifierad nytta.
### Acceptanskriterier fas 1
- Migration appliceras lokalt utan dataförlust.
- Befintliga endpoints fungerar med gamla rader (`signals = null`).
## Fas 2: Gemensamma normaliserings-/signalverktyg
1. Skapa gemensam utility-modul i backend, exempel:
- `backend/src/import-common/import-item.types.ts`
- `backend/src/import-common/import-signals.util.ts`
- `backend/src/import-common/import-display-name.util.ts`
2. Implementera signal-extraktion från textfält (`rawName`, `brand`, `offerText`):
- Ursprungsländer till `originCountries`
- Etiketter/märkningar till `labels`/`qualityFlags`
- Pack-format till `packaging`
3. Normalisera utan att förlora information:
- Ta bort signalord från primär matchsträng men spara i `signals`
- Ex: `Fläskytterfilé (Sverige)` -> matchsträng `flaskytterfile`, `signals.originCountries=["Sverige"]`
4. Implementera `displayNameDetailed`:
- Bundle: inkludera `bundleItems` i visningsnamn
- Ex: `Kaptenens Favoriter (Chumlax 3x100g + Alaska pollock 3x100g)`
### Acceptanskriterier fas 2
- Signals extraheras deterministiskt för kända mönster (Sverige/Tyskland/Eko/Ekologiskt).
- `displayNameDetailed` genereras för bundles.
## Fas 3: Kategoriupplösning i flyer (paritet med kvitto)
1. Extrahera/återanvänd kategori-regelmotorn från receipt-import till gemensam tjänst:
- Ex: `backend/src/import-common/category-resolver.service.ts`
2. Använd den i flyer-import efter normalisering:
- `categoryHint` + signaltext + regler -> `categoryId`
3. Prioritet:
- Produktmatchad kategori (om säkert matchad produkt har kategori) kan väga högst
- Annars regelbaserad kategori
- Annars behåll `categoryHint` utan `categoryId`
4. Specifika regler för kött/fläskytterfilé verifieras.
### Acceptanskriterier fas 3
- `Fläskytterfilé` får korrekt `categoryId` i flyer-session.
- `categoryId` sätts automatiskt för en betydande andel rader med tydlig signal.
## Fas 4: Matchningsparitet flyer <-> kvitto
1. Bryt ut matchning till gemensam matcher (eller harmonisera algoritm):
- alias exact
- canonical/normalized exact
- token/fuzzy
- bonus för brand/weight/signalträffar
2. Matchning ska använda signalrensad namnsträng + metadata:
- Länder och eco-etiketter ska inte sabotera namnmatch
3. Standardisera reason codes mellan flöden (så långt möjligt utan brytande API):
- `alias_exact`, `normalized_exact`, `token_overlap:*`, `no_match`
4. Behåll strikt policy: ingen auto-create produkt.
### Acceptanskriterier fas 4
- Färre `no_match` på samma flyer-input jämfört med baseline.
- Matchningsorsaker blir mer förklarbara och konsekventa.
## Fas 5: API/DTO och persistens
1. Uppdatera flyer DTO:
- `backend/src/flyer-import/dto/flyer-import.response.ts`
- Lägg till `signals` och `displayNameDetailed`.
2. Uppdatera persistens i `flyer-import.service.ts`:
- Spara `signals`, `displayNameDetailed`, `categoryId`.
3. Säkerställ att `getSession`, `getLatestSession`, `updateSessionItem` returnerar nya fält.
4. Behåll kompatibilitet mot klient:
- Nya fält adderas utan att ta bort befintliga.
### Acceptanskriterier fas 5
- Response innehåller tydlig bundle-info och signaler per rad.
- Inga regressions i existerande frontend-parsing.
## Fas 6: Frontend (flyer import-tab)
1. Uppdatera domänmodeller i Flutter:
- `flutter/lib/features/import/domain/flyer_import_item.dart`
- ev. session/result-objekt
2. Visa `displayNameDetailed` där tillgängligt, annars fallback `rawName`.
3. Visa `bundleItems` tydligt i list-/detaljrad.
4. Visa badge/metadata för signaler (`Sverige`, `Ekologisk`) utan att skriva över produktnamn.
5. Säkerställ att manuellt urval till inköpslista fortsätter fungera.
### Acceptanskriterier fas 6
- Bundle-rader är tydligare i UI.
- Ursprung/eko syns som metadata.
## Fas 7: Teststrategi
### Backend enhetstester
- `flyer-normalizer.service.spec.ts`
- extraktion av `signals` (origin/labels)
- bundle-detaljnamn
- Ny kategori-resolver-spec
- `Fläskytterfilé` -> köttkategori
- `flyer-import.service.spec.ts`
- `categoryId` sätts vid tydlig signal
- `signals` och `displayNameDetailed` persisteras/returneras
- Matchningstester
- namn med land/eko matchar korrekt produkt
### Integrationstester
- End-to-end parseAndMatch med representativ flyer-fixture.
- Verifiera att inga produkter auto-skaps.
- Verifiera att shopping-list insertion fungerar med/utan `matchedProductId`.
### Frontendtester
- Serialisering av nya fält i import-session.
- Rendering av `displayNameDetailed` + `bundleItems`.
## Fas 8: Mätning och rollout
1. Lägg till enkel före/efter-mätning i logg/trace:
- andel `no_match`
- andel med satt `categoryId`
2. Soft rollout via feature flag (om möjligt), annars stegvis release.
3. Utvärdera verkliga flyer-sessioner innan vidare automatisering.
## Konkreta filer att ändra (planerad)
- `backend/prisma/schema.prisma`
- `backend/src/flyer-import/flyer-import.service.ts`
- `backend/src/flyer-import/services/flyer-normalizer.service.ts`
- `backend/src/flyer-import/dto/flyer-import.response.ts`
- `backend/src/receipt-import/receipt-import.service.ts` (endast för extraktion/återanvändning av gemensamma delar)
- Nya gemensamma filer under `backend/src/import-common/*`
- `flutter/lib/features/import/domain/flyer_import_item.dart`
- `flutter/lib/features/import/data/flyer_import_session.dart`
- `flutter/lib/features/import/presentation/flyer_import_tab.dart`
- Relevanta spec/test-filer i backend + flutter
## Risker och mitigering
- Risk: API-kontraktsändringar bryter klient.
- Mitigering: endast additive fält, fallback på gamla fält.
- Risk: Felkategori vid aggressiva regler.
- Mitigering: regelprioritet + reason-codes + tester för edge cases.
- Risk: Övermatchning av produkter.
- Mitigering: tröskelvärden + konservativ confidence för fuzzy.
## Leveransordning (rekommenderad)
1. Fas 12 (schema + signals + utilities)
2. Fas 3 (kategoriupplösning flyer)
3. Fas 4 (matchningsparitet)
4. Fas 5 (DTO/persistens)
5. Fas 6 (frontend)
6. Fas 78 (tester + mätning/rollout)
## Definition of Done
- Flyer och kvitto använder samma centrala regler för kategorisering/matchning där möjligt.
- Flyer-rader innehåller `signals` och tydligare produktrepresentation (`displayNameDetailed`, bundle-innehåll).
- `categoryId` sätts automatiskt i flyer när tillräcklig signal finns (inkl. fläskytterfilé-fall).
- Ingen automatisk produktskapning sker.
- Tester uppdaterade och gröna.
+98
View File
@@ -0,0 +1,98 @@
# Plan: Hantera deprecated dependencies (`inflight` / `glob`) i backend
## Mål
Minska säkerhets- och stabilitetsrisk från deprecated/transitiva paket i `backend` genom kontrollerad uppgradering, verifiering och CI-skydd utan att bryta befintliga flöden.
## Bakgrund
`npm ci` i `backend` visar varningar för:
- `inflight@1.0.6` (ej underhållen, memory leak)
- `glob@7.2.3` (föråldrad med kända sårbarheter)
Detta är normalt transitiva beroenden, men bör ändå adresseras systematiskt.
## Scope
- Endast Node-backend (`backend/`)
- Inga funktionella ändringar i app-logik
- Fokus: dependency graph, lockfile, CI-kontroller, dokumentation
## Implementationsplan
1. **Kartlägg källan till transitiva paket**
- Kör i `backend/`:
- `npm ls inflight`
- `npm ls glob`
- Dokumentera exakt vilka toppnivåpaket som drar in versionerna.
- Syfte: avgöra om problemet löses via toppnivåuppdatering eller `overrides`.
2. **Uppdatera direkta beroenden först (minst invasivt)**
- Kör riktad uppdatering av paket som identifieras i steg 1 (t.ex. test-/build-verktyg först, runtime efteråt).
- Kör därefter:
- `npm ci`
- `npm ls inflight glob`
- Beslutspunkt:
- Om `glob@7`/`inflight` försvinner: gå vidare till verifiering.
- Om kvarstår: gå till steg 3.
3. **Inför `overrides` i `backend/package.json` vid behov**
- Lägg till kontrollerade `overrides` för att styra bort sårbara/föråldrade versioner.
- Prioritet:
- `glob` till modern, kompatibel version i aktiv support.
- Undvik tvingad ersättning av `inflight` med inkompatibla alias om konsumentpaket inte stödjer det.
- Regenerera lockfile via normal npm-process och verifiera installationsflöde.
4. **Säkerhetsverifiera dependency-trädet**
- Kör:
- `npm audit --audit-level=high`
- `npm audit` (för full kontext)
- Klassificera återstående fynd:
- fixbara nu
- accepterad residualrisk (med motivering)
5. **Regressionstest av backend efter dependency-ändringar**
- Kör samma kvalitetskedja som används i projektet:
- `npm run prisma:validate`
- `npm run prisma:generate`
- `npm run typecheck`
- `npm run lint`
- `npm test`
- `npm run build`
- Syfte: säkerställa att dependency-upgrade inte skapar drift-/build-regressioner.
6. **Skärp CI-policy (om inte redan tillräcklig)**
- Verifiera att `.github/workflows/test.yml` fortsatt kör `npm audit --audit-level=high` för backend-push.
- Om önskat: höj till `critical` eller behåll `high` enligt teamets riskprofil.
- Rekommendation: behåll `high` i nuläget för bättre tidig signal i ett aktivt projekt.
7. **Dokumentera beslut och operativ hantering**
- Uppdatera `README.md` kort med:
- att deprecated-varningar hanterats
- hur man felsöker nya transitiva varningar (`npm ls <paket>`)
- policy för hur snabbt dependencies ska uppdateras
## Risker och mitigering
- **Risk: Breaking changes vid dependency bump**
- Mitigering: uppgradera stegvis + full kvalitetskedja i steg 5.
- **Risk: `overrides` maskerar underliggande kompatibilitetsproblem**
- Mitigering: använd `overrides` endast när toppnivåuppdatering inte räcker; dokumentera tydligt varför.
- **Risk: Kvarvarande audit-fynd blockerar release**
- Mitigering: klassificera residualrisk och skapa uppföljningsärende med ägare och deadline.
## Leverabler
- Uppdaterat `backend/package.json` (ev. `overrides` + uppdaterade versionsintervall)
- Uppdaterad `backend/package-lock.json`
- Eventuell liten CI-justering i `.github/workflows/test.yml`
- Kort dokumentationsnotis i `README.md`
## Acceptanskriterier
- `npm ls inflight` visar inte problematisk kedja, eller tydligt dokumenterad residualrisk med motivering.
- `npm ls glob` visar ingen osäker/föråldrad kedja (eller dokumenterad temporär avvikelse med plan).
- `npm audit --audit-level=high` passerar, eller kvarvarande fynd är explicit riskaccepterade.
- Backend-kvalitetskedjan passerar utan regression.
- CI för backend fortsätter passera.
## Rekommenderad genomförandeordning
1) Kartlägg (`npm ls`)
2) Riktade uppdateringar
3) `overrides` endast vid behov
4) Audit + full testkedja
5) CI/dokumentation
@@ -0,0 +1,69 @@
# Plan: Harmonisering av importfält baserat på inventory-tabellen
## Mål
Skapa konsistens mellan kvitto-import, flyer-import och inventory-tabellen genom att anpassa fältnamn, datatyper och struktur. Detta kommer att förenkla integrationen och minska risken för fel.
## Bakgrund
- `inventory`-tabellen är central och har en väletablerad struktur.
- Kvitto-import och flyer-import använder olika fältnamn och datatyper, vilket skapar inkonsistenser.
- Flyer-import använder `signals.originCountries` (array), medan `inventory` använder `origin` (string).
## Scope
- Uppdatera `ParsedReceiptItem` och `FlyerImportItem` för att matcha `inventory`-tabellen.
- Uppdatera mappningslogiken i importfunktionerna.
- Uppdatera databasen för att stödja `originCountries` som en array (lång sikt).
## Implementationsplan
### 1. Uppdatera `ParsedReceiptItem` (kvitto-import)
- **Mål**: Anpassa fältnamn och datatyper för att matcha `inventory`-tabellen.
- **Åtgärder**:
- Lägg till `categoryId` för att möjliggöra kategorisättning.
- Använd `rawName` istället för `receiptName` för konsistens.
- Mappa `origin` till `inventory.origin`.
### 2. Uppdatera `FlyerImportItem` (flyer-import)
- **Mål**: Anpassa fältnamn och datatyper för att matcha `inventory`-tabellen.
- **Åtgärder**:
- Använd `rawName` istället för `receiptName` för konsistens.
- Mappa `signals.originCountries[0]` till `inventory.origin`.
- Mappa `categoryId` till `product.categoryId` om en produkt skapas/uppdateras.
### 3. Uppdatera mappningslogiken
- **Mål**: Förenkla mappningen från importfunktionerna till `inventory`-tabellen.
- **Åtgärder**:
- Uppdatera `receipt-import.service.ts` för att använda `inventory`-fältnamn.
- Uppdatera `flyer-import.service.ts` för att använda `inventory`-fältnamn.
### 4. Uppdatera databasen (lång sikt)
- **Mål**: Stödja `originCountries` som en array i `inventory`-tabellen.
- **Åtgärder**:
- Lägg till `originCountries Json?` i `inventory`-tabellen.
- Uppdatera `CreateInventoryDto` för att inkludera `originCountries`.
### 5. Uppdatera DTO:er
- **Mål**: Säkerställa att DTO:er matchar `inventory`-tabellen.
- **Åtgärder**:
- Uppdatera `CreateInventoryDto` för att inkludera `originCountries`.
## Leverabler
- Uppdaterade `ParsedReceiptItem` och `FlyerImportItem` som matchar `inventory`-tabellen.
- Uppdaterad mappningslogik i `receipt-import.service.ts` och `flyer-import.service.ts`.
- Uppdaterad databas för att stödja `originCountries` som en array.
- Uppdaterade DTO:er för att inkludera `originCountries`.
## Acceptanskriterier
- `ParsedReceiptItem` och `FlyerImportItem` använder samma fältnamn och datatyper som `inventory`-tabellen.
- Mappningslogiken i importfunktionerna är förenklad och använder `inventory`-fältnamn.
- `inventory`-tabellen stödjer `originCountries` som en array.
- `CreateInventoryDto` inkluderar `originCountries`.
## Rekommenderad genomförandeordning
1. Uppdatera `ParsedReceiptItem` och `FlyerImportItem`.
2. Uppdatera mappningslogiken i importfunktionerna.
3. Uppdatera databasen för att stödja `originCountries` som en array.
4. Uppdatera DTO:er för att inkludera `originCountries`.
## Handover from Planning Session
- Planen är klar och redo för implementering.
- Inga frågor eller otydligheter kvarstår.
+706
View File
@@ -0,0 +1,706 @@
🚨 Kritiska Problem (Prioritet 1: Fixa Omedelbart)
Dessa problem påverkar användarupplevelsen mest och bör åtgärdas först.
1. Prestanda: Långsam inladdning & Stora Filer
🔴 Problem:
Total storlek: 2,978 KiB (för stor för en webbapp).
/main.dart.js: 1,216 KiB (Flutters kompilade JavaScript).
canvaskit.wasm: 1,592 KiB (Flutters CanvasKit-renderare).
MaterialIcons-Regular.otf: 9.8 KiB (ikoner).
Largest Contentful Paint (LCP) misslyckades (tidsgräns överskreds).
Total Blocking Time (TBT) misslyckades (långa JavaScript-uppgifter blockerar huvudtråden).
JavaScript-exekveringstid: 1.8s (för långt).
🟢 Lösningar:
A. Optimera Flutter för Web
Flutter-webbappar är tyngre än traditionella webbappar på grund av CanvasKit. Här är hur du minskar storleken:
Använd HTML-renderaren istället för CanvasKit
CanvasKit ger bättre grafik men är mycket tyngre.
Ändra i index.html:
html
Copy
<script>
window.flutterConfiguration = {
renderMode: "html", // Istället för "canvas" (CanvasKit)
};
</script>
Fördel: Minskar storleken med ~1.5MB (WASM-filen laddas inte).
Nackdel: Vissa avancerade animationer/efekter fungerar inte lika bra.
Aktivera komprimering (GZIP/Brotli)
Din server skickar okomprimerade filer.
Lösning:
Nginx: Lägg till i konfigurationen:
nginx
Copy
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
brotli on;
brotli_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
Apache: Använd mod_deflate eller mod_brotli.
Cloudflare: Aktivera "Brotli Compression" i inställningarna.
Cachea statiska resurser
Problem: /main.dart.js och flutter_bootstrap.js har ingen cache-TTL.
Lösning: Lägg till Cache-Control-headers:
nginx
Copy
location ~* \.(js|css|png|jpg|jpeg|gif|ico|wasm|otf)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
Besparing: ~1.2MB vid upprepade besök.
Dela upp JavaScript-koden (Code Splitting)
Flutter laddar allt i en stor fil (main.dart.js).
Lösning: Använd deferred imports för att ladda funktioner på begäran:
dart
Copy
// I din Dart-kod:
import 'package:flutter_web_plugins/flutter_web_plugins.dart';
import 'package:my_app/receipt_import.dart' deferred as receipt_import;
// Ladda endast när behövs:
Future<void> loadReceiptImport() async {
await receipt_import.loadLibrary();
}
Effekt: Minskar initial laddningstid.
Optimera bilder
Problem: Bilder laddas inte optimalt.
Lösning:
Använd flutter_image_compress för att komprimera bilder innan uppladdning.
För webb: Använd <picture> med srcset för responsiva bilder.
Exempel:
html
Copy
<picture>
<source srcset="image-480w.jpg" media="(max-width: 600px)">
<source srcset="image-800w.jpg" media="(max-width: 1200px)">
<img src="image-1200w.jpg" alt="Receptbild">
</picture>
B. Förbättra Laddningsordningen
Fördröj laddning av icke-kritiska resurser
Ladda canvaskit.wasm efter att sidan har renderats:
html
Copy
<script>
window.addEventListener('load', () => {
const script = document.createElement('script');
script.src = 'https://www.gstatic.com/canvaskit/v1/chromium/canvaskit.wasm';
document.body.appendChild(script);
});
</script>
Använd preload för kritiska resurser
Lägg till i <head>:
html
Copy
<link rel="preload" href="/main.dart.js" as="script">
<link rel="preload" href="/flutter_bootstrap.js" as="script">
2. Tillgänglighet: Grundläggande Problem
🔴 Problem:
[user-scalable="no"] i viewport-meta-taggen
Varför det är dåligt: Användare med nedsatt syn kan inte zooma in.
Lösning: Ta bort user-scalable="no":
html
Copy
<meta name="viewport" content="width=device-width, initial-scale=1.0">
Saknas alt-texter på bilder
Problem: Skärmläsare kan inte beskriva bilder.
Lösning: Lägg till alt-texter i Flutter:
dart
Copy
Image.network(
'https://example.com/recept.jpg',
semanticLabel: 'Bild på lasagne med ost och tomatsås', // Alt-text
),
Saknas lang-attribut
Problem: Skärmläsare vet inte vilket språk sidan använder.
Lösning: Lägg till i <html>:
html
Copy
<html lang="sv">
3. SEO: Grundläggande Problem
🔴 Problem:
Saknas meta description
Varför det är dåligt: Sökmotorer visar ingen beskrivning i resultaten.
Lösning: Lägg till i <head>:
html
Copy
<meta name="description" content="Upptäck och lagra dina recept. Importera kvitton och håll koll på ditt kylskåp med vår smarta app.">
Ogiltig robots.txt
Problem: Din robots.txt innehåller HTML-kod istället för korrekt syntax.
Lösning: Skapa en korrekt robots.txt:
text
Copy
User-agent: *
Allow: /
Sitemap: https://recept.gynther.se/sitemap.xml
⚠️ Viktiga Förbättringar (Prioritet 2: Fixa Inom 1-2 Veckor)
Dessa problem påverkar användarupplevelsen och SEO, men är inte lika kritiska.
1. Prestanda: JavaScript & Rendering
🟡 Problem:
Lång JavaScript-exekveringstid (1.8s)
Orsak: Flutters main.dart.js tar 1.7s att parsas och exekveras.
Minify CSS/JS misslyckades
Flutter genererar redan minifierad kod, men du kan optimera vidare.
🟢 Lösningar:
Aktivera Flutters --release flagga
Bygg appen med:
bash
Copy
flutter build web --release
Detta genererar optimerad och minifierad kod.
Använd flutter build web --no-source-maps
Source maps ökar filstorleken. Ta bort dem i produktion:
bash
Copy
flutter build web --no-source-maps
Fördröj laddning av icke-kritisk JavaScript
Använd defer eller async för skript som inte behövs omedelbart:
html
Copy
<script src="/flutter_bootstrap.js" defer></script>
Reducera DOM-storlek
Problem: Din sida har 21 element med en max-djup på 7 (acceptabelt, men kan optimeras).
Lösning: Undvik onödiga Container-widgets i Flutter. Använd const widgets där möjligt.
2. Tillgänglighet: Interaktiva Element
🟡 Problem:
Saknas focus-indikatorer
Användare som navigerar med tangentbord ser inte vilka element som är fokuserade.
Saknas aria-label på anpassade knappar
Skärmläsare vet inte vad knapparna gör.
🟢 Lösningar:
Lägg till focus-stilar i CSS
Exempel:
css
Copy
button:focus, [tabindex="0"]:focus {
outline: 2px solid #4285F4;
outline-offset: 2px;
}
Använd semantics i Flutter för tillgänglighet
Exempel för en knapp:
dart
Copy
Semantics(
label: 'Importera kvitto',
button: true,
child: ElevatedButton(
onPressed: () => _importReceipt(),
child: Text('Importera'),
),
),
Lägg till aria-label på ikonknappar
Exempel:
dart
Copy
IconButton(
icon: Icon(Icons.upload),
onPressed: () => _uploadFile(),
tooltip: 'Ladda upp kvitto', // Visas på hover
semanticsLabel: 'Ladda upp kvitto', // För skärmläsare
),
3. Säkerhet: Content Security Policy (CSP)
🟡 Problem:
Saknas CSP-header
Din sida är sårbar för XSS-attacker (Cross-Site Scripting).
🟢 Lösning:
Lägg till en stark CSP i din serverkonfiguration:
nginx
Copy
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://www.gstatic.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://*.gstatic.com; font-src 'self' https://fonts.gstatic.com; connect-src 'self' https://recept.gynther.se; frame-src 'none'; object-src 'none';";
Förklaring:
script-src: Tillåter skript från self och gstatic.com (för Flutter).
unsafe-inline: Nödvändigt för Flutter (men försök minska användningen).
img-src: Tillåter bilder från self och gstatic.com.
📌 Mindre Problem (Prioritet 3: Fixa När Tid Finns)
Dessa är förbättringar som inte är kritiska men kan förbättra UX och SEO.
1. Prestanda: Bildoptimering
Problem: Bilder laddas utan width och height (orsakar layout shifts).
Lösning: Använd Image.network med explicit storlek:
dart
Copy
Image.network(
'https://example.com/recept.jpg',
width: 300,
height: 200,
fit: BoxFit.cover,
),
2. SEO: Structured Data
Problem: Saknas schema.org-markup för recept (gör att Google kan visa "rich results").
Lösning: Lägg till JSON-LD i <head>:
html
Copy
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Recipe",
"name": "Lasagne",
"author": {
"@type": "Person",
"name": "Nils-Johan Gynther"
},
"datePublished": "2026-05-21",
"description": "En klassisk lasagne med köttfärs och bechamelsås.",
"prepTime": "PT30M",
"cookTime": "PT45M",
"totalTime": "PT1H15M",
"recipeYield": "4 portioner",
"recipeCategory": "Middag",
"recipeCuisine": "Italiensk",
"keywords": "lasagne, pasta, köttfärs",
"recipeIngredient": ["500g köttfärs", "250g ost", "1 paket lasagneplattor"],
"recipeInstructions": [
{
"@type": "HowToStep",
"name": "Börja med att fräsa köttfärsen.",
"text": "Fräs köttfärsen i en stekpanna tills den är genomstekt."
},
{
"@type": "HowToStep",
"name": "Lagra lasagnen i ugnen.",
"text": "Skikta lasagneplattor, köttfärs och sås i en ugnsform. Grädda i 45 minuter på 200°C."
}
]
}
</script>
Effekt: Google kan visa receptkort i sökresultaten (ökad klickfrekvens).
3. Tillgänglighet: Logisk Tab-Order
Problem: Användare kan tabba till element som är dolda eller off-screen.
Lösning: Använd FocusableAction i Flutter för att kontrollera tab-order:
dart
Copy
FocusableActionDetector(
autofocus: true,
onFocusChange: (hasFocus) {
if (hasFocus) {
// Hantera focus
}
},
child: MyWidget(),
),
📊 Sammanfattning av Prioriteringar
Prioritet
Problem
Lösning
Tidsuppskattning
Impact
1 (Kritisk)
Stora filer (2.9MB)
Byt till HTML-renderare, aktivera GZIP, cachea resurser
1-2 timmar
⭐⭐⭐⭐⭐ (Hög)
1 (Kritisk)
Lång JavaScript-exekvering (1.8s)
Code splitting, defer non-critical JS
2-4 timmar
⭐⭐⭐⭐⭐ (Hög)
1 (Kritisk)
user-scalable="no"
Ta bort från viewport-meta-taggen
5 minuter
⭐⭐⭐⭐ (Hög)
1 (Kritisk)
Saknas meta description
Lägg till i
5 minuter
⭐⭐⭐ (Medel)
1 (Kritisk)
Ogiltig robots.txt
Skapa korrekt robots.txt
10 minuter
⭐⭐⭐ (Medel)
2 (Viktig)
Saknas CSP-header
Lägg till i serverkonfigurationen
1 timme
⭐⭐⭐⭐ (Hög)
2 (Viktig)
Saknas alt-texter
Lägg till semanticLabel i Flutter
1-2 timmar
⭐⭐⭐ (Medel)
2 (Viktig)
Lång DOM-parsning
Minska onödiga widgets
1-2 timmar
⭐⭐ (Låg)
3 (Mindre)
Saknas structured data
Lägg till JSON-LD för recept
1 timme
⭐⭐ (Låg)
🛠️ Konkreta Åtgärder (Steg-för-Steg)
Steg 1: Fixa Prestanda (1-2 dagar)
Byt till HTML-renderare (sparar ~1.5MB):
Ändra index.html som visat ovan.
Aktivera GZIP/Brotli på servern.
Cachea statiska filer (1 år för JS/CSS).
Bygg appen med --release:
bash
Copy
flutter build web --release --no-source-maps
Ladd upp den optimerade versionen till din server.
Steg 2: Fixa Tillgänglighet (1 dag)
Ta bort user-scalable="no" från viewport.
Lägg till lang="sv" i <html>.
Lägg till alt-texter på alla bilder.
Lägg till semanticsLabel på knappar och ikoner.
Steg 3: Fixa SEO (1 dag)
Lägg till meta description.
Fixa robots.txt.
Lägg till structured data för recept.
Steg 4: Säkerhet (1 timme)
Lägg till CSP-header i serverkonfigurationen.
📈 Förväntade Resultat
Mätning
Nuvarande
Efter Fixar
Förbättring
Total storlek
2,978 KiB
~500-800 KiB
-70%
Laddningstid (LCP)
Timeout
< 2.5s
✅ Fixad
JavaScript-exekvering
1.8s
< 1s
-45%
Tillgänglighet
82/100
95+/100
+15 poäng
SEO
6/10
9/10
+50%
Säkerhet
Risk för XSS
Skyddad
✅ Fixad
🔍 Hur du Testar Förbättringarna
Kör Lighthouse igen i Chrome DevTools (F12 → "Lighthouse").
Testa på mobil (använd Chromes "Device Toolbar").
Använd WebPageTest (webpagetest.org) för att mäta prestanda globalt.
Testa tillgänglighet med:
WAVE Evaluation Tool
Skärmläsare (t.ex. NVDA eller VoiceOver).
💡 Extra Tips för Flutter-Webbappar
Använd flutter build web --target=lib/main_deferred.dart
Förbättrar code splitting.
Ladda Flutter asynkront
Lägg till async i <script>-taggen:
html
Copy
<script src="/flutter_bootstrap.js" async></script>
Använd flutter_tex för text-rendering
Minskar beroendet av CanvasKit.
Override Flutters default fonts
Använd systemtypsnitt för snabbare laddning:
css
Copy
@font-face {
font-family: 'Roboto';
src: local('Roboto'), local('Roboto-Regular');
}
🎯 Sammanfattning: Vad du ska göra nu
Fixa de kritiska prestandaproblemen (HTML-renderare, GZIP, caching).
Fixa tillgänglighetsproblemen (user-scalable, alt-texter, lang).
Fixa SEO (meta description, robots.txt).
Lägg till säkerhetsheaders (CSP).
Testa och iterera med Lighthouse.
+359
View File
@@ -0,0 +1,359 @@
flyerimporter.md
📌 Steg 1: Skapa en funktion för att extrahera text från PDF:en
Använd pdf-parse som primär metod och Tesseract.js som fallback för OCR.
Kod: extractFlyerText.ts
typescript
Copy
import * as fs from 'fs';
import * as pdf from 'pdf-parse';
import Tesseract from 'tesseract.js';
/**
* Extraherar text från en PDF-fil (flyer), med fallback till OCR.
* @param pdfPath Sökväg till PDF-filen.
* @returns Extraherad text.
*/
export async function extractFlyerText(pdfPath: string): Promise<string> {
try {
// Försök med pdf-parse först
const dataBuffer = fs.readFileSync(pdfPath);
const data = await pdf(dataBuffer);
if (data.text.trim()) {
return data.text;
}
} catch (error) {
console.warn('pdf-parse misslyckades, försöker med OCR...');
}
// Fallback till Tesseract.js för OCR
try {
const { data: { text } } = await Tesseract.recognize(pdfPath, 'swe', {
logger: (m) => console.log(m),
});
return text;
} catch (error) {
console.error('OCR misslyckades:', error);
throw new Error('Kunde inte extrahera text från PDF:en.');
}
}
📌 Steg 2: Skapa en funktion för att skicka texten till Mistral Tiny
Använd Mistral Tiny för att extrahera och strukturera all produktinformation från flyern.
Kod: importFlyerWithAI.ts
typescript
Copy
import { MistralClient } from '@mistralai/mistralai';
const mistral = new MistralClient({
apiKey: process.env.MISTRAL_API_KEY,
});
/**
* Skickar flyer-texten till Mistral Tiny för att extrahera strukturerad data.
* @param text Texten från flyern.
* @returns Strukturerad data (JSON-array).
*/
export async function importFlyerWithAI(text: string): Promise<any[]> {
const prompt = `
Du är en expert på att tolka svenska matvaruflyers (t.ex. från Willys).
Extrahera ALL produktinformation från följande text och returnera den som en JSON-array.
För varje produkt, inkludera:
- name: Produktnamn (fullständigt namn)
- weight: Vikt (om tillgänglig, t.ex. "150g", "Ca 1kg")
- origin: Ursprung/land/märke (om tillgänglig, t.ex. "FALKENBERG", "NYBERGS DELI • Sverige")
- price: Pris (som ett nummer, t.ex. 39.90)
- comparisonPrice: Jämförpris (som ett nummer, t.ex. 266.00)
- unit: Enhet (kg, st, förp, l, etc.)
- offer: Erbjudande (t.ex. ["Max 3 köp/hushåll", "Lägsta 30-dgrspris 125:00 kr"])
- category: Kategori (t.ex. "Fisk", "Kött", "Mejeri", "Grönsaker", "Frukt", "Dryck")
- validFrom: Giltig från (datum i formatet YYYY-MM-DD, om tillgängligt)
- validTo: Giltig till (datum i formatet YYYY-MM-DD, om tillgängligt)
Texten att tolka:
${text}
Returnera ENDAST en JSON-array. Inga andra kommentarer.
Exempel på utdata:
[
{
"name": "KALLRÖKT LAX, GRAVAD LAX",
"weight": "150g",
"origin": "FALKENBERG",
"price": 39.90,
"comparisonPrice": 266.00,
"unit": "kg",
"offer": ["Max 3 köp/hushåll"],
"category": "Fisk",
"validFrom": "2026-05-18",
"validTo": "2026-05-24"
}
]
`;
try {
const response = await mistral.chat({
model: 'mistral-tiny', // Använder den enklaste modellen
messages: [{ role: 'user', content: prompt }],
temperature: 0.1, // Låg temperatur för mer deterministiska svar
});
// Rensa upp JSON-strängen
const jsonString = response.choices[0].message.content
.replace(/```json|```/g, '')
.trim();
// Parsa JSON:en
return JSON.parse(jsonString);
} catch (error) {
console.error('Fel vid AI-import:', error);
throw new Error('Kunde inte importera flyern med AI.');
}
}
📌 Steg 3: Fullständigt importflöde
Kombinera text-extrahering och AI-import i ett fullständigt flöde.
Kod: flyerImportService.ts
typescript
Copy
import { extractFlyerText } from './extractFlyerText';
import { importFlyerWithAI } from './importFlyerWithAI';
/**
* Importerar en flyer (PDF) och returnerar strukturerad data.
* @param pdfPath Sökväg till PDF-filen.
* @returns Strukturerad data från flyern.
*/
export async function importFlyer(pdfPath: string) {
try {
// 1. Extrahera text från PDF:en
console.log('Extraherar text från flyern...');
const text = await extractFlyerText(pdfPath);
// 2. Skicka texten till Mistral Tiny för att extrahera data
console.log('Skickar text till Mistral Tiny för extrahering...');
const products = await importFlyerWithAI(text);
return {
success: true,
products,
text,
};
} catch (error) {
console.error('Fel vid import:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Okänt fel',
};
}
}
📌 Steg 4: API-Endpoint för flyer-import
Skapa en Express-endpoint för att hantera uppladdning och import av flyers.
Kod: flyerImportRouter.ts
typescript
Copy
import express from 'express';
import multer from 'multer';
import { importFlyer } from '../services/flyerImportService';
import * as fs from 'fs';
const router = express.Router();
const upload = multer({ dest: 'uploads/' });
// Endpoint för att ladda upp och importera en flyer
router.post('/import/flyer', upload.single('flyer'), async (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ error: 'Ingen flyer uppladdad.' });
}
const result = await importFlyer(req.file.path);
// Rensa upp uppladdad fil
fs.unlinkSync(req.file.path);
if (!result.success) {
return res.status(500).json({ error: result.error });
}
res.json(result);
} catch (error) {
console.error('Fel vid flyer-import:', error);
res.status(500).json({ error: 'Kunde inte importera flyern.' });
}
});
export default router;
📌 Steg 5: Exempel på utdata
När du skickar texten från Willys flyer till Mistral Tiny, kommer du att få tillbaka en JSON-array med alla produkter, t.ex.:
json
Copy
[
{
"name": "KALLRÖKT LAX, GRAVAD LAX",
"weight": "150g",
"origin": "FALKENBERG",
"price": 39.9,
"comparisonPrice": 266.0,
"unit": "kg",
"offer": ["Max 3 köp/hushåll"],
"category": "Fisk",
"validFrom": "2026-05-18",
"validTo": "2026-05-24"
},
{
"name": "FLÄSKYTTERFILÉ",
"weight": "Ca 1kg",
"origin": "NYBERGS DELI • Sverige",
"price": 64.9,
"comparisonPrice": 64.9,
"unit": "kg",
"offer": ["Max 3 förp/hushåll"],
"category": "Kött",
"validFrom": "2026-05-18",
"validTo": "2026-05-24"
}
]
📌 Steg 6: Använda den extraherade datan
När du har den strukturerade datan, kan du:
Lagra den i din databas (t.ex. för att jämföra med inventory).
Visa den för användaren (t.ex. i en tabell).
Använda den för att generera recept (med eller utan AI).
Exempel: Lagra i databasen
typescript
Copy
// Antas att du har en Prisma-modell för flyer-produkter
await prisma.flyerProduct.createMany({
data: products.map((product) => ({
name: product.name,
weight: product.weight,
origin: product.origin,
price: product.price,
comparisonPrice: product.comparisonPrice,
unit: product.unit,
offer: JSON.stringify(product.offer),
category: product.category,
validFrom: product.validFrom ? new Date(product.validFrom) : null,
validTo: product.validTo ? new Date(product.validTo) : null,
})),
});
📌 Steg 7: Frontend-Integrering (Exempel: React)
Här är hur du kan integrera flyer-importen i din frontend:
Kod: FlyerImportForm.tsx
tsx
Copy
import { useState } from 'react';
import axios from 'axios';
function FlyerImportForm() {
const [file, setFile] = useState<File | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [result, setResult] = useState<any>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!file) return;
setIsLoading(true);
const formData = new FormData();
formData.append('flyer', file);
try {
const response = await axios.post('/api/import/flyer', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
setResult(response.data);
} catch (error) {
console.error('Fel vid uppladdning:', error);
} finally {
setIsLoading(false);
}
};
return (
<div>
<h2>Importera flyer</h2>
<form onSubmit={handleSubmit}>
<input
type="file"
accept=".pdf"
onChange={(e) => setFile(e.target.files?.[0] || null)}
required
/>
<button type="submit" disabled={isLoading}>
{isLoading ? 'Importerar...' : 'Importera flyer'}
</button>
</form>
{result?.success && (
<div>
<h3>Importerade produkter ({result.products.length})</h3>
<table>
<thead>
<tr>
<th>Namn</th>
<th>Pris</th>
<th>Jämförpris</th>
<th>Kategori</th>
<th>Erbjudande</th>
</tr>
</thead>
<tbody>
{result.products.map((product: any, index: number) => (
<tr key={index}>
<td>{product.name}</td>
<td>{product.price} {product.unit}</td>
<td>{product.comparisonPrice} {product.unit}</td>
<td>{product.category}</td>
<td>{product.offer.join(', ')}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
export default FlyerImportForm;
📌 Miljövariabler (.env)
env
Copy
# Mistral API-nyckel
MISTRAL_API_KEY=din_api_nyckel_här
+169
View File
@@ -0,0 +1,169 @@
# Plan för förbättrad dokumentationsstruktur
## Bakgrund
Projektet har idag över 20 `.md`-filer spridda över olika mappar, vilket gör det svårt att hitta information och förstå projektets helhet. Den nya strukturen syftar till att:
- **Göra dokumentationen användarvänlig** för både utvecklare och språkmodeller.
- **Optimerad för underhåll** med tydlig separation mellan aktiv och arkiverad dokumentation.
- **Skapa kontextuell sammanhang** genom länkade filer och tydliga hierarkier.
---
## Nuvarande problem
- **Fragmentering**: Relaterat innehåll är splittrat (t.ex. Flutter-dokumentation i `_archive/docs/flutter/` vs. backend-dokumentation i rotmappen).
- **Föråldrat innehåll**: Arkiverade filer blandas med aktiva (t.ex. `SESSION_CHECKPOINT_2026-05-12.md`).
- **Duplicering**: Samma koncept dokumenteras på flera ställen (t.ex. kategoriträd).
- **Icke-optimerat för språkmodeller**: Saknar tydlig hierarki och kontextuella länkar.
---
## Föreslagen struktur
```
/
├── docs/ # Huvudkatalog för ALL aktiv dokumentation
│ ├── 01-overview/ # Övergripande projektbeskrivning
│ │ ├── README.md # Huvudsaklig ingress (ersätter rot-README.md)
│ │ ├── ARCHITECTURE.md # Systemarkitektur
│ │ └── GLOSSARY.md # Termer och definitioner
│ │
│ ├── 02-setup/ # Installation och konfiguration
│ │ ├── INSTALL.md # Miljökrav, beroenden, första uppstart
│ │ ├── CONFIG.md # Konfigurationsfiler (.env, Docker, etc.)
│ │ └── TROUBLESHOOTING.md # Vanliga problem och lösningar
│ │
│ ├── 03-development/ # Utvecklingsguider
│ │ ├── CONTRIBUTING.md # Bidragsregler, kodstandard, PR-process
│ │ ├── WORKFLOWS.md # Git-flöden, branch-strategi, CI/CD
│ │ ├── DATABASE.md # Schema, migrationer, seedning
│ │ ├── API.md # Backend-API:er, Swagger-länkar
│ │ ├── FLUTTER.md # Flutter-specifik dokumentation
│ │ └── MICROSERVICES.md # Importer, AI, etc.
│ │
│ ├── 04-deploy/ # Driftsättning och underhåll
│ │ ├── DEPLOY.md # Steg-för-steg deploy
│ │ ├── MAINTENANCE.md # Underhållsskript, backup, monitorering
│ │ └── SCALING.md # Prestanda, skalning
│ │
│ ├── 05-features/ # Djupdyk i funktioner
│ │ ├── RECIPE_IMPORT.md # Kvittosimport och flyer-parsing
│ │ ├── CATEGORY_TREE.md # Kategorihantering och L3-integration
│ │ ├── SHOPPING_LIST.md # Inköpslistor och flyer-integration
│ │ └── ... # Övriga funktioner
│ │
│ └── 06-archive/ # Arkiverade dokument
│ ├── sessions/ # Gamla sessionsanteckningar
│ ├── legacy/ # Föråldrade planer
│ └── flutter_legacy/ # Gamla Flutter-dokument
├── .github/ # GitHub-specifika filer
│ ├── COPILOT_INSTRUCTIONS.md
│ └── ...
└── ... # Övriga projektfiler (backend/, flutter/, etc.)
```
---
## Migrationsplan
### Steg 1: Skapa den nya strukturen
```bash
mkdir -p docs/{01-overview,02-setup,03-development,04-deploy,05-features,06-archive/sessions,06-archive/legacy,06-archive/flutter_legacy}
```
### Steg 2: Flytta och uppdatera filer
| Källfil(er) | Målfil | Åtgärd |
|---------------------------------------------|-----------------------------------------|------------------------------------------------------------------------|
| `TEKNISK_BESKRIVNING.md` | `docs/01-overview/ARCHITECTURE.md` | Extrahera arkitekturavsnitt. |
| `TEKNISK_BESKRIVNING.md` (deploy) | `docs/04-deploy/DEPLOY.md` | Extrahera deploy-avsnitt. |
| `TEKNISK_BESKRIVNING.md` (databas) | `docs/03-development/DATABASE.md` | Extrahera databasavsnitt + lägg till underhållsskript. |
| `flyerimporter.md` | `docs/05-features/RECIPE_IMPORT.md` | Uppdatera med nya flöden och API-endpoints. |
| `_archive/docs/flutter/*` | `docs/03-development/FLUTTER.md` | Slå samman och uppdatera Flutter-dokumentation. |
| `README.md` | `docs/01-overview/README.md` | Uppdatera med länkar till nya dokument. |
| `.github/copilot-instructions.md` | `.github/COPILOT_INSTRUCTIONS.md` | Flytta och uppdatera. |
| `_archive/docs/SESSION_*.md` | `docs/06-archive/sessions/` | Arkivera gamla sessionsanteckningar. |
| `MVP_CHECKLISTA.md` | `docs/06-archive/legacy/` | Arkivera föråldrade planer. |
### Steg 3: Skapa nya filer
| Fil | Syfte |
|-----------------------------|-----------------------------------------------------------------------|
| `docs/03-development/CONTRIBUTING.md` | Standardiserade bidragsregler (branches, PR, code review). |
| `docs/03-development/API.md` | Dokumentation av backend-API:er (OpenAPI-länkar, exempel). |
| `docs/03-development/MICROSERVICES.md` | Beskrivning av microservices (Importer, AI). |
| `docs/05-features/CATEGORY_TREE.md` | Djupdyk i kategorihantering och L3-integration. |
| `docs/04-deploy/MAINTENANCE.md` | Backup, monitorering, logghantering. |
---
## Exempel på innehåll
### `docs/01-overview/ARCHITECTURE.md`
```markdown
# Systemarkitektur
```mermaid
graph TD
A[Flutter App] -->|HTTP/REST| B[Backend API]
B -->|Prisma Client| C[MariaDB]
B -->|gRPC| D[Importer Microservice]
D -->|HTTP| E[Externa API:er]
C -->|Seed| F[Initial Data]
```
## Komponenter
1. **Flutter App**: State management med Riverpod, UI med Material Design.
2. **Backend API**: NestJS + Prisma, autentisering via JWT.
3. **Microservices**: Importer (Node.js + Puppeteer), AI (Python + Mistral).
```
---
### `docs/03-development/DATABASE.md`
```markdown
# Databas
## Schema
```mermaid
erDiagram
User ||--o{ Recipe : creates
User ||--o{ InventoryItem : owns
Category ||--o{ Product : "L3"
FlyerSession ||--o{ FlyerItem : contains
```
## Migrationer
### Standardflöde
1. Uppdatera `prisma/schema.prisma`.
2. Skapa migration:
```bash
npx prisma migrate dev --name add_feature_x
```
3. Testa lokalt:
```bash
npx prisma migrate reset
npx prisma db seed
```
### Underhållsskript
- **Rensa databas** (behåll kategorier):
```bash
./deploy.sh --clean-database
```
> Obs! Uppdatera `prisma/maintenance/clean-database.sql` när nya tabeller läggs till.
```
---
## Fördelar
- **Lätt att hitta**: Logisk gruppering (t.ex. all Flutter-dokumentation på ett ställe).
- **Uppdaterad**: Arkiverade filer separerade från aktiv dokumentation.
- **Optimerad för språkmodeller**: Tydliga rubriker, länkar, och maskinläsbara format.
- **Underhållbar**: Modulär struktur gör det enkelt att uppdatera enskilda delar.
---
## Nästa steg
1. **Godkänn planen**: Bekräfta att strukturen uppfyller era behov.
2. **Implementera**: Skapa den nya strukturen och flytta filer stegvis.
3. **Uppdatera länkar**: Se till att alla referenser pekar på de nya platserna.
+118
View File
@@ -0,0 +1,118 @@
# Plan: Anpassad multiplattformsplan for recipe-app
## Mal
Gora Flutter-klienten i `flutter/` till en riktig multiplattformsklient (web + Android + iOS) utan att bryta befintligt webbflode via Docker/Caddy, och med tydlig miljohantering for API-anrop pa mobil.
## Nulagesanalys (projektanpassad)
- Flutter-projektet ar i praktiken web-only just nu: `flutter/` saknar `android/` och `ios/`.
- Webbbygget ar redan etablerat och stabilt via Docker:
- `flutter/Dockerfile` bygger `flutter build web --dart-define=API_BASE_URL=/api`.
- `compose.flutter.yml` och `flutter/Caddyfile` proxar `/api/*` till `recipe-api:8080`.
- API-basurl hanteras redan med `--dart-define` (bra grund for multiplattform):
- `flutter/lib/core/api/api_client.dart`
- `flutter/lib/features/import/data/import_repository.dart`
- Token-lagring ar forberedd for mobil men inte implementerad:
- `flutter/lib/core/platform/token_storage.dart`
- `flutter/lib/core/platform/platform_providers.dart` (har TODO om secure storage).
## Viktig skillnad mot gamla planen
- Ingen hardkodad `Config.apiUrl` med fasta domaner ska inforas som huvudlosning.
- Projektet anvander redan `String.fromEnvironment('API_BASE_URL')`; vi behaller detta och utokar till mobil.
- Befintlig Docker-setup for web ska inte ersattas, bara kompletteras med mobil-byggflode.
## Foreslagen implementation
### Fas 1: Aktivera plattformsstommar utan att rora webdeploy
1. Skapa Android/iOS-mappar i `flutter/`:
- `flutter create --platforms android,ios .`
2. Verifiera att webfiler och befintliga Dart-filer inte overskrivs pa ett destruktivt satt.
3. Bekrafta att Docker-webbygget fortfarande fungerar oforandrat.
**Leverabel:** Projektet innehaller `flutter/android/` och `flutter/ios/` samtidigt som webflodet ar intakt.
### Fas 2: Plattformsaker konfiguration av API-basurl
1. Standardisera API-konfiguration kring en enda kontraktspunkt:
- Behall `API_BASE_URL` via `--dart-define`.
2. Satt tydliga miljoer:
- Web i Docker: `API_BASE_URL=/api` (som idag).
- Android emulator lokalt: t.ex. `http://10.0.2.2:8080/api` (vid lokal backend utan reverse proxy).
- Fysisk mobil/test/prod: publik HTTPS-url (doman som ar natbar utanfor Docker).
3. Se over alla direkta API-basar i Flutter-koden sa att de gar via samma pattern (inga hardkodade hostnamn).
**Leverabel:** Samma kodbas fungerar pa web och mobil genom miljoinjektering, inte forks av API-klient.
### Fas 3: Mobilanpassad auth/tokenlagring
1. Implementera `SecureTokenStorage` med `flutter_secure_storage` for mobil.
2. Uppdatera `platform_providers.dart` till plattformsval:
- Web -> befintlig `WebTokenStorage`.
- Android/iOS -> `SecureTokenStorage`.
3. Verifiera att inloggning/logout/session beter sig lika mellan web och mobil.
**Leverabel:** JWT lagras sakert pa mobil, befintligt webbeteende bibehalls.
### Fas 4: UI- och UX-hardning for mindre skarmar
1. Identifiera skarmar med hog informationsdensitet (admin/import/tabeller).
2. Lagg in responsiva brytpunkter med `LayoutBuilder`/`MediaQuery` dar det behovs.
3. Prioritera funktionellt minimum for mobil i forsta iteration:
- Login
- Inventarie
- Receptlista
- Grundlaggande importfloden
4. Markera admin-tunga vyer som sekundara om de inte ar mobilkritiska i fas 1.
**Leverabel:** Nyckelfloden ar anvandbara pa telefon utan horisontell overflow eller blockerande layoutfel.
### Fas 5: Build- och releaseflode (utan att blanda ihop med Docker-runtime)
1. Dokumentera separata kommandon:
- Web (befintligt): Docker/compose.
- Android: `flutter build apk --release` (och ev. `appbundle`).
- iOS: `flutter build ios --release` (kraver macOS/Xcode).
2. Behall principen: mobilappar kor inte i Docker; Docker far anvandas som byggmiljo dar det ar rimligt.
3. Om CI ska byggas senare: separera web-jobb och mobil-jobb for tydlighet.
**Leverabel:** Reproducerbar byggprocess for web och mobil med tydlig ansvarsskillnad.
### Fas 6: Test- och verifieringsplan
1. Statisk kvalitet:
- `flutter analyze`
- `flutter test`
2. Plattformsverifiering:
- Web via befintlig container
- Android emulator + fysisk enhet
- iOS simulator (pa macOS)
3. Natverksverifiering:
- Bekrafta att mobil kan na vald `API_BASE_URL` over HTTPS/CORS/proxyregler.
4. Regression:
- Inloggning, token-refresh/logout
- CRUD i inventarie/recept
- Importendpoints med storre payloads
**Leverabel:** Checklista med passerade verifieringspunkter innan distribution.
## Konkreta filer som sannolikt berors
- `flutter/pubspec.yaml` (nytt beroende for secure storage)
- `flutter/lib/core/platform/platform_providers.dart`
- `flutter/lib/core/platform/token_storage.dart` (ev. endast kontraktsjustering)
- Ny fil: `flutter/lib/core/platform/secure_token_storage.dart`
- Mobilplattformar som genereras: `flutter/android/**`, `flutter/ios/**`
- Dokumentation: `README.md` och/eller `TEKNISK_BESKRIVNING.md` (kommandon och miljoexempel)
## Risker och hantering
- iOS-bygg kan inte verifieras i Windows/Linux-miljo -> hanteras med separat macOS-steg.
- Hardkodade URL:er kan smyga sig in i featurekod -> hanteras med kodsok + central konfigpolicy.
- UI-regression pa web vid responsiva andringar -> hanteras med web-regressionstest av kritiska vyer.
## Prioriterad ordning for implementation
1. Fas 1 (plattformsstommar)
2. Fas 2 (API-konfiguration)
3. Fas 3 (secure token storage)
4. Fas 6 del 1 (analyze/test tidigt)
5. Fas 4 (mobil UI-hardning)
6. Fas 5 + Fas 6 slutlig verifiering och dokumentation
## Definition of Done
- Flutter-projektet bygger for web + android (och ios dar macOS finns).
- Mobil och web anvander samma API-konfigmodell via `--dart-define`.
- Mobil lagrar token sakert; webflodet ar oforandrat.
- Minst nyckelfloden login/inventarie/recept/import ar verifierade pa mobil.
- Dokumentationen beskriver exakt hur man bygger och kor respektive plattform i detta repo.
@@ -0,0 +1,134 @@
# Session Checkpoint (2026-05-12)
> Föregående checkpoint: [SESSION_CHECKPOINT_2026-05-11.md](SESSION_CHECKPOINT_2026-05-11.md)
## Status
- Alla ändringar är lokalt implementerade och verifierade (tester gröna, inga type-fel, 0 npm-sårbarheter).
- Ej committat/pushat.
## Klart i denna session
### 1. Alias-scopehantering (admin kan höja privat → globalt)
**Backend (`receipt-alias`):**
- `UpdateReceiptAliasDto` utökat med `isGlobal?: boolean` (`@IsBoolean()`).
- `receipt-alias.service.ts`: `update()` hanterar scope-byte med:
- Admin-only-regel: vanlig användare → `ForbiddenException`.
- Global → privat utan owner → `BadRequestException`.
- Beräknar `nextIsGlobal` + `nextOwnerId` korrekt och sparar.
- Konfliktcheck baseras på nästa scope, inte gammalt.
**Admin-UI (`flutter/lib/features/admin/presentation/admin_aliases_panel.dart`):**
- Edit-dialog har scope-switch.
- Switch är **låst (disabled)** om aliaset redan är globalt — endast privat → global är möjligt.
- Subtitel förklarar läget dynamiskt.
- Bekräftelse-chip visas i listan i 6 sekunder efter genomförd scope-ändring.
- `_scopeChangedAliasId` / `_scopeChangedToGlobal` state-fält med Timer + dispose-cleanup.
**Admin repository (`flutter/lib/features/admin/data/admin_repository.dart`):**
- `updateReceiptAlias` tar emot `bool? isGlobal` och inkluderar det i PATCH-body.
**Tester (16/16 passerar):**
- `receipt-alias.service.spec.ts`: admin kan höja privat → global; användare blockeras; global → privat utan owner ger `BadRequestException`.
- `receipt-alias.security.spec.ts`: controller delegerar DTO med `isGlobal` korrekt.
### 2. Kategori-chip fallback
- Den gemensamma chip-buildern returnerar nu `SizedBox.shrink()` vid tom kategoriväg — "okänd"-chip visas inte längre i admin- eller importpaneler.
### 3. Beroendehärdning
**recipe-app backend:**
- Uppgraderat till **NestJS 11** (`@nestjs/common/core/platform-express/testing` 11.1.19, `@nestjs/jwt` 11.0.2, `@nestjs/passport` 11.0.5).
- `multer` uppgraderat till **2.1.1** (åtgärdar high-severity CVE i transitivt beroende).
- `@types/express`**5.0.5**, `@nestjs/cli`**11.0.21**, `@nestjs/schematics`**11.1.0**.
- `jwt.strategy.ts`: fail-fast check för `JWT_SECRET` + typed `secretOrKey`.
- `JWT_SECRET: ci-test-secret` satt i CI-env i `.gitea/workflows/test.yml`.
- `npm audit --audit-level=high` = **0 sårbarheter**.
**microservice-importer backend:**
- Samma uppgradering till Nest 11 + multer 2.1.1 + `@types/express` 5.
- `npm audit --audit-level=high` = **0 sårbarheter**.
### 4. Quality-gate scripts
**recipe-app/backend/package.json** nya scripts:
| Script | Syfte |
|---|---|
| `prisma:validate` | `prisma validate --schema prisma/schema.prisma` |
| `typecheck` | `tsc --noEmit` |
| `audit:high` | `npm audit --audit-level=high` |
| `quality:ci` | Kedja: validate → generate → typecheck → test → build → audit |
**recipe-app/.gitea/workflows/test.yml** uppdateringar:
- Prisma-steg kör `npm run prisma:validate`.
- Audit-steg kör `npm run audit:high`.
- Nytt **Typecheck backend**-steg i PR-snabbjobb och push-fulljobb.
**microservice-importer/backend/package.json** nya scripts:
| Script | Syfte |
|---|---|
| `typecheck` | `tsc --noEmit` |
| `audit:high` | `npm audit --audit-level=high` |
| `quality:ci` | Kedja: typecheck → build → audit |
**microservice-importer/.github/workflows/test.yml** uppdateringar:
- `npm install``npm ci`.
- Prisma/test-steg ersatta med: typecheck → build → audit.
- `continue-on-error` borttagen från build.
## Ändrade filer (recipe-app)
- `backend/src/receipt-alias/dto/update-receipt-alias.dto.ts`
- `backend/src/receipt-alias/receipt-alias.service.ts`
- `backend/src/receipt-alias/receipt-alias.service.spec.ts`
- `backend/src/receipt-alias/receipt-alias.security.spec.ts`
- `backend/src/auth/jwt.strategy.ts`
- `backend/package.json`
- `backend/package-lock.json`
- `.gitea/workflows/test.yml`
- `flutter/lib/features/admin/data/admin_repository.dart`
- `flutter/lib/features/admin/presentation/admin_aliases_panel.dart`
- `TEKNISK_BESKRIVNING.md`
- `NEXT_STEPS.md`
- `_archive/docs/SESSION_CHECKPOINT_2026-05-12.md` (denna fil)
## Ändrade filer (microservice-importer)
- `backend/package.json`
- `.github/workflows/test.yml`
- `TEKNISK_BESKRIVNING.md`
- `next_steps_MSImporter.md`
## Snabb återstart nästa gång
```bash
# Verifiera status
cd recipe-app/backend
npm run quality:ci
cd ../../microservice-importer/backend
npm run quality:ci
```
Flutter-analys:
```bash
cd recipe-app/flutter
flutter analyze lib/features/admin/presentation/admin_aliases_panel.dart
```
Alias-tester:
```bash
cd recipe-app/backend
npm test -- src/receipt-alias/receipt-alias.service.spec.ts src/receipt-alias/receipt-alias.security.spec.ts --runInBand
```
## Nästa rekommenderade steg
1. Commit och push av samtliga lokala ändringar.
2. Deploy till produktionsmiljö och verifiera alias-flöde med riktiga kvitton.
3. Manuell verifiering: admin kan höja privat → globalt via UI; vanlig användare kan inte.
4. Widgettester för alias-panelens switch (privat → global, disabled-state om globalt).
@@ -0,0 +1,87 @@
# Session Checkpoint (2026-05-21)
> Föregående checkpoint: [SESSION_CHECKPOINT_2026-05-12.md](SESSION_CHECKPOINT_2026-05-12.md)
## Status
- Arbetsytan är ren (`git status --short` gav ingen output).
- Kritiska build-blockers för Flutter-l10n är åtgärdade.
- Backend build + backend tester + Flutter tester verifierade gröna i denna session.
## Klart i denna session
### 1. Felsökning och fix av Docker-fel i Flutter `gen-l10n`
**Problem:** Docker-bygg kraschade vid `flutter gen-l10n` p.g.a. ogiltig ARB-JSON och konflikt i locale-filer.
**Åtgärder:**
- `flutter/lib/l10n/app_en.arb` reparerad (felaktig JSON-struktur, saknade/utanförliggande nycklar).
- Krock mellan engelska locale-filer hanterad (dubbla `en`-källor var en del av tidigare felsymptom).
- `flutter gen-l10n` kördes om utan formatteringsfel.
### 2. Fix av Flutter test-fel: saknad l10n-nyckel `required`
**Problem:** `flutter test` föll på:
- `The getter 'required' isn't defined for the type 'AppLocalizations'`
- fel i `lib/features/admin/presentation/admin_users_panel.dart`.
**Åtgärder:**
- Återställde saknade nycklar i `flutter/lib/l10n/app_en.arb`:
- `required`
- `logoutAction`
- `adminAiDescription`
- `adminPagePrefix`
- Synkade svenska ARB-filen och la till saknad nyckel:
- `profileDatabaseDescription`
- Regenererade lokaliseringar med `flutter gen-l10n`.
### 3. Kvalitetsverifiering
Körda verifieringar:
```bash
# Backend
cd backend
npm run build
npm run test
# Flutter
cd ../flutter
flutter gen-l10n
flutter test --reporter compact
```
**Resultat:**
- Backend build: OK
- Backend tests: OK (29/29 suites, 245/245 tester)
- Flutter tests: OK (alla passerar)
## Viktig kontext inför nästa session
- Root-varningen från Flutter i Docker (`trying to run flutter as root`) är en varning och blockerar inte i sig.
- Den blockerande orsaken var ARB/l10n-konsistens, inte root-varningen.
- Nuvarande l10n-läge är stabilt efter regeneration.
## Rekommenderad snabbstart imorgon
```bash
# 1) Verifiera ren arbetsyta
git status --short
# 2) Reprova hela lokala verifieringen
cd backend
npm run build && npm run test
cd ../flutter
flutter gen-l10n
flutter test --reporter compact
# 3) Om allt är grönt, kör deploy/build-pipeline igen
```
## Ändrade filer i denna session (huvudsakligen)
- `flutter/lib/l10n/app_en.arb`
- `flutter/lib/l10n/app_sv.arb`
- genererade l10n-filer under `flutter/lib/l10n/generated/*`
- mindre korrigeringar i backend-test/service under felsökningen, slutläge verifierat grönt.
+12 -12
View File
@@ -3,7 +3,7 @@
Detta dokument ar for anvandare och operativa testare.
Har beskriver vi vad som fungerar i Flutter-klienten och hur den anvands i praktiken.
## Dokumentstatus (2026-05-03)
## Dokumentstatus (2026-05-19)
- Fokus: anvandarflode, inte implementation.
- Teknisk detaljniva finns i `teknisk_beskrivning_flutter.md`.
@@ -14,12 +14,14 @@ Har beskriver vi vad som fungerar i Flutter-klienten och hur den anvands i prakt
Flutter-webben ar en klient for Recipe App som kors i Docker och exponeras via Caddy.
Den anvands parallellt med Next-frontenden under migrering och verifiering.
## Senaste forbattringar
- Kvittoimportens granskningsflode ar klart och stabiliserat.
- Pagande kvittoimport sparas i klientens session och kan atertas efter refresh/navigation.
- Tolkning av antal/forpackning i kvittorader ar forbattrad, inklusive format som `2st`.
- AI-kategoriforslag och produktforslag visas separerat for tydligare val.
## Senaste forbattringar
- Kvittoimportens granskningsflode ar klart och stabiliserat.
- Pagande kvittoimport sparas i klientens session och kan atertas efter refresh/navigation.
- Tolkning av antal/forpackning i kvittorader ar forbattrad, inklusive format som `2st`.
- AI-kategoriforslag och produktforslag visas separerat for tydligare val.
- Flyerimport har nu sessionpersistens med lattviktig cache (`sessionId`, filnamn, valda rader).
- Flyer-tabben hydrerar tillstand via backend-sessioner vid aterbesok och app-omstart.
## Aktuella anvandarfloden
@@ -46,8 +48,6 @@ Den anvands parallellt med Next-frontenden under migrering och verifiering.
- `teknisk_beskrivning_flutter.md` - teknisk referens for drift/utveckling.
- `../README.md` - overgripande produktinformation.
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
## Notering
Aktiv och detaljerad status for Flutter-sparet finns i rotens dokumentation och i teknisk Flutter-dokumentation i samma katalog.
+22 -33
View File
@@ -3,22 +3,24 @@
Detta dokument ar Flutter-teamets roadmap och prioriteringslista.
All historik och implementationdetaljer finns i `teknisk_beskrivning_flutter.md`.
## Dokumentstatus (2026-05-03)
## Dokumentstatus (2026-05-19)
- Fokus: aktiv planering framat.
- Endast en roadmap for Flutter for att undvika dubbletter.
## Klart senaste sessionerna
## Klart senaste sessionerna
- Fas 6b: granskningsflode for kvittoimport (edit, destination, merge, spara).
- Fas 6c: separering av AI-kategorichip och produktforslagschip.
- Fas 6d: klientpersistens for pagande kvittoimport + forbattrad antal/forpackningsinferens.
- Flyerimport: sessionpersistens i klient och backend-hydrering via sessions-endpoints.
- Fas 6b: granskningsflode for kvittoimport (edit, destination, merge, spara).
- Fas 6c: separering av AI-kategorichip och produktforslagschip.
- Fas 6d: klientpersistens for pagande kvittoimport + forbattrad antal/forpackningsinferens.
## Pagande arbete
- Robust bildimport och diagnostik i drift.
- Aliasstrategi i kvittoimport (hybrid user-scope + global fallback via admin).
- Utokad adminfunktionalitet i Flutter-sparet.
## Pagande arbete
- Robust bildimport och diagnostik i drift.
- Aliasstrategi i kvittoimport (hybrid user-scope + global fallback via admin).
- Utokad adminfunktionalitet i Flutter-sparet.
- E2E-verifiering av flyerimport: tab-byte, refresh och app-omstart i staging.
## Prioriterade nasta steg
@@ -34,11 +36,11 @@ All historik och implementationdetaljer finns i `teknisk_beskrivning_flutter.md`
- AI-guiding labels ("Denna rad matchade mejeri automatiskt")
- Implementering: ~8h
2. Verifiera bildimport och felhantering end-to-end i testmiljo.
3. Implementera alias-inlarning vid manuell korrigering i importflodet.
4. Forbattra UI/UX i granskningsfloden for kvittoimport.
5. Fortsatt migrering av kvarvarande adminfloden.
6. Lokalisera kvarvarande delar i import- och inventarievyer.
2. Verifiera bildimport och felhantering end-to-end i testmiljo.
3. Implementera alias-inlarning vid manuell korrigering i importflodet.
4. Forbattra UI/UX i granskningsfloden for kvittoimport.
5. Fortsatt migrering av kvarvarande adminfloden.
6. Lokalisera kvarvarande delar i import- och inventarievyer.
## Viktiga beslut
@@ -52,20 +54,7 @@ All historik och implementationdetaljer finns i `teknisk_beskrivning_flutter.md`
- `teknisk_beskrivning_flutter.md` - teknisk referens.
- `../NEXT_STEPS.md` - overgripande roadmap for hela produkten.
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
## Notering
Denna fil ar arkiv/planunderlag for Flutter-sparet. Primar status och prioritering finns i rotens `NEXT_STEPS.md`.
+157 -52
View File
@@ -1,52 +1,157 @@
# Plan for produktlansering
Detta dokument ar en releasechecklista.
Det kompletterar `NEXT_STEPS.md` och ska inte duplicera backloggen.
## Dokumentstatus (2026-05-03)
- Malgrupp: produktagare, systemadministratorer, utvecklingsteam.
- Fokus: vad som maste vara verifierat innan release.
## 1. Sakerhet och data
- [ ] Kansliga uppgifter krypterade enligt beslutad modell.
- [ ] Rate limiting aktiv pa relevanta API/AI-endpoints.
- [ ] Secret-hantering verifierad (inga hardkodade hemligheter).
- [ ] Roll- och accesskontroller testade i praktiken.
## 2. DevOps och stabilitet
- [ ] CI/CD for build, test och deploy pa plats.
- [ ] Migreringar + seedning kor konsekvent vid release.
- [ ] Health checks och loggning verifierade.
- [ ] Backup/restore testad for datavolymer.
## 3. Kvalitet och test
- [ ] Kritiska floden har testtackning (auth, import, CRUD, AI).
- [ ] Minst en end-to-end verifiering i testmiljo per release.
- [ ] DTO-validering och felhantering kontrollerad.
## 4. Funktionell releaseklarhet
- [ ] Kvittoimport fungerar end-to-end med granskningssteg.
- [ ] User-scoped produktmodell verifierad med flera testanvandare.
- [ ] Kategoritrad seedat och validerat i aktuell miljo.
- [ ] Bildimport och fallbackfloden fungerar i driftmiljo.
## 5. Riskhantering
- [ ] AI-kostnad, timeout och fallback beteende verifierat.
- [ ] Ingen osynk mellan migrationer och seedskript.
- [ ] Kanda release-risker dokumenterade med ansvarig agare.
## Relaterade dokument
- `NEXT_STEPS.md` - overgripande prioriteringar.
- `TEKNISK_BESKRIVNING.md` - teknisk implementation.
- `flutter/next_steps_flutter.md` - Flutter-specifik leveransplan.
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
# Plan för produktlansering
Detta dokument är en releasechecklista.
Det kompletterar `NEXT_STEPS.md` och ska inte duplicera backloggen.
## Dokumentstatus (2026-05-21)
- Målgrupp: produktägare, systemadministratörer, utvecklingsteam.
- Fokus: vad som måste vara verifierat innan release.
## 1. Säkerhet och dataskydd
- [ ] Känsliga uppgifter krypterade enligt beslutad modell.
- [ ] Rate limiting aktiv på relevanta API/AI-endpoints.
- [ ] Secret-hantering verifierad (inga hardkodade hemligheter).
- [ ] Roll- och accesskontroller testade i praktiken.
- [ ] Migrera autentisering från `localStorage` till `httpOnly`-cookies i Flutter Web.
- [ ] Implementera automatiserad datarensning för `AiTrace` (retention-policy).
- [ ] Utför penetrationstest för IDOR och XSS.
- [ ] Dokumentera GDPR-processer (t.ex. rätt att glömmas, dataportabilitet).
## 2. DevOps och stabilitet
- [ ] CI/CD för build, test och deploy på plats.
- [ ] Migreringar + seedning kör konsekvent vid release.
- [ ] Health checks och loggning verifierade.
- [ ] Backup/restore testad för datavolymer.
## 3. Kvalitet och test
- [ ] Kritiska flöden har testtäckning (auth, import, CRUD, AI).
- [ ] Minst en end-to-end verifiering i testmiljö per release.
- [ ] DTO-validering och felhantering kontrollerad.
- [ ] Skapa E2E-tester för flyer- och kvittoimport (t.ex. Cypress eller Playwright).
- [ ] Validera OCR-korrigeringar med ett större dataset.
## 4. Funktionell releaseklarhet
- [ ] Kvittoimport fungerar end-to-end med granskningsteg.
- [ ] User-scoped produktmodell verifierad med flera testanvändare.
- [ ] Kategoriträd seedat och validerat i aktuell miljö.
- [ ] Bildimport och fallbackflöde fungerar i driftmiljö.
- [ ] Genomför manuell testning av aliasflödet med riktiga kvitton.
- [ ] Test sessionhydrering i olika scenarier (t.ex. flikbyte, app-krasch).
## 5. Riskhantering
- [ ] AI-kostnad, timeout och fallback-beteende verifierat.
- [ ] Ingen osynk mellan migrationer och seedskript.
- [ ] Kända release-risker dokumenterade med ansvarig ägare.
- [ ] Implementera adaptiv retry-logik med exponentiell backoff för AI-anrop.
- [ ] Lägg till kostnadsgränser och varningar i `AiTraceService`.
- [ ] Dokumentera fallback-beteende (t.ex. cache, manuell granskning).
## 6. Prestanda och skalbarhet
- [ ] Optimera Prisma-frågor med `EXPLAIN ANALYZE` och indexering.
- [ ] Implementera virtuell scrollning i Flutter för stora listor.
- [ ] Dokumentera belastningsgränser för `importer-api` och planera för skalning.
- [ ] Lägg till Redis-cache för vanliga frågor (t.ex. produkt-sökning).
## 7. Dokumentation och användarstöd
- [ ] Skapa admin-handbok med vanliga arbetsflöden.
- [ ] Förbättra felmeddelanden i UI med tydliga instruktioner.
- [ ] Utöka `HelpTextsModule` med kontextuella guider för alla huvudfunktioner.
- [ ] Generera OpenAPI-specifikation för backend (t.ex. med `@nestjs/swagger`).
## Relaterade dokument
- `NEXT_STEPS.md` — övergripande prioriteringar.
- `TEKNISK_BESKRIVNING.md` — teknisk implementation.
- `flutter/next_steps_flutter.md` — Flutter-specifik leveransplan.
- `MVP_CHECKLISTA.md` — testchecklista för MVP.
## Kritiska utvecklingsområden
### 1. Säkerhet och dataskydd (Högsta prioritet)
**Motivering:**
- Känsliga uppgifter (t.ex. JWT i localStorage) och AI-trace-loggar innehåller maskerade men potentiellt känsliga uppgifter.
- Risk för XSS-attacker och IDOR om inte åtgärdat.
**Åtgärder:**
- Migrera autentisering till `httpOnly`-cookies.
- Implementera automatiserad datarensning för `AiTrace`.
- Utför penetrationstester.
---
### 2. Stabilitet i AI-integration (Hög prioritet)
**Motivering:**
- Timeout och retry-logik kan leda till 503-fel.
- Saknad kostnadskontroll för AI-anrop.
**Åtgärder:**
- Implementera adaptiv retry-logik.
- Lägg till kostnadsgränser i `AiTraceService`.
- Dokumentera fallback-beteende.
---
### 3. Sluttestning av importflöden (Hög prioritet)
**Motivering:**
- Alias-strategin och sessionhydrering är ej fullt testade.
- Saknas E2E-tester för hela importflödet.
**Åtgärder:**
- Genomför manuell testning med riktiga kvitton.
- Skapa E2E-tester.
---
### 4. Prestanda och skalbarhet (Medel prioritet)
**Motivering:**
- Långsamma Prisma-frågor och renderingsproblem i Flutter-UI.
- Saknad caching-strategi.
**Åtgärder:**
- Optimera databasfrågor.
- Implementera virtuell scrollning.
- Lägg till Redis-cache.
---
### 5. Dokumentation och användarstöd (Medel prioritet)
**Motivering:**
- Saknas admin-guider och tydliga felmeddelanden.
- Begränsat innehåll i `HelpTextsModule`.
**Åtgärder:**
- Skapa admin-handbok.
- Utöka hjälptexter.
- Generera OpenAPI-dokumentation.
---
## Prioriteringsordning
| **Område** | **Prioritet** | **Berörda filer/dokument** |
|---------------------------------|---------------|-----------------------------------------------------|
| 1. Säkerhet och dataskydd | ⭐⭐⭐⭐⭐ | `TEKNISK_BESKRIVNING.md`, `flutter/.../auth/` |
| 2. Stabilitet i AI-integration | ⭐⭐⭐⭐ | `flyerimporter.md`, `backend/src/ai/` |
| 3. Sluttestning av importflöden | ⭐⭐⭐⭐ | `MVP_CHECKLISTA.md`, `NEXT_STEPS.md` |
| 4. Prestanda och skalbarhet | ⭐⭐⭐ | `backend/src/flyer-import/`, `flutter/lib/admin/` |
| 5. Dokumentation och stöd | ⭐⭐ | `README.md`, `HelpTextsModule`, `backend/src/ai/` |
---
## Nästa steg
1. Låsa säkerhetsbristerna (särskilt autentisering och dataskydd).
2. Stabilisera AI-integration med bättre felhantering och kostnadskontroll.
3. Kör sluttestning av importflöden och rätta eventuella regressioner.
4. Optimera prestanda i databas och UI.
5. Förbättra dokumentationen för admin och slutanvändare.
Dessa åtgärder säkerställer en **stabil, säker och användarvänlig lansering**.
+5 -1
View File
@@ -1 +1,5 @@
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
## Arkiverad frontend-dokumentation
- Den aktiva dokumentationen finns i rotens `README.md`, `TEKNISK_BESKRIVNING.md` och `NEXT_STEPS.md`.
- Senaste uppdatering (2026-05-19): flyerimport har session-endpoints i backend och klientpersistens/hydrering i Flutter.
- Denna fil behålls endast som arkivreferens för äldre frontend-spår.
+1
View File
@@ -0,0 +1 @@
min-release-age=1
+2 -2
View File
@@ -17,7 +17,7 @@ COPY prisma ./prisma
COPY src ./src
COPY tsconfig.json nest-cli.json ./
RUN ./node_modules/.bin/prisma generate
RUN npm test
RUN npm test -- --runInBand
RUN npm run build
# Stage 3: Kör applikationen
@@ -31,4 +31,4 @@ COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/dist ./dist
EXPOSE 8080
CMD ["sh", "-c", "until ./node_modules/.bin/prisma migrate deploy; do echo 'Migration failed, retrying in 5s...'; sleep 5; done && node dist/main"]
CMD ["sh", "-c", "if [ \"${SKIP_MIGRATION:-false}\" != \"true\" ]; then echo 'Running automatic Prisma migration...'; until ./node_modules/.bin/prisma migrate deploy --schema prisma/schema.prisma; do echo 'Migration failed, retrying in 5s...'; sleep 5; done; else echo 'Skipping automatic Prisma migration (SKIP_MIGRATION=true).'; fi && node dist/main"]
+26
View File
@@ -0,0 +1,26 @@
import tseslint from '@typescript-eslint/eslint-plugin';
import tsParser from '@typescript-eslint/parser';
export default [
{
ignores: ['dist/**', 'node_modules/**'],
},
{
files: ['src/**/*.ts'],
languageOptions: {
parser: tsParser,
parserOptions: {
sourceType: 'module',
},
},
plugins: {
'@typescript-eslint': tseslint,
},
rules: {
'@typescript-eslint/no-unused-vars': [
'warn',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
],
},
},
];
+3598 -1916
View File
File diff suppressed because it is too large Load Diff
+33 -14
View File
@@ -7,49 +7,65 @@
"start": "node dist/main",
"start:dev": "nest start --watch",
"prisma:generate": "prisma generate",
"prisma:validate": "prisma validate --schema prisma/schema.prisma",
"prisma:migrate": "prisma migrate dev",
"prisma:deploy": "prisma migrate deploy",
"typecheck": "tsc --noEmit",
"lint": "eslint \"src/**/*.ts\"",
"audit:high": "npm audit --audit-level=high",
"quality:ci": "npm run prisma:validate && npm run prisma:generate && npm run typecheck && npm run lint && npm test && npm run build && npm run audit:high",
"test": "jest",
"test:watch": "jest --watch"
},
"dependencies": {
"@nestjs/common": "^10.3.0",
"@nestjs/core": "^10.3.0",
"@nestjs/jwt": "^10.2.0",
"@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^10.3.0",
"@mistralai/mistralai": "^0.5.0",
"@nestjs/common": "^11.1.19",
"@nestjs/core": "^11.1.19",
"@nestjs/jwt": "^11.0.2",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.1.19",
"@nestjs/schedule": "^6.1.3",
"@nestjs/throttler": "^6.4.0",
"@prisma/client": "6.12.0",
"bcryptjs": "^2.4.3",
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"helmet": "^8.0.0",
"multer": "^1.4.5-lts.2",
"multer": "^2.1.1",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"pdf-parse": "^1.1.1",
"prisma": "6.12.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"sharp": "^0.33.5",
"tesseract.js": "^5.1.1",
"uuid": "^11.1.0"
},
"devDependencies": {
"@nestjs/cli": "^10.3.0",
"@nestjs/schematics": "^10.1.1",
"@nestjs/testing": "^10.3.0",
"@nestjs/cli": "^11.0.21",
"@nestjs/schematics": "^11.1.0",
"@nestjs/testing": "^11.1.19",
"@types/bcryptjs": "^2.4.6",
"@types/express": "^4.17.21",
"@types/jest": "^29.5.14",
"@types/express": "^5.0.5",
"@types/jest": "^30.0.0",
"@types/multer": "^1.4.12",
"@types/node": "^22.15.29",
"@types/node": "^22.19.19",
"@types/passport-jwt": "^4.0.1",
"@types/pdf-parse": "^1.1.5",
"@types/supertest": "^7.2.0",
"@types/uuid": "^10.0.0",
"jest": "^29.7.0",
"@typescript-eslint/eslint-plugin": "^8.46.2",
"@typescript-eslint/parser": "^8.46.2",
"eslint": "^9.38.0",
"jest": "^30.4.2",
"supertest": "^7.2.2",
"ts-jest": "^29.2.6",
"ts-jest": "^29.4.11",
"typescript": "^5.4.5"
},
"overrides": {
"test-exclude": "^8.0.0"
},
"jest": {
"preset": "ts-jest",
"testEnvironment": "node",
@@ -59,6 +75,9 @@
"js",
"json",
"ts"
],
"transformIgnorePatterns": [
"node_modules/(@mistralai)"
]
}
}
@@ -0,0 +1,40 @@
-- Rensar applikationsdata men behaller kategorier och anvandare.
-- Uppdatera denna fil när nya tabeller läggs till i schema.prisma.
SET FOREIGN_KEY_CHECKS = 0;
-- Flyer-related tables (tabeller för flyer-import)
DELETE FROM `FlyerSelection`;
DELETE FROM `FlyerItem`;
DELETE FROM `FlyerSession`;
-- Shopping list (om tabellen existerar)
DELETE FROM `ShoppingListItem`;
-- Inventory (lagerhålling)
DELETE FROM `InventoryConsumption`;
DELETE FROM `InventoryItem`;
-- Recipes (recept)
DELETE FROM `RecipeShare`;
DELETE FROM `RecipeIngredient`;
DELETE FROM `Recipe`;
-- Meal planning (måltidsplanering)
DELETE FROM `MealPlanEntry`;
-- Pantry (skafferi)
DELETE FROM `PantryItem`;
-- Products (produkter) - BEHAL KATEGORIER OCH ANVANDARE
DELETE FROM `Nutrition`;
DELETE FROM `ProductTag`;
DELETE FROM `ReceiptAlias`;
DELETE FROM `UnitMapping`;
DELETE FROM `UserProduct`;
DELETE FROM `Product`;
-- Help texts (hjälptexter)
DELETE FROM `HelpText`;
SET FOREIGN_KEY_CHECKS = 1;
@@ -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)
);
@@ -0,0 +1,85 @@
CREATE TABLE `FlyerSession` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`userId` INTEGER NOT NULL,
`retailer` VARCHAR(191) NOT NULL,
`weekKey` VARCHAR(191) NOT NULL,
`status` VARCHAR(191) NOT NULL DEFAULT 'draft',
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
`expiresAt` DATETIME(3) NULL,
INDEX `FlyerSession_userId_idx`(`userId`),
INDEX `FlyerSession_weekKey_idx`(`weekKey`),
INDEX `FlyerSession_status_idx`(`status`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE TABLE `FlyerItem` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`sessionId` INTEGER NOT NULL,
`rawName` VARCHAR(191) NOT NULL,
`normalizedName` VARCHAR(191) NOT NULL,
`categoryHint` VARCHAR(191) NULL,
`price` DECIMAL(10, 2) NULL,
`priceUnit` VARCHAR(191) NULL,
`comparisonPrice` DECIMAL(10, 2) NULL,
`comparisonUnit` VARCHAR(191) NULL,
`offerText` VARCHAR(191) NULL,
`parseConfidence` DOUBLE NOT NULL,
`parseReasons` JSON NULL,
`matchedProductId` INTEGER NULL,
`matchedProductName` VARCHAR(191) NULL,
`matchedVia` VARCHAR(191) NULL,
`matchConfidence` DOUBLE NULL,
`matchReasons` JSON NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
INDEX `FlyerItem_sessionId_idx`(`sessionId`),
INDEX `FlyerItem_normalizedName_idx`(`normalizedName`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE TABLE `FlyerSelection` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`sessionId` INTEGER NOT NULL,
`itemId` INTEGER NOT NULL,
`userId` INTEGER NOT NULL,
`plannedQuantity` DECIMAL(10, 2) NULL,
`plannedUnit` VARCHAR(191) NULL,
`priority` VARCHAR(191) NOT NULL DEFAULT 'normal',
`note` VARCHAR(191) NULL,
`status` VARCHAR(191) NOT NULL DEFAULT 'planned',
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `FlyerSelection_sessionId_itemId_key`(`sessionId`, `itemId`),
INDEX `FlyerSelection_sessionId_idx`(`sessionId`),
INDEX `FlyerSelection_userId_status_idx`(`userId`, `status`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
ALTER TABLE `FlyerSession`
ADD CONSTRAINT `FlyerSession_userId_fkey`
FOREIGN KEY (`userId`) REFERENCES `User`(`id`)
ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE `FlyerItem`
ADD CONSTRAINT `FlyerItem_sessionId_fkey`
FOREIGN KEY (`sessionId`) REFERENCES `FlyerSession`(`id`)
ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE `FlyerSelection`
ADD CONSTRAINT `FlyerSelection_sessionId_fkey`
FOREIGN KEY (`sessionId`) REFERENCES `FlyerSession`(`id`)
ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE `FlyerSelection`
ADD CONSTRAINT `FlyerSelection_itemId_fkey`
FOREIGN KEY (`itemId`) REFERENCES `FlyerItem`(`id`)
ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE `FlyerSelection`
ADD CONSTRAINT `FlyerSelection_userId_fkey`
FOREIGN KEY (`userId`) REFERENCES `User`(`id`)
ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,51 @@
ALTER TABLE `FlyerSession`
ADD COLUMN `sourceFileName` VARCHAR(191) NULL,
ADD COLUMN `sourceMimeType` VARCHAR(191) NULL,
ADD COLUMN `sourceFileSize` INTEGER NULL,
ADD COLUMN `sourceStorageKey` VARCHAR(191) NULL,
ADD COLUMN `sourceData` LONGBLOB NULL;
ALTER TABLE `FlyerItem`
ADD COLUMN `categoryId` INTEGER NULL;
ALTER TABLE `FlyerItem`
ADD CONSTRAINT `FlyerItem_categoryId_fkey`
FOREIGN KEY (`categoryId`) REFERENCES `Category`(`id`)
ON DELETE SET NULL ON UPDATE CASCADE;
CREATE INDEX `FlyerItem_categoryId_idx` ON `FlyerItem`(`categoryId`);
CREATE TABLE `ShoppingListItem` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`userId` INTEGER NOT NULL,
`name` VARCHAR(191) NOT NULL,
`productId` INTEGER NULL,
`categoryId` INTEGER NULL,
`quantity` DECIMAL(10, 2) NULL,
`unit` VARCHAR(191) NULL,
`source` VARCHAR(191) NOT NULL DEFAULT 'manual',
`status` VARCHAR(191) NOT NULL DEFAULT 'open',
`checkedAt` DATETIME(3) NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
INDEX `ShoppingListItem_userId_status_idx`(`userId`, `status`),
INDEX `ShoppingListItem_productId_unit_status_idx`(`productId`, `unit`, `status`),
INDEX `ShoppingListItem_categoryId_idx`(`categoryId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
ALTER TABLE `ShoppingListItem`
ADD CONSTRAINT `ShoppingListItem_userId_fkey`
FOREIGN KEY (`userId`) REFERENCES `User`(`id`)
ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE `ShoppingListItem`
ADD CONSTRAINT `ShoppingListItem_productId_fkey`
FOREIGN KEY (`productId`) REFERENCES `Product`(`id`)
ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE `ShoppingListItem`
ADD CONSTRAINT `ShoppingListItem_categoryId_fkey`
FOREIGN KEY (`categoryId`) REFERENCES `Category`(`id`)
ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,7 @@
-- AlterTable
ALTER TABLE `FlyerItem`
ADD COLUMN `brand` VARCHAR(191) NULL,
ADD COLUMN `weight` VARCHAR(191) NULL,
ADD COLUMN `bundleWeight` VARCHAR(191) NULL,
ADD COLUMN `isBundle` BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN `bundleItems` JSON NULL;
@@ -0,0 +1,24 @@
-- CreateTable
CREATE TABLE `AiTrace` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`source` VARCHAR(191) NOT NULL,
`userId` INTEGER NULL,
`sessionId` INTEGER NULL,
`model` VARCHAR(191) NULL,
`prompt` LONGTEXT NULL,
`rawOutput` LONGTEXT NULL,
`normalizedOutput` JSON NULL,
`status` VARCHAR(191) NOT NULL,
`error` TEXT NULL,
`durationMs` INTEGER NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
INDEX `AiTrace_source_createdAt_idx`(`source`, `createdAt`),
INDEX `AiTrace_userId_createdAt_idx`(`userId`, `createdAt`),
INDEX `AiTrace_status_createdAt_idx`(`status`, `createdAt`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- AddForeignKey
ALTER TABLE `AiTrace` ADD CONSTRAINT `AiTrace_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,4 @@
-- AlterTable
ALTER TABLE `FlyerItem`
ADD COLUMN `signals` JSON NULL,
ADD COLUMN `displayNameDetailed` VARCHAR(191) NULL;
@@ -0,0 +1,9 @@
-- Add originCountries field to InventoryItem table
-- This migration adds support for multiple origin countries as a JSON array
ALTER TABLE `InventoryItem`
ADD COLUMN `originCountries` JSON NULL
AFTER `origin`;
-- Create an index for the originCountries field for better query performance
CREATE INDEX `IDX_InventoryItem_originCountries` ON `InventoryItem` ((CAST(`originCountries` AS CHAR(255))));
+159 -8
View File
@@ -27,10 +27,14 @@ model User {
ownedProducts Product[]
inventoryItems InventoryItem[]
pantryItems PantryItem[]
mealPlanEntries MealPlanEntry[]
receiptAliases ReceiptAlias[]
unitMappings UnitMapping[]
}
mealPlanEntries MealPlanEntry[]
receiptAliases ReceiptAlias[]
unitMappings UnitMapping[]
flyerSessions FlyerSession[]
flyerSelections FlyerSelection[]
shoppingListItems ShoppingListItem[]
aiTraces AiTrace[]
}
model Product {
id Int @id @default(autoincrement())
@@ -55,16 +59,19 @@ model Product {
categoryId Int?
categoryRef Category? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
isPrivate Boolean @default(false)
unitMappings UnitMapping[]
}
unitMappings UnitMapping[]
shoppingListItems ShoppingListItem[]
}
model Category {
id Int @id @default(autoincrement())
name String
parentId Int?
parent Category? @relation("CategoryTree", fields: [parentId], references: [id], onDelete: SetNull)
children Category[] @relation("CategoryTree")
products Product[]
children Category[] @relation("CategoryTree")
products Product[]
flyerItems FlyerItem[]
shoppingListItems ShoppingListItem[]
@@unique([name, parentId])
@@index([parentId])
@@ -97,6 +104,7 @@ model InventoryItem {
unit String
brand String?
origin String?
originCountries Json?
receiptName String?
location String?
purchaseDate DateTime?
@@ -263,3 +271,146 @@ 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])
}
model FlyerSession {
id Int @id @default(autoincrement())
userId Int
retailer String
weekKey String
status String @default("draft")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
expiresAt DateTime?
sourceFileName String?
sourceMimeType String?
sourceFileSize Int?
sourceStorageKey String?
sourceData Bytes?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
items FlyerItem[]
selections FlyerSelection[]
@@index([userId])
@@index([weekKey])
@@index([status])
}
model FlyerItem {
id Int @id @default(autoincrement())
sessionId Int
rawName String
normalizedName String
brand String?
categoryHint String?
categoryId Int?
price Decimal? @db.Decimal(10, 2)
priceUnit String?
comparisonPrice Decimal? @db.Decimal(10, 2)
comparisonUnit String?
weight String?
bundleWeight String?
isBundle Boolean @default(false)
bundleItems Json?
signals Json?
displayNameDetailed String?
offerText String?
parseConfidence Float
parseReasons Json?
matchedProductId Int?
matchedProductName String?
matchedVia String?
matchConfidence Float?
matchReasons Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
session FlyerSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
categoryRef Category? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
selections FlyerSelection[]
@@index([sessionId])
@@index([normalizedName])
@@index([categoryId])
}
model FlyerSelection {
id Int @id @default(autoincrement())
sessionId Int
itemId Int
userId Int
plannedQuantity Decimal? @db.Decimal(10, 2)
plannedUnit String?
priority String @default("normal")
note String?
status String @default("planned")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
session FlyerSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
item FlyerItem @relation(fields: [itemId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([sessionId, itemId])
@@index([sessionId])
@@index([userId, status])
}
model ShoppingListItem {
id Int @id @default(autoincrement())
userId Int
name String
productId Int?
categoryId Int?
quantity Decimal? @db.Decimal(10, 2)
unit String?
source String @default("manual")
status String @default("open")
checkedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
product Product? @relation(fields: [productId], references: [id], onDelete: SetNull)
categoryRef Category? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
@@index([userId, status])
@@index([productId, unit, status])
@@index([categoryId])
}
model AiTrace {
id Int @id @default(autoincrement())
source String
userId Int?
sessionId Int?
model String?
prompt String? @db.LongText
rawOutput String? @db.LongText
normalizedOutput Json?
status String
error String? @db.Text
durationMs Int?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
@@index([source, createdAt])
@@index([userId, createdAt])
@@index([status, createdAt])
}
@@ -0,0 +1,30 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class AiTraceCleanupService {
private readonly logger = new Logger(AiTraceCleanupService.name);
private readonly retentionDays: number;
constructor(private readonly prisma: PrismaService) {
this.retentionDays = parseInt(process.env.AI_TRACE_RETENTION_DAYS ?? '30', 10);
}
@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
async cleanupOldTraces() {
this.logger.log('Starting cleanup of old AiTrace records...');
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - this.retentionDays);
const result = await this.prisma.aiTrace.deleteMany({
where: {
createdAt: {
lt: cutoffDate,
},
},
});
this.logger.log(`Cleaned up ${result.count} old AiTrace records.`);
}
}
+213
View File
@@ -0,0 +1,213 @@
import { AiTraceService } from './ai-trace.service';
describe('AiTraceService receipt masking', () => {
const prismaMock = {
aiTrace: {
findFirst: jest.fn(),
findMany: jest.fn(),
},
flyerSession: {
findMany: jest.fn(),
findUnique: jest.fn(),
},
};
const service = new AiTraceService(prismaMock as any);
beforeEach(() => {
jest.clearAllMocks();
});
it('masks sensitive data in receipt prompt and rawOutput', async () => {
prismaMock.aiTrace.findFirst.mockResolvedValue({
id: 42,
source: 'receipt',
status: 'success',
createdAt: new Date('2026-05-21T10:00:00.000Z'),
userId: 7,
sessionId: null,
model: 'importer-receipt-ai',
durationMs: 240,
error: null,
prompt: 'Kund email anna@example.com och telefon 070-123 45 67',
rawOutput: JSON.stringify({
personnummer: '850101-1234',
email: 'anna@example.com',
nested: {
namn: 'Anna Andersson',
phone: '+46701234567',
},
}),
normalizedOutput: {
items: [
{
rawName: 'Mjolk',
customerEmail: 'anna@example.com',
},
],
},
user: {
username: 'admin',
email: 'admin@example.com',
},
});
const result = await service.getTraceById('receipt-42');
expect(result.prompt).not.toContain('anna@example.com');
expect(result.prompt).not.toContain('070-123 45 67');
expect(result.prompt).toContain('[MASKED]');
expect(result.rawOutput).not.toContain('850101-1234');
expect(result.rawOutput).not.toContain('anna@example.com');
expect(result.rawOutput).not.toContain('Anna Andersson');
expect(result.rawOutput).toContain('[MASKED]');
expect(result.normalizedOutput).toEqual({
items: [
{
rawName: 'Mjolk',
customerEmail: '[MASKED]',
},
],
});
});
it('filters flyer list by errors in database query', async () => {
prismaMock.flyerSession.findMany.mockResolvedValue([]);
await service.listTraces({
source: 'flyer',
limit: 20,
onlyErrors: true,
});
expect(prismaMock.flyerSession.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
items: { none: {} },
}),
}),
);
});
it('returns flyer prompt/rawOutput and trace counters from aiTrace supplement', async () => {
prismaMock.flyerSession.findUnique.mockResolvedValue({
id: 101,
userId: 7,
createdAt: new Date('2026-05-21T12:00:00.000Z'),
sourceFileName: 'willys.pdf',
sourceMimeType: 'application/pdf',
sourceFileSize: 12345,
user: { username: 'admin', email: 'admin@example.com' },
items: [
{
id: 1,
rawName: 'Tomat',
normalizedName: 'tomat',
brand: null,
categoryHint: 'Grönsaker',
categoryId: null,
price: null,
priceUnit: null,
comparisonPrice: null,
comparisonUnit: null,
weight: null,
bundleWeight: null,
isBundle: false,
bundleItems: [],
offerText: null,
parseConfidence: 0.9,
parseReasons: ['low_confidence'],
matchedProductId: null,
matchedProductName: null,
matchedVia: 'none',
matchConfidence: null,
matchReasons: [],
},
],
});
prismaMock.aiTrace.findMany.mockResolvedValue([
{
sessionId: 101,
prompt: 'Flyer prompt med email kund@example.com',
rawOutput: '{"ok":true}',
normalizedOutput: { retryCount: 2, chunkCount: 4 },
},
]);
const result = await service.getTraceById('flyer-101');
expect(result.prompt).toContain('[MASKED]');
expect(result.rawOutput).toContain('{"ok":true}');
expect(result.retryCount).toBe(2);
expect(result.chunkCount).toBe(4);
expect(result.warnings).toEqual(
expect.arrayContaining([
expect.objectContaining({
kind: 'parse',
code: 'low_confidence',
title: 'Låg parsningskvalitet',
severity: 'warning',
}),
]),
);
expect(result.legacyWarnings).toContain('parse:low_confidence');
});
it('keeps multiple token_overlap warnings for same row', async () => {
prismaMock.flyerSession.findUnique.mockResolvedValue({
id: 202,
userId: 9,
createdAt: new Date('2026-05-23T09:00:00.000Z'),
sourceFileName: 'willys-v21.pdf',
sourceMimeType: 'application/pdf',
sourceFileSize: 2222,
user: { username: 'admin', email: 'admin@example.com' },
items: [
{
id: 11,
rawName: 'Tomatmix',
normalizedName: 'tomatmix',
brand: null,
categoryHint: 'Grönsaker',
categoryId: null,
price: null,
priceUnit: null,
comparisonPrice: null,
comparisonUnit: null,
weight: null,
bundleWeight: null,
isBundle: false,
bundleItems: [],
offerText: null,
parseConfidence: 0.9,
parseReasons: [],
matchedProductId: null,
matchedProductName: null,
matchedVia: 'token',
matchConfidence: 0.7,
matchReasons: ['token_overlap:0.42', 'token_overlap:0.73'],
},
],
});
prismaMock.aiTrace.findMany.mockResolvedValue([
{
sessionId: 202,
prompt: 'prompt',
rawOutput: '{"ok":true}',
normalizedOutput: { retryCount: 0, chunkCount: 1 },
},
]);
const result = await service.getTraceById('flyer-202');
const tokenWarnings = result.warnings.filter((warning) => warning.code === 'token_overlap');
expect(tokenWarnings).toHaveLength(2);
expect(result.legacyWarnings).toEqual(
expect.arrayContaining(['match:token_overlap:0.42', 'match:token_overlap:0.73']),
);
});
});
+634
View File
@@ -0,0 +1,634 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import {
describeMatchReason,
describeParseReason,
FlyerReasonDescriptor,
} from '../flyer-import/services/reason-codes';
export type AiTraceSource = 'receipt' | 'flyer';
export type AiTraceStatus = 'success' | 'warning' | 'error';
const AI_TRACE_MASK_FIELDS = ['personnummer', 'telefon', 'email', 'address', 'namn'];
const SWEDISH_PERSONAL_ID_REGEX = /\b(\d{2})?(\d{6})[-+ ]?(\d{4})\b/g;
const EMAIL_REGEX = /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi;
const PHONE_REGEX = /\b(?:\+46|0)\s?\d(?:[\d\s-]{6,}\d)\b/g;
export type AiTraceListItem = {
id: string;
source: AiTraceSource;
status: AiTraceStatus;
createdAt: string;
userId: number;
userLabel: string;
sessionId: number | null;
fileName: string | null;
model: string | null;
durationMs: number | null;
warningsCount: number;
hasPrompt: boolean;
hasOutput: boolean;
error: string | null;
};
export type AiTraceListResponse = {
items: AiTraceListItem[];
nextCursor: string | null;
};
export type AiTraceDetail = {
id: string;
source: AiTraceSource;
status: AiTraceStatus;
createdAt: string;
userId: number;
userLabel: string;
sessionId: number | null;
fileName: string | null;
model: string | null;
durationMs: number | null;
retryCount: number | null;
chunkCount: number | null;
warnings: AdminAiWarning[];
legacyWarnings: string[];
error: string | null;
prompt: string | null;
rawOutput: string | null;
normalizedOutput: Record<string, unknown> | null;
summary: Record<string, unknown>;
};
export type AdminAiWarning = FlyerReasonDescriptor & {
itemIndex?: number;
};
type FlyerTraceSupplement = {
prompt: string | null;
rawOutput: string | null;
retryCount: number | null;
chunkCount: number | null;
};
@Injectable()
export class AiTraceService {
constructor(private readonly prisma: PrismaService) {}
async listTraces(params: {
source: AiTraceSource;
limit: number;
cursor?: string;
period?: '24h' | '7d' | '30d';
onlyErrors?: boolean;
}): Promise<AiTraceListResponse> {
if (params.source === 'receipt') {
return this.listReceiptTraces(params);
}
const take = Math.max(1, Math.min(params.limit || 20, 100));
const cursorId = this.parseCursor(params.cursor);
const periodStart = this.periodStart(params.period);
const sessions = await this.prisma.flyerSession.findMany({
where: {
...(periodStart ? { createdAt: { gte: periodStart } } : {}),
...(cursorId ? { id: { lt: cursorId } } : {}),
...(params.onlyErrors ? { items: { none: {} } } : {}),
},
orderBy: { id: 'desc' },
take: take + 1,
select: {
id: true,
userId: true,
createdAt: true,
sourceFileName: true,
user: { select: { username: true, email: true } },
items: {
select: {
id: true,
parseReasons: true,
matchReasons: true,
},
},
},
});
const hasMore = sessions.length > take;
const page = hasMore ? sessions.slice(0, take) : sessions;
const items: AiTraceListItem[] = page.map((session) => {
const warningSet = this.collectWarnings(
session.items.map((item, itemIndex) => ({
parseReasons: item.parseReasons,
matchReasons: item.matchReasons,
itemIndex,
})),
);
const warningsCount = this.countActionableWarnings(warningSet.warnings);
const status = this.statusFromSession(session.items.length, warningsCount);
return {
id: this.flyerTraceId(session.id),
source: 'flyer',
status,
createdAt: session.createdAt.toISOString(),
userId: session.userId,
userLabel: this.userLabel(session.user?.username, session.user?.email, session.userId),
sessionId: session.id,
fileName: session.sourceFileName,
model: 'ministral-8b-2512',
durationMs: null,
warningsCount,
hasPrompt: false,
hasOutput: session.items.length > 0,
error: status === 'error' ? 'Inga produkter kunde extraheras från flyern.' : null,
};
});
const supplements = await this.getFlyerTraceSupplements(page.map((session) => session.id));
const withSupplements = items.map((item) => {
const sessionId = item.sessionId ?? 0;
const supplement = supplements.get(sessionId);
const hasPrompt = item.hasPrompt || !!supplement?.prompt;
const hasOutput = item.hasOutput || !!supplement?.rawOutput;
const error = item.status === 'warning' && item.warningsCount > 0
? `Det finns ${item.warningsCount} varningar i detaljvyn.`
: item.error;
return {
...item,
hasPrompt,
hasOutput,
error,
};
});
return {
items: withSupplements,
nextCursor: hasMore ? String(page[page.length - 1]?.id ?? '') : null,
};
}
async getTraceById(id: string): Promise<AiTraceDetail> {
const parsed = this.parseTraceId(id);
if (parsed.source === 'receipt') {
return this.getReceiptTraceById(parsed.numericId);
}
const session = await this.prisma.flyerSession.findUnique({
where: { id: parsed.numericId },
select: {
id: true,
userId: true,
createdAt: true,
sourceFileName: true,
sourceMimeType: true,
sourceFileSize: true,
user: { select: { username: true, email: true } },
items: {
orderBy: { id: 'asc' },
select: {
id: true,
rawName: true,
normalizedName: true,
brand: true,
categoryHint: true,
categoryId: true,
price: true,
priceUnit: true,
comparisonPrice: true,
comparisonUnit: true,
weight: true,
bundleWeight: true,
isBundle: true,
bundleItems: true,
offerText: true,
parseConfidence: true,
parseReasons: true,
matchedProductId: true,
matchedProductName: true,
matchedVia: true,
matchConfidence: true,
matchReasons: true,
},
},
},
});
if (!session) {
throw new NotFoundException('AI-trace hittades inte.');
}
const warningSet = this.collectWarnings(
session.items.map((item, itemIndex) => ({
parseReasons: item.parseReasons,
matchReasons: item.matchReasons,
itemIndex,
})),
);
const warnings = warningSet.warnings;
const status = this.statusFromSession(
session.items.length,
this.countActionableWarnings(warnings),
);
const supplement = await this.getFlyerTraceSupplementBySessionId(session.id);
const normalizedOutput = {
sessionId: session.id,
source: 'flyer',
sourceFileName: session.sourceFileName,
sourceMimeType: session.sourceMimeType,
sourceFileSize: session.sourceFileSize,
itemCount: session.items.length,
items: session.items.map((item) => ({
id: item.id,
rawName: item.rawName,
normalizedName: item.normalizedName,
brand: item.brand,
categoryHint: item.categoryHint,
categoryId: item.categoryId,
price: item.price != null ? Number(item.price) : null,
priceUnit: item.priceUnit,
comparisonPrice: item.comparisonPrice != null ? Number(item.comparisonPrice) : null,
comparisonUnit: item.comparisonUnit,
weight: item.weight,
bundleWeight: item.bundleWeight,
isBundle: item.isBundle,
bundleItems: Array.isArray(item.bundleItems) ? item.bundleItems : [],
offerText: item.offerText,
parseConfidence: item.parseConfidence,
parseReasons: Array.isArray(item.parseReasons) ? item.parseReasons : [],
matchedProductId: item.matchedProductId,
matchedProductName: item.matchedProductName,
matchedVia: item.matchedVia,
matchConfidence: item.matchConfidence,
matchReasons: Array.isArray(item.matchReasons) ? item.matchReasons : [],
})),
warnings,
legacyWarnings: warningSet.legacyWarnings,
} as Record<string, unknown>;
return {
id: this.flyerTraceId(session.id),
source: 'flyer',
status,
createdAt: session.createdAt.toISOString(),
userId: session.userId,
userLabel: this.userLabel(session.user?.username, session.user?.email, session.userId),
sessionId: session.id,
fileName: session.sourceFileName,
model: 'ministral-8b-2512',
durationMs: null,
retryCount: supplement.retryCount,
chunkCount: supplement.chunkCount,
warnings,
legacyWarnings: warningSet.legacyWarnings,
error: session.items.length === 0 ? 'Inga produkter kunde extraheras från flyern.' : null,
prompt: supplement.prompt,
rawOutput:
this.maskRawOutput(supplement.rawOutput) ?? JSON.stringify(this.maskSensitiveData(normalizedOutput)),
normalizedOutput: this.maskSensitiveData(normalizedOutput),
summary: {
source: 'flyer',
sessionId: session.id,
itemCount: session.items.length,
warningsCount: this.countActionableWarnings(warnings),
promptAvailable: !!supplement.prompt,
outputAvailable: true,
retentionHintDays: 30,
maskedFields: AI_TRACE_MASK_FIELDS,
},
};
}
private async getFlyerTraceSupplements(sessionIds: number[]): Promise<Map<number, FlyerTraceSupplement>> {
if (sessionIds.length === 0) return new Map<number, FlyerTraceSupplement>();
const rows = await this.prisma.aiTrace.findMany({
where: {
source: 'flyer',
sessionId: { in: sessionIds },
},
orderBy: [{ sessionId: 'desc' }, { createdAt: 'desc' }],
select: {
sessionId: true,
prompt: true,
rawOutput: true,
normalizedOutput: true,
},
});
const out = new Map<number, FlyerTraceSupplement>();
for (const row of rows) {
if (row.sessionId == null || out.has(row.sessionId)) continue;
out.set(row.sessionId, {
prompt: row.prompt ? this.maskSensitiveText(row.prompt) : null,
rawOutput: row.rawOutput,
retryCount: this.extractTraceNumber(row.normalizedOutput, 'retryCount'),
chunkCount: this.extractTraceNumber(row.normalizedOutput, 'chunkCount'),
});
}
return out;
}
private async getFlyerTraceSupplementBySessionId(sessionId: number): Promise<FlyerTraceSupplement> {
const rows = await this.getFlyerTraceSupplements([sessionId]);
return rows.get(sessionId) ?? {
prompt: null,
rawOutput: null,
retryCount: null,
chunkCount: null,
};
}
private extractTraceNumber(value: unknown, key: string): number | null {
if (!value || typeof value !== 'object') return null;
const entry = (value as Record<string, unknown>)[key];
if (typeof entry === 'number' && Number.isFinite(entry)) return entry;
if (typeof entry === 'string') {
const parsed = Number.parseInt(entry, 10);
return Number.isFinite(parsed) ? parsed : null;
}
return null;
}
private statusFromSession(itemCount: number, warningsCount: number): AiTraceStatus {
if (itemCount <= 0) return 'error';
if (warningsCount > 0) return 'warning';
return 'success';
}
private maskSensitiveData(data: Record<string, unknown>): Record<string, unknown> {
const clone = JSON.parse(JSON.stringify(data)) as Record<string, unknown>;
return this.maskDeep(clone) as Record<string, unknown>;
}
private maskDeep(value: unknown): unknown {
if (typeof value === 'string') {
return this.maskSensitiveText(value);
}
if (Array.isArray(value)) {
return value.map((entry) => this.maskDeep(entry));
}
if (value && typeof value === 'object') {
const out: Record<string, unknown> = {};
for (const [key, nested] of Object.entries(value as Record<string, unknown>)) {
const lowerKey = key.toLowerCase();
if (AI_TRACE_MASK_FIELDS.some((field) => lowerKey.includes(field))) {
out[key] = '[MASKED]';
continue;
}
out[key] = this.maskDeep(nested);
}
return out;
}
return value;
}
private maskSensitiveText(value: string): string {
return value
.replace(EMAIL_REGEX, '[MASKED]')
.replace(SWEDISH_PERSONAL_ID_REGEX, '[MASKED]')
.replace(PHONE_REGEX, '[MASKED]');
}
private maskRawOutput(rawOutput: string | null | undefined): string | null {
if (typeof rawOutput !== 'string' || rawOutput.trim().length === 0) {
return null;
}
try {
const parsed = JSON.parse(rawOutput);
if (parsed && typeof parsed === 'object') {
const masked = this.maskDeep(parsed);
return JSON.stringify(masked);
}
if (typeof parsed === 'string') {
return this.maskSensitiveText(parsed);
}
return this.maskSensitiveText(String(parsed));
} catch {
return this.maskSensitiveText(rawOutput);
}
}
private parseCursor(cursor?: string): number | null {
if (!cursor) return null;
const value = Number.parseInt(cursor, 10);
return Number.isFinite(value) && value > 0 ? value : null;
}
private periodStart(period?: '24h' | '7d' | '30d'): Date | null {
if (!period) return null;
const now = Date.now();
const map: Record<string, number> = {
'24h': 24 * 60 * 60 * 1000,
'7d': 7 * 24 * 60 * 60 * 1000,
'30d': 30 * 24 * 60 * 60 * 1000,
};
const duration = map[period];
if (!duration) return null;
return new Date(now - duration);
}
private parseTraceId(id: string): { source: AiTraceSource; numericId: number } {
const trimmed = id.trim();
if (trimmed.startsWith('flyer-')) {
const value = Number.parseInt(trimmed.replace('flyer-', ''), 10);
if (Number.isFinite(value) && value > 0) {
return { source: 'flyer', numericId: value };
}
}
if (trimmed.startsWith('receipt-')) {
const value = Number.parseInt(trimmed.replace('receipt-', ''), 10);
if (Number.isFinite(value) && value > 0) {
return { source: 'receipt', numericId: value };
}
}
throw new NotFoundException('AI-trace hittades inte.');
}
private flyerTraceId(sessionId: number): string {
return `flyer-${sessionId}`;
}
private userLabel(username: string | null | undefined, email: string | null | undefined, userId: number): string {
if (username && username.trim().length > 0) return username.trim();
if (email && email.trim().length > 0) return email.trim();
return `user:${userId}`;
}
private collectWarnings(items: Array<{ parseReasons: unknown; matchReasons: unknown; itemIndex?: number; rawName?: string }>): {
warnings: AdminAiWarning[];
legacyWarnings: string[];
} {
const warnings: AdminAiWarning[] = [];
const legacyWarnings = new Set<string>();
const dedupe = new Set<string>();
for (const item of items) {
const itemIndex = item.itemIndex != null ? item.itemIndex + 1 : undefined;
const productName = item.rawName?.trim() || 'okänt';
if (Array.isArray(item.parseReasons)) {
for (const reason of item.parseReasons) {
const text = String(reason ?? '').trim();
if (!text) continue;
const warning: AdminAiWarning = {
...describeParseReason(text),
itemIndex,
productName,
} as AdminAiWarning;
const key = `${warning.kind}:${text}:${warning.itemIndex ?? 0}`;
if (dedupe.has(key)) continue;
dedupe.add(key);
warnings.push(warning);
legacyWarnings.add(`parse:${text}`);
}
}
if (Array.isArray(item.matchReasons)) {
for (const reason of item.matchReasons) {
const text = String(reason ?? '').trim();
if (!text) continue;
const warning: AdminAiWarning = {
...describeMatchReason(text),
itemIndex,
productName,
} as AdminAiWarning;
const key = `${warning.kind}:${text}:${warning.itemIndex ?? 0}`;
if (dedupe.has(key)) continue;
dedupe.add(key);
warnings.push(warning);
legacyWarnings.add(`match:${text}`);
}
}
}
return {
warnings,
legacyWarnings: Array.from(legacyWarnings),
};
}
private countActionableWarnings(warnings: AdminAiWarning[]): number {
return warnings.filter((warning) => warning.severity !== 'info').length;
}
private async listReceiptTraces(params: {
source: AiTraceSource;
limit: number;
cursor?: string;
period?: '24h' | '7d' | '30d';
onlyErrors?: boolean;
}): Promise<AiTraceListResponse> {
const take = Math.max(1, Math.min(params.limit || 20, 100));
const cursorId = this.parseCursor(params.cursor);
const periodStart = this.periodStart(params.period);
const rows = await this.prisma.aiTrace.findMany({
where: {
source: 'receipt',
...(periodStart ? { createdAt: { gte: periodStart } } : {}),
...(cursorId ? { id: { lt: cursorId } } : {}),
...(params.onlyErrors ? { status: 'error' } : {}),
},
orderBy: { id: 'desc' },
take: take + 1,
select: {
id: true,
source: true,
status: true,
createdAt: true,
userId: true,
sessionId: true,
model: true,
durationMs: true,
error: true,
prompt: true,
rawOutput: true,
user: { select: { username: true, email: true } },
},
});
const hasMore = rows.length > take;
const page = hasMore ? rows.slice(0, take) : rows;
return {
items: page.map((row) => ({
id: `receipt-${row.id}`,
source: 'receipt',
status: row.status === 'error' ? 'error' : row.status === 'warning' ? 'warning' : 'success',
createdAt: row.createdAt.toISOString(),
userId: row.userId ?? 0,
userLabel: this.userLabel(row.user?.username, row.user?.email, row.userId ?? 0),
sessionId: row.sessionId,
fileName: null,
model: row.model,
durationMs: row.durationMs,
warningsCount: 0,
hasPrompt: !!row.prompt,
hasOutput: !!row.rawOutput,
error: row.error,
})),
nextCursor: hasMore ? String(page[page.length - 1]?.id ?? '') : null,
};
}
private async getReceiptTraceById(traceId: number): Promise<AiTraceDetail> {
const row = await this.prisma.aiTrace.findFirst({
where: { id: traceId, source: 'receipt' },
select: {
id: true,
source: true,
status: true,
createdAt: true,
userId: true,
sessionId: true,
model: true,
durationMs: true,
error: true,
prompt: true,
rawOutput: true,
normalizedOutput: true,
user: { select: { username: true, email: true } },
},
});
if (!row) {
throw new NotFoundException('AI-trace hittades inte.');
}
const normalizedOutput = row.normalizedOutput && typeof row.normalizedOutput === 'object'
? this.maskSensitiveData(row.normalizedOutput as Record<string, unknown>)
: null;
return {
id: `receipt-${row.id}`,
source: 'receipt',
status: row.status === 'error' ? 'error' : row.status === 'warning' ? 'warning' : 'success',
createdAt: row.createdAt.toISOString(),
userId: row.userId ?? 0,
userLabel: this.userLabel(row.user?.username, row.user?.email, row.userId ?? 0),
sessionId: row.sessionId,
fileName: null,
model: row.model,
durationMs: row.durationMs,
retryCount: null,
chunkCount: null,
warnings: [],
legacyWarnings: [],
error: row.error,
prompt: row.prompt ? this.maskSensitiveText(row.prompt) : null,
rawOutput: this.maskRawOutput(row.rawOutput),
normalizedOutput,
summary: {
source: 'receipt',
traceId: row.id,
promptAvailable: !!row.prompt,
outputAvailable: !!row.rawOutput || normalizedOutput != null,
retentionHintDays: 30,
maskedFields: AI_TRACE_MASK_FIELDS,
},
};
}
}
+41 -7
View File
@@ -1,6 +1,10 @@
import { Controller, Get } from '@nestjs/common';
import { Controller, Get, Param, ParseIntPipe, Query, Post } from '@nestjs/common';
import { Roles } from '../auth/decorators/roles.decorator';
import { Public } from '../auth/decorators/public.decorator';
import { AI_CATEGORIZATION_MODEL } from './ai.service';
import { AiTraceService } from './ai-trace.service';
import { AiTraceCleanupService } from './ai-trace-cleanup.service';
import { ListAiTracesQueryDto } from './dto/list-ai-traces.query.dto';
const RECEIPT_IMPORT_MODEL = 'mistral-small-2603';
@@ -16,9 +20,14 @@ export interface AiModelInfo {
@Controller('ai')
export class AiController {
@Get('models')
@Public()
getModels(): AiModelInfo[] {
constructor(
private readonly aiTraceService: AiTraceService,
private readonly aiTraceCleanupService: AiTraceCleanupService,
) {}
@Get('models')
@Public()
getModels(): AiModelInfo[] {
return [
{
id: 'receipt-pdf',
@@ -64,7 +73,32 @@ export class AiController {
path: '/admin/products',
trigger: 'Manuell — knappen "✨ AI-kategorisera okategoriserade"',
access: 'Admin',
},
];
},
];
}
@Roles('admin')
@Get('traces')
listTraces(@Query() query: ListAiTracesQueryDto) {
return this.aiTraceService.listTraces({
source: query.source ?? 'flyer',
limit: query.limit ?? 20,
cursor: query.cursor,
period: query.period,
onlyErrors: query.onlyErrors ?? false,
});
}
@Roles('admin')
@Get('traces/:id')
getTraceById(@Param('id') id: string) {
return this.aiTraceService.getTraceById(id);
}
@Post('traces/cleanup')
@Roles('admin')
async manualCleanup() {
await this.aiTraceCleanupService.cleanupOldTraces();
return { success: true, message: 'Manual cleanup completed.' };
}
}
}
+14 -10
View File
@@ -1,10 +1,14 @@
import { Module } from '@nestjs/common';
import { AiService } from './ai.service';
import { AiController } from './ai.controller';
@Module({
controllers: [AiController],
providers: [AiService],
exports: [AiService],
})
export class AiModule {}
import { Module } from '@nestjs/common';
import { AiService } from './ai.service';
import { AiController } from './ai.controller';
import { AiTraceService } from './ai-trace.service';
import { AiTraceCleanupService } from './ai-trace-cleanup.service';
import { PrismaModule } from '../prisma/prisma.module';
@Module({
imports: [PrismaModule],
controllers: [AiController],
providers: [AiService, AiTraceService, AiTraceCleanupService],
exports: [AiService],
})
export class AiModule {}
+1 -1
View File
@@ -2,7 +2,7 @@ import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common'
import { FlatCategory } from '../categories/categories.service';
const MISTRAL_API_URL = 'https://api.mistral.ai/v1/chat/completions';
export const AI_CATEGORIZATION_MODEL = 'mistral-tiny';
export const AI_CATEGORIZATION_MODEL = 'ministral-8b-2512';
const MODEL = AI_CATEGORIZATION_MODEL;
export type CategorySuggestion = {
@@ -0,0 +1,6 @@
import { FlyerReasonDescriptor } from '../../flyer-import/services/reason-codes';
export type AdminAiWarning = FlyerReasonDescriptor & {
itemIndex?: number;
productName?: string;
};
@@ -0,0 +1,37 @@
import { Transform } from 'class-transformer';
import { IsBoolean, IsIn, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
export class ListAiTracesQueryDto {
@IsOptional()
@IsIn(['receipt', 'flyer'])
source?: 'receipt' | 'flyer';
@IsOptional()
@Transform(({ value }) => {
if (value === undefined || value === null || value === '') return undefined;
const parsed = Number.parseInt(String(value), 10);
return Number.isFinite(parsed) ? parsed : value;
})
@IsInt()
@Min(1)
@Max(100)
limit?: number;
@IsOptional()
@IsString()
cursor?: string;
@IsOptional()
@IsIn(['24h', '7d', '30d'])
period?: '24h' | '7d' | '30d';
@IsOptional()
@Transform(({ value }) => {
if (typeof value === 'boolean') return value;
const normalized = String(value ?? '').trim().toLowerCase();
if (!normalized) return undefined;
return ['1', 'true', 'yes', 'on'].includes(normalized);
})
@IsBoolean()
onlyErrors?: boolean;
}
+11 -1
View File
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
import { ScheduleModule } from '@nestjs/schedule';
import { HealthModule } from './health/health.module';
import { PrismaModule } from './prisma/prisma.module';
import { ProductsModule } from './products/products.module';
@@ -17,6 +18,10 @@ 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 { FlyerImportModule } from './flyer-import/flyer-import.module';
import { FlyerSelectionModule } from './flyer-selection/flyer-selection.module';
import { ShoppingListModule } from './shopping-list/shopping-list.module';
import { JwtAuthGuard } from './auth/jwt-auth.guard';
import { RolesGuard } from './auth/roles.guard';
@@ -30,6 +35,7 @@ import { RolesGuard } from './auth/roles.guard';
limit: 120, // 120 anrop per minut (generellt)
},
]),
ScheduleModule.forRoot(),
HealthModule,
PrismaModule,
ProductsModule,
@@ -46,6 +52,10 @@ import { RolesGuard } from './auth/roles.guard';
CategoriesModule,
AiModule,
RealtimeModule,
HelpTextsModule,
FlyerImportModule,
FlyerSelectionModule,
ShoppingListModule,
],
providers: [
{
@@ -62,4 +72,4 @@ import { RolesGuard } from './auth/roles.guard';
},
],
})
export class AppModule {}
export class AppModule {}
+2 -2
View File
@@ -7,9 +7,9 @@ import { RolesGuard } from './auth/roles.guard';
describe('App security configuration', () => {
function getAppModuleClass() {
process.env.JWT_SECRET = process.env.JWT_SECRET ?? 'test-secret';
// eslint-disable-next-line @typescript-eslint/no-var-requires
// eslint-disable-next-line global-require
return require('./app.module').AppModule as any;
}
}
it('har globala guards i förväntad ordning: Throttler -> Jwt -> Roles', () => {
const AppModule = getAppModuleClass();
+6 -1
View File
@@ -7,10 +7,15 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
private readonly logger = new Logger(JwtStrategy.name);
constructor() {
const jwtSecret = process.env.JWT_SECRET;
if (!jwtSecret) {
throw new Error('JWT_SECRET is not set');
}
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: process.env.JWT_SECRET,
secretOrKey: jwtSecret,
});
}
@@ -0,0 +1,62 @@
export type FlyerImportMatchVia = 'alias' | 'exact' | 'token' | 'none';
export type FlyerImportSignals = {
originCountries: string[];
labels: string[];
qualityFlags: string[];
variant: string | null;
packaging: string | null;
};
export type FlyerReasonDescriptor = {
code: string;
kind: 'parse' | 'match';
title: string;
message: string;
severity: 'info' | 'warning' | 'error';
location: string | null;
};
export type FlyerImportItem = {
flyerItemId: number | null;
rawName: string;
normalizedName: string;
brand: string | null;
category: string | null;
categoryId: number | null;
price: number | null;
priceUnit: string | null;
comparisonPrice: number | null;
comparisonUnit: string | null;
weight: string | null;
bundleWeight: string | null;
isBundle: boolean;
bundleItems: string[];
displayNameDetailed: string | null;
signals: FlyerImportSignals | null;
offerText: string | null;
isOffer: boolean;
offerLimitText: string | null;
parseConfidence: number;
parseReasons: string[];
parseReasonsDetailed: FlyerReasonDescriptor[];
matchedProductId: number | null;
matchedProductName: string | null;
matchedVia: FlyerImportMatchVia;
matchConfidence: number;
matchReasons: string[];
matchReasonsDetailed: FlyerReasonDescriptor[];
origin?: string | null;
};
export type FlyerImportResponse = {
sessionId: number | null;
retailer: 'willys';
parserVersion: 'v1';
sourceAvailable: boolean;
sourceFileName: string | null;
sourceMimeType: string | null;
sourceFileSize: number | null;
items: FlyerImportItem[];
warnings: string[];
};
@@ -0,0 +1,20 @@
import { Transform } from 'class-transformer';
import { IsInt, IsOptional, IsString, MaxLength, Min } from 'class-validator';
export class UpdateFlyerItemDto {
@IsOptional()
@IsString()
@MaxLength(191)
rawName?: string;
@IsOptional()
@Transform(({ value }) => {
if (value === null || value === undefined || value === '') return null;
if (typeof value === 'number') return value;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : value;
})
@IsInt()
@Min(1)
categoryId?: number | null;
}
@@ -0,0 +1,123 @@
import {
Body,
BadRequestException,
Controller,
Get,
Header,
HttpCode,
Patch,
Param,
ParseIntPipe,
Post,
Request,
StreamableFile,
UnauthorizedException,
UploadedFile,
UseInterceptors,
} from '@nestjs/common';
import { Throttle } from '@nestjs/throttler';
import { FileInterceptor } from '@nestjs/platform-express';
import { memoryStorage } from 'multer';
import { FlyerImportResponse } from './dto/flyer-import.response';
import { UpdateFlyerItemDto } from './dto/update-flyer-item.dto';
import { FlyerImportService } from './flyer-import.service';
const ALLOWED_MIMES = [
'application/pdf',
'application/octet-stream',
'text/plain',
'image/png',
'image/jpeg',
'image/webp',
];
@Controller('flyer-import')
export class FlyerImportController {
constructor(private readonly flyerImportService: FlyerImportService) {}
@Post('parse')
@HttpCode(200)
@Throttle({ default: { ttl: 60_000, limit: 10 } })
@UseInterceptors(
FileInterceptor('file', {
storage: memoryStorage(),
limits: { fileSize: 15 * 1024 * 1024 },
}),
)
async parseFlyer(
@UploadedFile() file?: Express.Multer.File,
@Request() req?: any,
): Promise<FlyerImportResponse> {
if (!file?.buffer) {
throw new BadRequestException('Ingen fil skickades med.');
}
if (!ALLOWED_MIMES.includes(file.mimetype)) {
throw new BadRequestException('Otillåten filtyp. Använd PDF, textfil eller bild (PNG, JPEG, WebP).');
}
const userId = this.getUserId(req);
return this.flyerImportService.parseAndMatch(file, userId);
}
@Get('sessions/latest')
@Throttle({ default: { ttl: 60_000, limit: 30 } })
async getLatestSession(@Request() req?: any): Promise<FlyerImportResponse> {
const userId = this.getUserId(req);
return this.flyerImportService.getLatestSession(userId);
}
@Get('sessions/:sessionId')
@Throttle({ default: { ttl: 60_000, limit: 30 } })
async getSession(
@Param('sessionId', ParseIntPipe) sessionId: number,
@Request() req?: any,
): Promise<FlyerImportResponse> {
const userId = this.getUserId(req);
return this.flyerImportService.getSession(sessionId, userId);
}
@Get('sessions/:sessionId/source')
@Throttle({ default: { ttl: 60_000, limit: 30 } })
@Header('Cache-Control', 'private, max-age=300')
async getSessionSource(
@Param('sessionId', ParseIntPipe) sessionId: number,
@Request() req?: any,
): Promise<StreamableFile> {
const userId = this.getUserId(req);
const source = await this.flyerImportService.getSessionSource(sessionId, userId);
return new StreamableFile(source.data, {
disposition: `inline; filename="${source.fileName.replace(/"/g, '')}"`,
type: source.mimeType,
length: source.contentLength,
});
}
@Patch('sessions/:sessionId/items/:itemId')
@HttpCode(200)
@Throttle({ default: { ttl: 60_000, limit: 60 } })
async updateSessionItem(
@Param('sessionId', ParseIntPipe) sessionId: number,
@Param('itemId', ParseIntPipe) itemId: number,
@Request() req: any,
@Body() dto: UpdateFlyerItemDto,
) {
const userId = this.getUserId(req);
return this.flyerImportService.updateSessionItem(sessionId, itemId, userId, dto);
}
private getUserId(req?: any): number {
const userId =
typeof req?.user?.id === 'number'
? req.user.id
: typeof req?.user?.userId === 'number'
? req.user.userId
: undefined;
if (!userId) {
throw new UnauthorizedException('Kunde inte identifiera användaren.');
}
return userId;
}
}
@@ -0,0 +1,22 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../prisma/prisma.module';
import { FlyerImportController } from './flyer-import.controller';
import { FlyerImportService } from './flyer-import.service';
import { TextExtractorService } from './services/text-extractor.service';
import { AiFlyerParserService } from './services/ai-flyer-parser.service';
import { FlyerNormalizerService } from './services/flyer-normalizer.service';
import { CategoriesModule } from '../categories/categories.module';
import { CategoryResolverService } from '../import-common/category-resolver.service';
@Module({
imports: [PrismaModule, CategoriesModule],
controllers: [FlyerImportController],
providers: [
FlyerImportService,
TextExtractorService,
AiFlyerParserService,
FlyerNormalizerService,
CategoryResolverService,
],
})
export class FlyerImportModule {}
@@ -0,0 +1,424 @@
import { ForbiddenException, NotFoundException } from '@nestjs/common';
import { FlyerImportService } from './flyer-import.service';
describe('FlyerImportService', () => {
const prismaMock = {
product: {
findMany: jest.fn(),
},
receiptAlias: {
findMany: jest.fn(),
},
flyerSession: {
findFirst: jest.fn(),
findUnique: jest.fn(),
create: jest.fn(),
},
flyerItem: {
findUnique: jest.fn(),
update: jest.fn(),
create: jest.fn(),
},
aiTrace: {
create: jest.fn(),
},
category: {
findUnique: jest.fn(),
},
};
const createService = (overrides?: {
categoriesService?: any;
categoryResolver?: any;
textExtractor?: any;
aiParser?: any;
normalizer?: any;
}) =>
new FlyerImportService(
prismaMock as any,
overrides?.categoriesService ?? { findFlattened: jest.fn().mockResolvedValue([]) },
overrides?.categoryResolver ?? { resolveForFlyer: jest.fn().mockReturnValue(null) },
overrides?.textExtractor ?? {},
overrides?.aiParser ?? {},
overrides?.normalizer ?? {},
);
beforeEach(() => {
jest.clearAllMocks();
});
describe('getSession', () => {
it('throws NotFoundException when session is missing', async () => {
prismaMock.flyerSession.findFirst.mockResolvedValue(null);
const service = createService();
await expect(service.getSession(123, 1)).rejects.toBeInstanceOf(NotFoundException);
expect(prismaMock.flyerSession.findFirst).toHaveBeenCalledWith({
where: { id: 123, userId: 1 },
select: {
id: true,
sourceFileName: true,
sourceMimeType: true,
sourceFileSize: true,
sourceStorageKey: true,
items: {
include: {
categoryRef: {
include: {
parent: {
include: {
parent: true,
},
},
},
},
},
orderBy: { id: 'asc' },
},
},
});
});
it('returns mapped response for owned session', async () => {
prismaMock.flyerSession.findFirst.mockResolvedValue({
id: 42,
items: [
{
id: 99,
rawName: 'Tomat',
normalizedName: 'tomat',
brand: null,
categoryHint: 'Gronsaker',
price: { toNumber: () => 19.9 },
priceUnit: 'kg',
comparisonPrice: null,
comparisonUnit: null,
weight: null,
bundleWeight: null,
isBundle: false,
bundleItems: [],
displayNameDetailed: 'Tomat',
signals: { originCountries: ['Sverige'], labels: [], qualityFlags: [], variant: null, packaging: null },
offerText: 'Max 2 kop/hushall',
parseConfidence: 0.9,
parseReasons: ['ai_parsed'],
matchedProductId: 5,
matchedProductName: 'Tomat',
matchedVia: 'exact',
matchConfidence: 0.95,
matchReasons: ['normalized_exact'],
},
],
});
const service = createService();
const result = await service.getSession(42, 1);
expect(result.sessionId).toBe(42);
expect(result.items).toHaveLength(1);
expect(result.items[0].flyerItemId).toBe(99);
expect(result.items[0].matchedVia).toBe('exact');
expect(result.items[0].displayNameDetailed).toBe('Tomat');
expect(result.items[0].signals?.originCountries).toEqual(['Sverige']);
expect(result.items[0].parseReasonsDetailed[0].title).toBe('AI-tolkad rad');
expect(result.items[0].matchReasonsDetailed[0].title).toBe('Exakt normaliserad matchning');
expect(result.sourceAvailable).toBe(false);
});
it('sanitizes bundleItems without breaking response mapping', async () => {
prismaMock.flyerSession.findFirst.mockResolvedValue({
id: 51,
items: [
{
id: 100,
rawName: 'Kaptenens Favoriter',
normalizedName: 'kaptenens favoriter',
brand: 'Kapten Royal',
categoryHint: 'Fisk',
price: { toNumber: () => 49.9 },
priceUnit: 'pkt',
comparisonPrice: { toNumber: () => 83.17 },
comparisonUnit: 'kg',
weight: null,
bundleWeight: '600g',
isBundle: true,
bundleItems: [' Chumlax 3x100g ', '', 'Alaska pollock 3x100g'],
offerText: 'Max 10 kop/hushall',
parseConfidence: 0.9,
parseReasons: ['ai_parsed'],
matchedProductId: null,
matchedProductName: null,
matchedVia: 'none',
matchConfidence: null,
matchReasons: [],
},
],
});
const service = createService();
const result = await service.getSession(51, 1);
expect(result.items[0].isBundle).toBe(true);
expect(result.items[0].bundleItems).toEqual(['Chumlax 3x100g', 'Alaska pollock 3x100g']);
});
});
describe('parseAndMatch', () => {
it('persists and returns signals/displayNameDetailed/categoryId in parse pipeline', async () => {
prismaMock.product.findMany.mockResolvedValue([
{ id: 11, name: 'Fläskytterfilé', canonicalName: 'Fläskytterfilé', categoryId: 7 },
]);
prismaMock.receiptAlias.findMany.mockResolvedValue([]);
prismaMock.flyerSession.create.mockResolvedValue({ id: 200 });
prismaMock.flyerItem.create
.mockResolvedValueOnce({ id: 1001 });
prismaMock.aiTrace.create.mockResolvedValue({ id: 1 });
const categoriesService = { findFlattened: jest.fn().mockResolvedValue([]) };
const categoryResolver = { resolveForFlyer: jest.fn().mockReturnValue(7) };
const textExtractor = { extractText: jest.fn().mockResolvedValue('raw flyer text') };
const aiParser = {
parseWithAI: jest.fn().mockResolvedValue({
items: [
{
rawName: 'Fläskytterfilé (Sverige) EKO',
normalizedName: 'flaskytterfile sverige eko',
brand: 'Garant',
category: 'Kött',
price: 99.9,
unit: 'kg',
comparisonPrice: null,
comparisonUnit: null,
weight: '900g',
bundleWeight: null,
isBundle: true,
bundleItems: ['Del 1', 'Del 2'],
offer: 'ekologiskt från Sverige',
confidence: 0.93,
reasonCodes: ['ai_parsed'],
},
],
trace: { prompt: null, rawOutput: null, chunkCount: 1, retryCount: 0 },
}),
};
const normalizer = {
normalize: jest.fn().mockReturnValue([
{
rawName: 'Fläskytterfilé (Sverige) EKO',
normalizedName: 'flaskytterfile sverige eko',
brand: 'Garant',
categoryHint: 'Kött',
price: 99.9,
priceUnit: 'kg',
comparisonPrice: null,
comparisonUnit: null,
weight: '900g',
bundleWeight: null,
isBundle: true,
bundleItems: ['Del 1', 'Del 2'],
offerText: 'ekologiskt från Sverige',
parseConfidence: 0.93,
parseReasons: ['ai_parsed'],
},
]),
};
const service = createService({
categoriesService,
categoryResolver,
textExtractor,
aiParser,
normalizer,
});
const result = await service.parseAndMatch(
{
originalname: 'flyer.pdf',
mimetype: 'application/pdf',
size: 10,
buffer: Buffer.from('pdf'),
} as any,
1,
);
expect(result.items).toHaveLength(1);
expect(result.items[0].displayNameDetailed).toBe('Fläskytterfilé (Sverige) EKO (Del 1 + Del 2)');
expect(result.items[0].signals?.originCountries).toEqual(['Sverige']);
expect(result.items[0].signals?.qualityFlags).toContain('eco');
expect(result.items[0].categoryId).toBe(7);
expect(result.items[0].normalizedName).toBe('flaskytterfile');
expect(prismaMock.flyerItem.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
displayNameDetailed: 'Fläskytterfilé (Sverige) EKO (Del 1 + Del 2)',
categoryId: 7,
signals: expect.objectContaining({ originCountries: ['Sverige'] }),
}),
}),
);
});
it('logs warning when categories fallback is used', async () => {
prismaMock.product.findMany.mockResolvedValue([]);
prismaMock.receiptAlias.findMany.mockResolvedValue([]);
prismaMock.flyerSession.create.mockResolvedValue({ id: 201 });
prismaMock.flyerItem.create.mockResolvedValue({ id: 1002 });
prismaMock.aiTrace.create.mockResolvedValue({ id: 2 });
const categoriesService = { findFlattened: jest.fn().mockRejectedValue(new Error('db down')) };
const textExtractor = { extractText: jest.fn().mockResolvedValue('raw') };
const aiParser = {
parseWithAI: jest.fn().mockResolvedValue({
items: [{ rawName: 'Tomat' }],
trace: { prompt: null, rawOutput: null, chunkCount: 1, retryCount: 0 },
}),
};
const normalizer = {
normalize: jest.fn().mockReturnValue([
{
rawName: 'Tomat',
normalizedName: 'tomat',
brand: null,
categoryHint: null,
price: null,
priceUnit: null,
comparisonPrice: null,
comparisonUnit: null,
weight: null,
bundleWeight: null,
isBundle: false,
bundleItems: [],
offerText: null,
parseConfidence: 0.9,
parseReasons: ['ai_parsed'],
},
]),
};
const service = createService({
categoriesService,
textExtractor,
aiParser,
normalizer,
});
const warnSpy = jest.spyOn((service as any).logger, 'warn');
await service.parseAndMatch(
{
originalname: 'flyer.pdf',
mimetype: 'application/pdf',
size: 10,
buffer: Buffer.from('pdf'),
} as any,
1,
);
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('Could not load categories for flyer import'),
);
});
});
describe('getLatestSession', () => {
it('returns empty response when no sessions exist', async () => {
prismaMock.flyerSession.findFirst.mockResolvedValue(null);
const service = createService();
const result = await service.getLatestSession(1);
expect(result.sessionId).toBeNull();
expect(result.items).toEqual([]);
expect(prismaMock.flyerSession.findFirst).toHaveBeenCalledWith({
where: { userId: 1 },
orderBy: { createdAt: 'desc' },
select: {
id: true,
sourceFileName: true,
sourceMimeType: true,
sourceFileSize: true,
sourceStorageKey: true,
items: {
include: {
categoryRef: {
include: {
parent: {
include: {
parent: true,
},
},
},
},
},
orderBy: { id: 'asc' },
},
},
});
});
});
describe('updateSessionItem', () => {
it('updates rawName and category path', async () => {
prismaMock.flyerSession.findUnique.mockResolvedValue({ id: 7, userId: 1 });
prismaMock.flyerItem.findUnique.mockResolvedValue({
id: 12,
sessionId: 7,
rawName: 'Tomat',
});
prismaMock.category.findUnique.mockResolvedValue({
id: 3,
name: 'Tomater',
parent: { name: 'Grönsaker', parent: { name: 'Mat', parent: null } },
});
prismaMock.flyerItem.update.mockResolvedValue({
id: 12,
rawName: 'Cocktailtomater',
normalizedName: 'cocktailtomater',
brand: null,
categoryHint: 'Mat > Grönsaker > Tomater',
categoryId: 3,
price: null,
priceUnit: null,
comparisonPrice: null,
comparisonUnit: null,
weight: null,
bundleWeight: null,
isBundle: false,
bundleItems: [],
offerText: null,
parseConfidence: 1,
parseReasons: [],
matchedProductId: null,
matchedProductName: null,
matchedVia: 'none',
matchConfidence: null,
matchReasons: [],
categoryRef: { name: 'Tomater', parent: { name: 'Grönsaker', parent: { name: 'Mat' } } },
});
const service = createService();
const result = await service.updateSessionItem(7, 12, 1, {
rawName: 'Cocktailtomater',
categoryId: 3,
});
expect(result.rawName).toBe('Cocktailtomater');
expect(result.categoryId).toBe(3);
expect(result.category).toBe('Mat > Grönsaker > Tomater');
});
});
describe('getSessionSource', () => {
it('throws when session belongs to another user', async () => {
prismaMock.flyerSession.findUnique.mockResolvedValue({
userId: 99,
sourceFileName: 'flyer.pdf',
sourceMimeType: 'application/pdf',
sourceFileSize: 10,
sourceData: Buffer.from('abc'),
});
const service = createService();
await expect(service.getSessionSource(1, 1)).rejects.toBeInstanceOf(ForbiddenException);
});
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,193 @@
import { BadRequestException } from '@nestjs/common';
import { AiFlyerParserService } from './ai-flyer-parser.service';
describe('AiFlyerParserService dedupe', () => {
const service = Object.create(AiFlyerParserService.prototype) as AiFlyerParserService;
it('buildPrompt enforces Swedish diacritics for cheese variants', () => {
const prompt = (service as any).buildPrompt('PRAST, HERRGARD, GREVE', 3000) as string;
expect(prompt).toContain('Behåll svenska diakritiska tecken (ä, å, ö, é)');
expect(prompt).toContain('Prästost');
expect(prompt).toContain('Herrgårdsost');
expect(prompt).toContain('Grevéost');
});
it('dedupes same product with minor offer text differences', () => {
const items = [
{
rawName: 'Kvisttomater',
normalizedName: 'kvisttomater',
brand: null,
category: 'Grönsaker',
price: 19.9,
priceUnit: 'kg',
comparisonPrice: null,
comparisonUnit: null,
weight: null,
bundleWeight: null,
isBundle: false,
bundleItems: [],
offerText: 'Max 2 köp/hushåll',
confidence: 0.9,
reasonCodes: ['ai_parsed'],
},
{
rawName: 'KVISTTOMATER',
normalizedName: 'kvisttomater',
brand: null,
category: 'Grönsaker',
price: 19.9,
priceUnit: 'kg',
comparisonPrice: null,
comparisonUnit: null,
weight: null,
bundleWeight: null,
isBundle: false,
bundleItems: [],
offerText: 'Max 2 kop/hushall',
confidence: 0.89,
reasonCodes: ['ai_parsed'],
},
];
const result = (service as any).dedupeItems(items);
expect(result).toHaveLength(1);
expect(result[0].normalizedName).toBe('kvisttomater');
});
it('keeps products with same name but different prices', () => {
const items = [
{
rawName: 'Kvisttomater',
normalizedName: 'kvisttomater',
brand: null,
category: 'Grönsaker',
price: 19.9,
priceUnit: 'kg',
comparisonPrice: null,
comparisonUnit: null,
weight: null,
bundleWeight: null,
isBundle: false,
bundleItems: [],
offerText: null,
confidence: 0.9,
reasonCodes: ['ai_parsed'],
},
{
rawName: 'Kvisttomater',
normalizedName: 'kvisttomater',
brand: null,
category: 'Grönsaker',
price: 24.9,
priceUnit: 'kg',
comparisonPrice: null,
comparisonUnit: null,
weight: null,
bundleWeight: null,
isBundle: false,
bundleItems: [],
offerText: null,
confidence: 0.9,
reasonCodes: ['ai_parsed'],
},
];
const result = (service as any).dedupeItems(items);
expect(result).toHaveLength(2);
});
it('keeps products with same name/price but materially different campaigns', () => {
const items = [
{
rawName: 'Kvisttomater',
normalizedName: 'kvisttomater',
brand: null,
category: 'Grönsaker',
price: 19.9,
priceUnit: 'kg',
comparisonPrice: null,
comparisonUnit: null,
weight: null,
bundleWeight: null,
isBundle: false,
bundleItems: [],
offerText: 'Max 2 köp/hushåll',
confidence: 0.9,
reasonCodes: ['ai_parsed'],
},
{
rawName: 'Kvisttomater',
normalizedName: 'kvisttomater',
brand: null,
category: 'Grönsaker',
price: 19.9,
priceUnit: 'kg',
comparisonPrice: null,
comparisonUnit: null,
weight: null,
bundleWeight: null,
isBundle: false,
bundleItems: [],
offerText: 'Ta 3 betala för 2',
confidence: 0.9,
reasonCodes: ['ai_parsed'],
},
];
const result = (service as any).dedupeItems(items);
expect(result).toHaveLength(2);
});
it('keeps bundle and non-bundle as separate entries', () => {
const items = [
{
rawName: 'Fiskpaket',
normalizedName: 'fiskpaket',
brand: 'Kapten',
category: 'Fisk',
price: 49.9,
priceUnit: 'pkt',
comparisonPrice: 83.17,
comparisonUnit: 'kg',
weight: null,
bundleWeight: '600g',
isBundle: true,
bundleItems: ['A', 'B'],
offerText: null,
confidence: 0.9,
reasonCodes: ['ai_parsed'],
},
{
rawName: 'Fiskpaket',
normalizedName: 'fiskpaket',
brand: 'Kapten',
category: 'Fisk',
price: 49.9,
priceUnit: 'pkt',
comparisonPrice: 83.17,
comparisonUnit: 'kg',
weight: null,
bundleWeight: null,
isBundle: false,
bundleItems: [],
offerText: null,
confidence: 0.9,
reasonCodes: ['ai_parsed'],
},
];
const result = (service as any).dedupeItems(items);
expect(result).toHaveLength(2);
});
it('throws for empty input in parseWithAI', async () => {
await expect((service as any).parseWithAI('')).rejects.toBeInstanceOf(
BadRequestException,
);
});
});
@@ -0,0 +1,634 @@
import {
BadRequestException,
Injectable,
Logger,
ServiceUnavailableException,
} from '@nestjs/common';
import * as fs from 'fs';
import * as path from 'path';
export interface AiFlyerParseResult {
rawName: string;
normalizedName: string;
brand: string | null;
category: string | null;
price: number | null;
priceUnit: string | null;
comparisonPrice: number | null;
comparisonUnit: string | null;
weight: string | null;
bundleWeight: string | null;
isBundle: boolean;
bundleItems: string[];
offerText: string | null;
confidence: number;
reasonCodes: string[];
}
export interface AiFlyerParseTrace {
prompt: string | null;
rawOutput: string | null;
chunkCount: number;
retryCount: number;
}
@Injectable()
export class AiFlyerParserService {
private readonly logger = new Logger(AiFlyerParserService.name);
private readonly timeoutMs: number;
private readonly maxRetries: number;
private readonly chunkSizeChars: number;
private readonly chunkOverlapChars: number;
private readonly maxChunks: number;
private readonly debugEnabled: boolean;
private readonly debugDirectory: string;
private mistral: any;
private apiKey: string;
constructor() {
this.apiKey = process.env.MISTRAL_API_KEY ?? '';
if (!this.apiKey) {
throw new Error('MISTRAL_API_KEY environment variable not set');
}
this.timeoutMs = this.readPositiveIntEnv('FLYER_AI_TIMEOUT_MS', 30_000);
this.maxRetries = this.readPositiveIntEnv('FLYER_AI_RETRIES', 2);
this.chunkSizeChars = this.readPositiveIntEnv('FLYER_AI_CHUNK_SIZE_CHARS', 3_000);
this.chunkOverlapChars = this.readPositiveIntEnv('FLYER_AI_CHUNK_OVERLAP_CHARS', 300);
this.maxChunks = this.readPositiveIntEnv('FLYER_AI_MAX_CHUNKS', 8);
this.debugEnabled = this.readBooleanEnv('FLYER_AI_DEBUG', false);
this.debugDirectory = process.env.FLYER_AI_DEBUG_DIR?.trim() || path.join(process.cwd(), 'debug');
}
private async getClient(): Promise<any> {
if (this.mistral) return this.mistral;
const mistralModule = await import('@mistralai/mistralai');
this.mistral = new mistralModule.default(this.apiKey);
return this.mistral;
}
/**
* Skickar flyer-text till mistral-8b-2512 för strukturerad extraktion.
*
* @param text Text från flyern (från pdf-parse eller OCR)
* @returns Array av parsade produkter
*/
async parseWithAI(text: string): Promise<{ items: AiFlyerParseResult[]; trace: AiFlyerParseTrace }> {
if (!text || text.trim().length === 0) {
throw new BadRequestException('Flyer-texten är tom. Kan inte fortsätta.');
}
const debugSession = this.createDebugSession('AI-flyerimporter');
try {
if (debugSession) {
await this.writeDebugFile(
debugSession,
`${debugSession.baseName}-input.txt`,
text,
);
}
const client = await this.getClient();
const chunks = this.splitIntoChunks(text);
this.logger.debug(`Parsing flyer text in ${chunks.length} chunk(s)`);
if (debugSession) {
await this.writeDebugFile(
debugSession,
`${debugSession.baseName}-chunks.json`,
JSON.stringify(chunks, null, 2),
);
}
const allItems: AiFlyerParseResult[] = [];
const prompts: string[] = [];
const rawResponses: string[] = [];
let retryCount = 0;
for (let i = 0; i < chunks.length; i++) {
const chunkResult = await this.parseChunkWithRetry(
client,
chunks[i],
i + 1,
chunks.length,
debugSession,
);
allItems.push(...chunkResult.items);
prompts.push(chunkResult.prompt);
rawResponses.push(chunkResult.rawOutput);
retryCount += Math.max(0, chunkResult.attemptsUsed - 1);
}
const deduped = this.dedupeItems(allItems);
const trace: AiFlyerParseTrace = {
prompt: prompts.length > 0 ? prompts.join('\n\n-----\n\n') : null,
rawOutput: rawResponses.length > 0 ? rawResponses.join('\n\n-----\n\n') : null,
chunkCount: chunks.length,
retryCount,
};
if (debugSession) {
await this.writeDebugFile(
debugSession,
`${debugSession.baseName}-result.json`,
JSON.stringify(deduped, null, 2),
);
}
return { items: deduped, trace };
} catch (err) {
if (debugSession) {
await this.writeDebugFile(
debugSession,
`${debugSession.baseName}-error.txt`,
this.toErrorMessage(err),
);
}
if (err instanceof SyntaxError) {
this.logger.error(`JSON parse error: ${String(err)}`);
throw new BadRequestException('AI returnerade ogiltigt JSON. Försök igen.');
}
if (err instanceof BadRequestException) {
throw err;
}
if (err instanceof ServiceUnavailableException) {
throw err;
}
this.logger.error(`AI parsing failed: ${String(err)}`);
throw new ServiceUnavailableException('AI-tjänsten är inte tillgänglig just nu.');
}
}
private async withTimeout<T>(
promise: Promise<T>,
timeoutMs: number,
timeoutMessage: string,
): Promise<T> {
let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
const timeoutPromise = new Promise<never>((_, reject) => {
timeoutHandle = setTimeout(() => {
reject(new ServiceUnavailableException(timeoutMessage));
}, timeoutMs);
});
try {
return await Promise.race([promise, timeoutPromise]);
} finally {
if (timeoutHandle) clearTimeout(timeoutHandle);
}
}
/**
* Bygger systemprompten för Mistral.
*/
private buildPrompt(text: string, maxTextLength: number): string {
const truncatedText = text.length > maxTextLength ? text.substring(0, maxTextLength) : text;
return `Du tolkar svenska matvaruflyers och ska returnera ENDAST en JSON-array.
Returnera objekt med exakt dessa fält:
- name: string (produkttitel)
- brand: string | null
- category: string | null
- isBundle: boolean
- weight: string | null (vikt/storlek for en enskild produkt)
- bundleWeight: string | null (totalvikt for hela kombipaketet)
- bundleItems: string[] (ingående produkter i paketet, tom array om ej bundle)
- price: number | null
- comparisonPrice: number | null
- unit: string | null (enhet for jamforpris, t.ex. kg/l/st)
- offer: string[]
Arbetssatt (viktigt):
Steg A) Identifiera om texten ar en gruppannons med flera varianter + gemensamma attribut.
Steg B) Returnera en post per faktisk produktvariant med arvd metadata.
Regler:
1) Vanlig produkt (ej bundle): isBundle=false, bundleWeight=null, bundleItems=[].
2) Kombipaket/bundle: isBundle=true, name ska vara paketets huvudnamn, bundleWeight totalvikt.
3) For bundle ska bundleItems innehalla de ingaende produkterna, t.ex. ["Chumlax 3x100g", "Alaska pollock 3x100g"].
4) price ar priset for hela forpackningen. comparisonPrice ar jamforpris som tal ("83:17" -> 83.17).
5) offer innehaller kampanjtext som "Max 10 kop/hushall".
6) Om en rubrik/lista innehaller flera kommaseparerade namn och efterfoljande rad/rader innehaller gemensam brand, vikt, pris eller kampanjvillkor: expandera till separata objekt (en per namn) och arv all gemensam metadata.
7) Tillämpa samma split-regel generellt for liknande tillbud (inte bara ost), nar listan tydligt representerar produktvarianter/smaker/sorter.
8) Splitta INTE om listan snarare ar ingredienser, avdelningar, eller otydlig marknadsforing utan tydlig produktvariant.
9) Specialregel ost: namn som PRAST/HERRGARD/GREVE ska normaliseras till Prästost/Herrgårdsost/Grevéost.
10) Om texten innehaller "ARLA KO" ska brand vara exakt "Arla Ko".
11) For ovan ostsorter ska category vara "Hardost".
12) Behåll svenska diakritiska tecken (ä, å, ö, é) i produktnamn. Returnera "Prästost", "Herrgårdsost", "Grevéost" - inte ASCII-versioner.
13) Returnera aldrig extra nycklar, text, markdown eller forklaringar utanfor JSON-arrayen.
Exempel bundle utdata:
[
{
"name": "Kaptenens Favoriter",
"brand": "Kapten Royal",
"category": "Fisk",
"isBundle": true,
"weight": null,
"bundleWeight": "600g",
"bundleItems": ["Chumlax 3x100g", "Alaska pollock 3x100g"],
"price": 49.90,
"comparisonPrice": 83.17,
"unit": "kg",
"offer": ["Max 10 kop/hushall"]
}
]
Exempel enkel produkt utdata:
[
{
"name": "ICA Basic Mjolk 1,5%",
"brand": "ICA Basic",
"category": "Mejeri",
"isBundle": false,
"weight": "1l",
"bundleWeight": null,
"bundleItems": [],
"price": 12.90,
"comparisonPrice": 12.90,
"unit": "l",
"offer": []
}
]
Exempel gruppannons med varianter (ska splittas):
Input-idé: "PRAST, HERRGARD, GREVE" + "ARLA KO" + gemensam vikt/pris.
Output-idé:
[
{
"name": "Prästost",
"brand": "Arla Ko",
"category": "Hardost",
"isBundle": false,
"weight": "667g",
"bundleWeight": null,
"bundleItems": [],
"price": null,
"comparisonPrice": 79.90,
"unit": "kg",
"offer": ["Max 3 forp/hushall"]
},
{
"name": "Herrgårdsost",
"brand": "Arla Ko",
"category": "Hardost",
"isBundle": false,
"weight": "667g",
"bundleWeight": null,
"bundleItems": [],
"price": null,
"comparisonPrice": 79.90,
"unit": "kg",
"offer": ["Max 3 forp/hushall"]
}
]
Exempel negativt fall (ska INTE splittas):
Input-idé: "Ingredienser: tomat, lok, vitlok".
Output-idé: en produktpost (ingen variant-expansion).
Text att tolka:
${truncatedText}`;
}
/**
* Rensa AI-svaret för att kunna parse som JSON.
*/
private sanitizeJsonResponse(content: string): string {
let cleaned = content.replace(/```json\n?/g, '').replace(/```\n?/g, '');
cleaned = cleaned.trim();
const jsonMatch = cleaned.match(/\[[\s\S]*\]/);
if (jsonMatch) {
cleaned = jsonMatch[0];
}
return cleaned;
}
/**
* Normaliserar och typkonverterar AI-item till vårt format.
*/
private normalizeAiItem(item: Record<string, unknown>, index: number): AiFlyerParseResult {
const toNumber = (val: unknown): number | null => {
if (typeof val === 'number') return val;
if (typeof val === 'string') {
const parsed = parseFloat(val.replace(',', '.'));
return isFinite(parsed) ? parsed : null;
}
return null;
};
const toString = (val: unknown): string | null => {
if (typeof val === 'string') return val.trim() || null;
return null;
};
const toArray = (val: unknown): string[] => {
if (Array.isArray(val)) {
return val.map(v => String(v)).filter(v => v.trim());
}
return [];
};
const rawName = toString(item.name) || `Produkt ${index + 1}`;
const normalizedName = this.normalizeName(rawName);
return {
rawName,
normalizedName,
brand: toString(item.brand),
category: toString(item.category),
price: toNumber(item.price),
priceUnit: toString(item.unit),
comparisonPrice: toNumber(item.comparisonPrice),
comparisonUnit: toString(item.comparisonUnit),
weight: toString(item.weight),
bundleWeight: toString(item.bundleWeight),
isBundle: Boolean(item.isBundle),
bundleItems: toArray(item.bundleItems),
offerText: toString(item.offer) || (toArray(item.offer).join(' ') || null),
confidence: 0.85,
reasonCodes: ['ai_parsed'],
};
}
private normalizeName(name: string): string {
return name
.toLowerCase()
.replace(/[^a-zåäöé0-9\s]/g, '')
.replace(/\s+/g, ' ')
.trim();
}
private splitIntoChunks(text: string): string[] {
const normalized = text.replace(/\r\n/g, '\n').trim();
if (!normalized) return [];
if (normalized.length <= this.chunkSizeChars) {
return [normalized];
}
const chunks: string[] = [];
let start = 0;
while (start < normalized.length && chunks.length < this.maxChunks) {
const end = Math.min(start + this.chunkSizeChars, normalized.length);
const chunk = normalized.slice(start, end).trim();
if (chunk) chunks.push(chunk);
if (end >= normalized.length) break;
start = Math.max(0, end - this.chunkOverlapChars);
}
return chunks;
}
private async parseChunkWithRetry(
client: any,
chunkText: string,
chunkIndex: number,
totalChunks: number,
debugSession: { dirPath: string; baseName: string } | null,
): Promise<{
items: AiFlyerParseResult[];
prompt: string;
rawOutput: string;
attemptsUsed: number;
}> {
const textWindows = [3000, 2200, 1600];
const attempts = Math.max(1, Math.min(this.maxRetries + 1, textWindows.length));
let lastError: unknown = null;
for (let i = 0; i < attempts; i++) {
const window = textWindows[i];
const prompt = this.buildPrompt(chunkText, window);
try {
this.logger.debug(
`Sending request to Mistral Tiny (chunk ${chunkIndex}/${totalChunks}, attempt ${i + 1}/${attempts}, timeout=${this.timeoutMs}ms, textWindow=${window})`,
);
if (debugSession) {
await this.writeDebugFile(
debugSession,
`${debugSession.baseName}-chunk-${chunkIndex}-attempt-${i + 1}-prompt.txt`,
prompt,
);
}
const response = await this.withTimeout<any>(
client.chat({
model: 'ministral-8b-2512',
messages: [{ role: 'user', content: prompt }],
temperature: 0.1,
}),
this.timeoutMs,
'Mistral-anrop timeout',
);
const content = this.ensureUtf8Content(response.choices?.[0]?.message?.content);
if (!content) {
throw new BadRequestException('Tomt svar från AI-modellen.');
}
this.logger.debug(`Mistral response length: ${content.length} chars`);
if (debugSession) {
await this.writeDebugFile(
debugSession,
`${debugSession.baseName}-chunk-${chunkIndex}-attempt-${i + 1}-response.txt`,
String(content),
);
}
const jsonString = this.sanitizeJsonResponse(content);
const items = JSON.parse(jsonString) as Array<Record<string, unknown>>;
if (!Array.isArray(items)) {
throw new BadRequestException('AI returnerade inte en JSON-array.');
}
return {
items: items.map((aiItem, idx) => this.normalizeAiItem(aiItem, idx)),
prompt,
rawOutput: String(content),
attemptsUsed: i + 1,
};
} catch (attemptErr) {
lastError = attemptErr;
if (debugSession) {
await this.writeDebugFile(
debugSession,
`${debugSession.baseName}-chunk-${chunkIndex}-attempt-${i + 1}-error.txt`,
this.toErrorMessage(attemptErr),
);
}
if (!this.isRetryableError(attemptErr) || i === attempts - 1) {
throw attemptErr;
}
this.logger.warn(
`Mistral chunk ${chunkIndex}/${totalChunks} attempt ${i + 1} failed (${this.toErrorMessage(attemptErr)}). Retrying with shorter text window.`,
);
}
}
throw lastError instanceof Error
? lastError
: new ServiceUnavailableException('AI-anrop misslyckades');
}
private dedupeItems(items: AiFlyerParseResult[]): AiFlyerParseResult[] {
const seen = new Set<string>();
const deduped: AiFlyerParseResult[] = [];
for (const item of items) {
const normalizedName = item.normalizedName.trim();
const normalizedBrand = (item.brand ?? '').trim().toLowerCase();
const normalizedPrice = item.price == null ? '' : Number(item.price).toFixed(2);
const normalizedPriceUnit = (item.priceUnit ?? '').trim().toLowerCase();
const normalizedComparisonPrice =
item.comparisonPrice == null ? '' : Number(item.comparisonPrice).toFixed(2);
const normalizedComparisonUnit = (item.comparisonUnit ?? '').trim().toLowerCase();
const offerSignature = this.offerSignature(item.offerText);
const key = [
normalizedName,
normalizedBrand,
normalizedPrice,
normalizedPriceUnit,
normalizedComparisonPrice,
normalizedComparisonUnit,
offerSignature,
item.isBundle ? '1' : '0',
].join('|');
if (seen.has(key)) continue;
seen.add(key);
deduped.push(item);
}
return deduped;
}
private offerSignature(offerText: string | null | undefined): string {
if (!offerText || offerText.trim().length === 0) return '';
const normalized = offerText
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9\s]/g, ' ')
.replace(/\s+/g, ' ')
.trim();
if (!normalized) return '';
const hasCampaignMarkers =
/(max|hogst|begransat|hushall|kund|kop|for|betala|ta)/.test(normalized)
|| /(\d+\s*for\s*\d+)/.test(normalized)
|| /(ta\s*\d+\s*betala\s*for\s*\d+)/.test(normalized);
return hasCampaignMarkers ? normalized : '';
}
private ensureUtf8Content(content: unknown): string {
const asString = this.flattenContent(content);
if (!asString) return '';
const utf8 = Buffer.from(asString, 'utf8').toString('utf8');
if (this.debugEnabled && (asString.includes('\uFFFD') || utf8.includes('\uFFFD'))) {
const hex = Buffer.from(asString, 'utf8').toString('hex').slice(0, 256);
this.logger.debug(`Potential encoding issue in AI response (hex preview): ${hex}`);
}
return utf8;
}
private flattenContent(content: unknown): string {
if (typeof content === 'string') {
return content;
}
if (Array.isArray(content)) {
return content
.map((part) => {
if (typeof part === 'string') return part;
if (part && typeof part === 'object' && 'text' in part) {
const text = (part as { text?: unknown }).text;
return typeof text === 'string' ? text : '';
}
return '';
})
.join('');
}
if (content == null) {
return '';
}
return String(content);
}
private readPositiveIntEnv(key: string, fallback: number): number {
const raw = process.env[key];
if (!raw) return fallback;
const parsed = Number.parseInt(raw, 10);
if (!Number.isFinite(parsed) || parsed <= 0) {
this.logger.warn(`Invalid ${key} value: "${raw}". Falling back to ${fallback}.`);
return fallback;
}
return parsed;
}
private readBooleanEnv(key: string, fallback: boolean): boolean {
const raw = process.env[key];
if (!raw) return fallback;
return ['1', 'true', 'yes', 'on'].includes(raw.trim().toLowerCase());
}
private createDebugSession(prefix: string): { dirPath: string; baseName: string } | null {
if (!this.debugEnabled) return null;
const now = new Date();
const y = String(now.getFullYear()).slice(-2);
const m = String(now.getMonth() + 1).padStart(2, '0');
const d = String(now.getDate()).padStart(2, '0');
const hh = String(now.getHours()).padStart(2, '0');
const mm = String(now.getMinutes()).padStart(2, '0');
const ss = String(now.getSeconds()).padStart(2, '0');
const datePart = `${y}${m}${d}`;
const timePart = `${hh}${mm}${ss}`;
const baseName = `${prefix}-${datePart}-${timePart}`;
const dirPath = path.join(this.debugDirectory, baseName);
return { dirPath, baseName };
}
private async writeDebugFile(
debugSession: { dirPath: string; baseName: string } | null,
filename: string,
content: string,
): Promise<void> {
if (!debugSession) return;
try {
await fs.promises.mkdir(debugSession.dirPath, { recursive: true });
const filePath = path.join(debugSession.dirPath, filename);
await fs.promises.writeFile(filePath, content, 'utf8');
} catch (err) {
this.logger.warn(`Failed to write flyer debug file ${filename}: ${this.toErrorMessage(err)}`);
}
}
private isRetryableError(err: unknown): boolean {
if (err instanceof ServiceUnavailableException) return true;
const message = this.toErrorMessage(err).toLowerCase();
return (
message.includes('timeout') ||
message.includes('timed out') ||
message.includes('rate limit') ||
message.includes('econnreset') ||
message.includes('socket hang up')
);
}
private toErrorMessage(err: unknown): string {
if (err instanceof Error) return err.message;
return String(err);
}
}
@@ -0,0 +1,219 @@
import { Test, TestingModule } from '@nestjs/testing';
import { FlyerNormalizerService } from './flyer-normalizer.service';
describe('FlyerNormalizerService', () => {
let service: FlyerNormalizerService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [FlyerNormalizerService],
}).compile();
service = module.get<FlyerNormalizerService>(FlyerNormalizerService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('normalize', () => {
it('should normalize a valid item', () => {
const items = [
{
rawName: 'KALLRÖKT LAX, GRAVAD LAX',
normalizedName: 'kallrökt lax gravad lax',
category: 'Fisk',
price: 39.9,
comparisonPrice: 266.0,
unit: 'kg',
offer: ['Max 3 köp/hushåll'],
confidence: 0.85,
reasonCodes: ['ai_parsed'],
},
];
const result = service.normalize(items);
expect(result).toHaveLength(1);
expect(result[0].rawName).toBe('KALLRÖKT LAX, GRAVAD LAX');
expect(result[0].price).toBe(39.9);
expect(result[0].priceUnit).toBe('kg');
expect(result[0].categoryHint).toBe('Fisk');
});
it('should handle missing fields gracefully', () => {
const items = [
{
name: 'PRODUKT',
// andra fält saknas
},
];
const result = service.normalize(items);
expect(result).toHaveLength(1);
expect(result[0].rawName).toBe('PRODUKT');
expect(result[0].price).toBeNull();
expect(result[0].categoryHint).toBeNull();
});
it('should skip items without name', () => {
const items = [
{ price: 100 }, // no name
{ rawName: 'VALID PRODUCT', price: 50 },
];
const result = service.normalize(items);
expect(result).toHaveLength(1);
expect(result[0].rawName).toBe('VALID PRODUCT');
});
it('should normalize units correctly', () => {
const items = [
{ rawName: 'Mjölk', unit: 'L' },
{ rawName: 'Smör', unit: 'styck' },
{ rawName: 'Socker', unit: 'KG' },
];
const result = service.normalize(items);
expect(result).toHaveLength(3);
expect(result[0].priceUnit).toBe('l');
expect(result[1].priceUnit).toBe('st');
expect(result[2].priceUnit).toBe('kg');
});
it('should parse Swedish prices correctly', () => {
const items = [
{ rawName: 'Produkt1', price: '39,90' },
{ rawName: 'Produkt2', price: 39.9 },
{ rawName: 'Produkt3', price: '100' },
];
const result = service.normalize(items);
expect(result[0].price).toBe(39.9);
expect(result[1].price).toBe(39.9);
expect(result[2].price).toBe(100);
});
it('should return empty list for non-array input', () => {
const result = service.normalize(null as any);
expect(result).toEqual([]);
const result2 = service.normalize(undefined as any);
expect(result2).toEqual([]);
});
it('splits listed cheese variants into separate products', () => {
const items = [
{
rawName: 'PRÄST®, HERRGÅRD®, GREVÉ®',
brand: 'ARLA KO',
unit: 'kg',
comparisonPrice: '79,90',
offer: ['Max 3 förp/hushåll'],
},
];
const result = service.normalize(items);
expect(result).toHaveLength(3);
expect(result.map((item) => item.rawName)).toEqual(['Prästost', 'Herrgårdsost', 'Grevéost']);
expect(result.every((item) => item.brand === 'Arla Ko')).toBe(true);
expect(result.every((item) => item.categoryHint === 'Hårdost')).toBe(true);
expect(result[0].parseReasons).toContain('split_cheese_variants');
});
it('normalizes PRAST token to Prästost', () => {
const items = [{ rawName: 'PRAST, GREVE', brand: 'ARLA KO' }];
const result = service.normalize(items);
expect(result.map((item) => item.rawName)).toContain('Prästost');
});
it('normalizes GREVE token to Grevéost', () => {
const items = [{ rawName: 'GREVE, PRAST', brand: 'ARLA KO' }];
const result = service.normalize(items);
expect(result.map((item) => item.rawName)).toContain('Grevéost');
});
it('keeps single cheese item unsplit but normalizes brand/category', () => {
const items = [
{
rawName: 'Prästost',
brand: 'arla ko',
},
];
const result = service.normalize(items);
expect(result).toHaveLength(1);
expect(result[0].rawName).toBe('Prästost');
expect(result[0].brand).toBe('Arla Ko');
expect(result[0].categoryHint).toBe('Hårdost');
});
it('fixes known OCR typo for spröd', () => {
const items = [
{
rawName: 'Pröd Bakad Firre',
brand: 'Findus',
},
];
const result = service.normalize(items);
expect(result).toHaveLength(1);
expect(result[0].rawName).toBe('Spröd Bakad Firre');
expect(result[0].normalizedName).toBe('spröd bakad firre');
});
it('does not apply spröd typo fix outside known fish context', () => {
const items = [
{
rawName: 'Pröd tvättmedel',
brand: 'Test',
},
];
const result = service.normalize(items);
expect(result).toHaveLength(1);
expect(result[0].rawName).toBe('Pröd tvättmedel');
});
it('fixes herggårdsost only in cheese context', () => {
const items = [
{
rawName: 'Herggårdsost 31%',
brand: 'Arla Ko',
},
];
const result = service.normalize(items);
expect(result).toHaveLength(1);
expect(result[0].rawName).toContain('Herrgårdsost');
});
it('fixes greveost typo in cheese context and preserves é', () => {
const items = [
{
rawName: 'Greveost skivad',
brand: 'Arla Ko',
},
];
const result = service.normalize(items);
expect(result).toHaveLength(1);
expect(result[0].rawName).toContain('Grevéost');
expect(result[0].normalizedName).toContain('grevéost');
});
});
});
@@ -0,0 +1,264 @@
import { Injectable, Logger } from '@nestjs/common';
export interface NormalizedFlyerItem {
rawName: string;
normalizedName: string;
brand: string | null;
categoryHint: string | null;
price: number | null;
priceUnit: string | null;
comparisonPrice: number | null;
comparisonUnit: string | null;
weight: string | null;
bundleWeight: string | null;
isBundle: boolean;
bundleItems: string[];
offerText: string | null;
parseConfidence: number;
parseReasons: string[];
}
@Injectable()
export class FlyerNormalizerService {
private readonly logger = new Logger(FlyerNormalizerService.name);
private readonly MAX_BUNDLE_ITEMS = 20;
private readonly MAX_BUNDLE_ITEM_LENGTH = 120;
private readonly CHEESE_VARIANT_TO_NAME: Record<string, string> = {
prast: 'Prästost',
herrgard: 'Herrgårdsost',
greve: 'Grevéost',
};
private readonly UNIT_MAPPING: Record<string, string> = {
// Längd
mm: 'mm',
cm: 'cm',
m: 'm',
// Vikt
mg: 'mg',
g: 'g',
hg: 'hg',
kg: 'kg',
ton: 'ton',
// Volym
ml: 'ml',
cl: 'cl',
dl: 'dl',
l: 'l',
// Övrigt
st: 'st',
styck: 'st',
stycke: 'st',
pkt: 'pkt',
paket: 'pkt',
fp: 'pkt',
förp: 'pkt',
förpackning: 'pkt',
};
/**
* Normaliserar en AI-parsad produktlista.
*/
normalize(items: any[]): NormalizedFlyerItem[] {
if (!Array.isArray(items)) {
this.logger.warn('normalize() received non-array, returning empty list');
return [];
}
return items
.flatMap((item, idx) => this.normalizeItem(item, idx))
.filter((item): item is NormalizedFlyerItem => item !== null);
}
private normalizeItem(item: any, index: number): Array<NormalizedFlyerItem | null> {
if (!item || typeof item !== 'object') {
this.logger.warn(`Item ${index} is not an object, skipping`);
return [null];
}
const rawNameValue = this.extractString(item.rawName) || this.extractString(item.name);
if (!rawNameValue) {
this.logger.warn(`Item ${index} has no name, skipping`);
return [null];
}
const rawName = this.fixKnownOcrTypos(rawNameValue);
const normalizedName = this.extractString(item.normalizedName) || this.normalizeName(rawName);
const normalizedBrand = this.normalizeBrand(this.extractString(item.brand), rawName);
const categoryHint = this.normalizeCategory(this.extractString(item.category), rawName);
const baseItem: NormalizedFlyerItem = {
rawName,
normalizedName,
brand: normalizedBrand,
categoryHint,
price: this.extractPrice(item.price),
priceUnit: this.normalizeUnit(this.extractString(item.unit)),
comparisonPrice: this.extractPrice(item.comparisonPrice),
comparisonUnit: this.normalizeUnit(this.extractString(item.comparisonUnit)),
weight: this.extractString(item.weight),
bundleWeight: this.extractString(item.bundleWeight),
isBundle: Boolean(item.isBundle),
bundleItems: this.extractStringArray(item.bundleItems),
offerText: this.normalizeOfferText(item.offer),
parseConfidence: item.confidence ?? 0.85,
parseReasons: Array.isArray(item.reasonCodes)
? item.reasonCodes.map(String)
: ['normalized'],
};
const expandedItems = this.expandCheeseVariants(baseItem);
if (expandedItems.length > 0) {
return expandedItems;
}
return [baseItem];
}
private extractString(val: any): string | null {
if (typeof val === 'string') return val.trim() || null;
return null;
}
private extractPrice(val: any): number | null {
if (typeof val === 'number') return val;
if (typeof val === 'string') {
const num = parseFloat(val.replace(/,/g, '.'));
return isFinite(num) ? num : null;
}
return null;
}
private extractStringArray(val: any): string[] {
if (!Array.isArray(val)) return [];
return val
.map((entry) => String(entry).trim())
.filter(Boolean)
.slice(0, this.MAX_BUNDLE_ITEMS)
.map((entry) => entry.slice(0, this.MAX_BUNDLE_ITEM_LENGTH));
}
private normalizeName(name: string): string {
return name
.toLowerCase()
.replace(/[^a-zåäöé0-9\s]/g, '')
.replace(/\s+/g, ' ')
.trim();
}
private normalizeUnit(unit: string | null): string | null {
if (!unit) return null;
const cleaned = unit.trim().toLowerCase().replace(/\./g, '');
return this.UNIT_MAPPING[cleaned] ?? null;
}
private normalizeCategory(category: string | null, rawName?: string): string | null {
if (this.containsSwedishCheeseVariant(rawName)) {
return 'Hårdost';
}
if (!category) return null;
const normalized = category.trim().toLowerCase();
// Mappning av tänkta kategorivärdena från AI
const categoryMap: Record<string, string> = {
fisk: 'Fisk',
kött: 'Kött',
mejeri: 'Mejeri',
grönsaker: 'Grönsaker',
frukt: 'Frukt',
dryck: 'Dryck',
frukt_grönsaker: 'Frukt & Grönsaker',
fastfood: 'Fastfood',
bröd: 'Bröd',
fryst: 'Fryst',
godis: 'Godis',
pasta: 'Pasta',
};
return categoryMap[normalized] ?? null;
}
private normalizeBrand(brand: string | null, rawName?: string): string | null {
const value = `${brand ?? ''} ${rawName ?? ''}`.trim().toLowerCase();
if (value.includes('arla ko')) {
return 'Arla Ko';
}
return brand;
}
private containsSwedishCheeseVariant(value?: string | null): boolean {
if (!value) return false;
const normalized = this.stripDiacritics(value.toLowerCase());
return ['prast', 'herrgard', 'greve'].some((token) => normalized.includes(token));
}
private expandCheeseVariants(item: NormalizedFlyerItem): NormalizedFlyerItem[] {
if (item.isBundle) return [];
const normalizedRaw = this.stripDiacritics(item.rawName.toLowerCase());
const tokens = normalizedRaw
.split(/[,/&]|\boch\b|\band\b/g)
.map((part) => part.trim())
.filter(Boolean);
const variants = Array.from(
new Set(
tokens
.map((token) => token.replace(/[^a-z0-9\s]/g, ''))
.flatMap((token) => Object.keys(this.CHEESE_VARIANT_TO_NAME).filter((key) => token.includes(key))),
),
);
if (variants.length <= 1) {
return [];
}
return variants.map((variant) => {
const productName = this.CHEESE_VARIANT_TO_NAME[variant];
return {
...item,
rawName: productName,
normalizedName: this.normalizeName(productName),
categoryHint: 'Hårdost',
parseReasons: [...item.parseReasons, 'split_cheese_variants'],
};
});
}
private stripDiacritics(value: string): string {
return value
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '');
}
private normalizeOfferText(offer: any): string | null {
if (!offer) return null;
if (typeof offer === 'string') {
return offer.trim() || null;
}
if (Array.isArray(offer)) {
const joined = offer.map(String).filter(s => s.trim()).join(' ');
return joined || null;
}
return null;
}
private fixKnownOcrTypos(value: string): string {
let corrected = value;
if (/\bbakad\b/i.test(value) && /\bfirre\b/i.test(value)) {
corrected = corrected.replace(/\bpröd\b/gi, (match) => (match[0] === 'P' ? 'Spröd' : 'spröd'));
}
if (/ost\b|hårdost/i.test(value)) {
corrected = corrected.replace(/\bherg{1,2}årds?ost\b/gi, (match) => (match[0] === 'H' ? 'Herrgårdsost' : 'herrgårdsost'));
corrected = corrected.replace(/\bgreveost\b/gi, (match) => (match[0] === 'G' ? 'Grevéost' : 'grevéost'));
}
return corrected;
}
}
@@ -0,0 +1,71 @@
import { describeMatchReason, describeParseReason } from './reason-codes';
describe('reason-codes', () => {
it('describes known parse reasons in Swedish', () => {
expect(describeParseReason('ai_parsed')).toMatchObject({
kind: 'parse',
code: 'ai_parsed',
severity: 'info',
title: 'AI-tolkad rad',
});
expect(describeParseReason('split_cheese_variants')).toMatchObject({
kind: 'parse',
code: 'split_cheese_variants',
severity: 'info',
});
expect(describeParseReason('normalized')).toMatchObject({
kind: 'parse',
code: 'normalized',
severity: 'info',
});
expect(describeParseReason('low_confidence')).toMatchObject({
kind: 'parse',
code: 'low_confidence',
severity: 'warning',
title: 'Låg parsningskvalitet',
});
});
it('describes known match reasons in Swedish', () => {
expect(describeMatchReason('no_match')).toMatchObject({
kind: 'match',
code: 'no_match',
severity: 'warning',
title: 'Ingen produktmatchning',
});
expect(describeMatchReason('alias_exact')).toMatchObject({
kind: 'match',
code: 'alias_exact',
severity: 'info',
});
expect(describeMatchReason('normalized_exact')).toMatchObject({
kind: 'match',
code: 'normalized_exact',
severity: 'info',
});
expect(describeMatchReason('token_overlap:0.72')).toMatchObject({
kind: 'match',
code: 'token_overlap',
severity: 'info',
title: 'Tokenmatchning',
});
expect(describeMatchReason('alias_points_to_missing_product')).toMatchObject({
kind: 'match',
code: 'alias_points_to_missing_product',
severity: 'error',
});
expect(describeMatchReason('empty_name')).toMatchObject({
kind: 'match',
code: 'empty_name',
severity: 'error',
});
});
});
@@ -0,0 +1,184 @@
export type ReasonKind = 'parse' | 'match';
export type ReasonSeverity = 'info' | 'warning' | 'error';
export type ParseReasonCode =
| 'ai_parsed'
| 'split_cheese_variants'
| 'normalized'
| 'low_confidence';
export type MatchReasonCode =
| 'no_match'
| 'alias_exact'
| 'normalized_exact'
| 'token_overlap'
| 'alias_points_to_missing_product'
| 'empty_name';
export type FlyerReasonDescriptor = {
code: string;
kind: ReasonKind;
title: string;
message: string;
severity: ReasonSeverity;
location: string | null;
};
export type DescribeReasonContext = {
location?: string | null;
itemIndex?: number;
lang?: 'sv';
};
const PARSE_DEFAULT_LOCATION = 'Steg: AI-parser';
const MATCH_DEFAULT_LOCATION = 'Steg: matchning mot dina produkter';
export function describeParseReason(
rawCode: string,
context?: DescribeReasonContext,
): FlyerReasonDescriptor {
const code = normalizeCode(rawCode);
const location = context?.location ?? PARSE_DEFAULT_LOCATION;
switch (code) {
case 'ai_parsed':
return {
code,
kind: 'parse',
title: 'AI-tolkad rad',
message: 'Raden tolkades av AI utan att en deterministisk regel matchade.',
severity: 'info',
location,
};
case 'split_cheese_variants':
return {
code,
kind: 'parse',
title: 'Variant-split',
message: 'Gruppannonsen expanderades till individuella ostvarianter.',
severity: 'info',
location,
};
case 'normalized':
return {
code,
kind: 'parse',
title: 'Normaliserad rad',
message: 'Produkttexten normaliserades för bättre matchning.',
severity: 'info',
location,
};
case 'low_confidence':
return {
code,
kind: 'parse',
title: 'Låg parsningskvalitet',
message: 'Modellens säkerhet är låg, granska raden manuellt.',
severity: 'warning',
location,
};
default:
return {
code,
kind: 'parse',
title: 'Okänd parserorsak',
message: `En okänd parserorsak rapporterades: ${rawCode}`,
severity: 'warning',
location,
};
}
}
export function describeMatchReason(
rawCode: string,
context?: DescribeReasonContext,
): FlyerReasonDescriptor {
const location = context?.location ?? MATCH_DEFAULT_LOCATION;
const code = normalizeCode(rawCode);
switch (code) {
case 'no_match':
return {
code,
kind: 'match',
title: 'Ingen produktmatchning',
message:
'Vi kunde inte hitta någon befintlig produkt som matchar texten på flyern.',
severity: 'warning',
location,
};
case 'alias_exact':
return {
code,
kind: 'match',
title: 'Aliasmatchning',
message: 'Raden matchades exakt via ett registrerat alias.',
severity: 'info',
location,
};
case 'normalized_exact':
return {
code,
kind: 'match',
title: 'Exakt normaliserad matchning',
message: 'Raden matchades exakt efter normalisering av produktnamnet.',
severity: 'info',
location,
};
case 'token_overlap': {
const overlap = parseTokenOverlap(rawCode);
const overlapSuffix = overlap == null ? '' : ` (överlapp: ${Math.round(overlap * 100)}%)`;
return {
code,
kind: 'match',
title: 'Tokenmatchning',
message: `Raden matchades med tokenöverlapp mot en befintlig produkt${overlapSuffix}.`,
severity: 'info',
location,
};
}
case 'alias_points_to_missing_product':
return {
code,
kind: 'match',
title: 'Trasig alias-koppling',
message: 'Ett alias pekar på en produkt som inte längre finns.',
severity: 'error',
location,
};
case 'empty_name':
return {
code,
kind: 'match',
title: 'Tomt produktnamn',
message: 'Raden saknar tolkbart produktnamn.',
severity: 'error',
location,
};
default:
return {
code,
kind: 'match',
title: 'Okänd matchorsak',
message: `En okänd matchorsak rapporterades: ${rawCode}`,
severity: 'warning',
location,
};
}
}
function normalizeCode(rawCode: string): string {
const trimmed = String(rawCode ?? '').trim();
if (trimmed.startsWith('token_overlap:')) {
return 'token_overlap';
}
return trimmed;
}
function parseTokenOverlap(rawCode: string): number | null {
const match = String(rawCode).trim().match(/^token_overlap:(\d+(?:\.\d+)?)$/);
if (!match) return null;
const parsed = Number.parseFloat(match[1]);
if (!Number.isFinite(parsed)) return null;
return Math.max(0, Math.min(1, parsed));
}
@@ -0,0 +1,100 @@
import { Injectable, Logger } from '@nestjs/common';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import * as pdf from 'pdf-parse';
import Tesseract from 'tesseract.js';
@Injectable()
export class TextExtractorService {
private readonly logger = new Logger(TextExtractorService.name);
/**
* Extraherar text från en PDF-buffer.
* Försöker med pdf-parse först; om det inte ger resultat, fallback till OCR.
*
* @param buffer PDF-fil som buffer
* @returns Extraherad text
*/
async extractText(
buffer: Buffer,
mimeType?: string,
originalFilename?: string,
): Promise<string> {
// Försök primär PDF-extract
try {
this.logger.debug('Attempting pdf-parse extraction');
const pdfData = await pdf(buffer);
const text = pdfData.text?.trim() || '';
const wordCount = text.split(/\s+/).filter(w => w.length > 0).length;
this.logger.debug(`pdf-parse extracted ${wordCount} words`);
// Om vi fick tillräckligt med text, returnera det
if (wordCount >= 10) {
return text;
}
this.logger.debug('pdf-parse gave too little text, falling back to OCR');
} catch (err) {
this.logger.warn(`pdf-parse failed: ${String(err)}`);
}
// Fallback: OCR med Tesseract
return this.extractTextViaOCR(buffer, mimeType, originalFilename);
}
/**
* Extraherar text från en PDF eller bild via OCR (Tesseract).
*
* @param buffer Fil-buffer (PDF eller bild)
* @returns Extraherad text
*/
private async extractTextViaOCR(
buffer: Buffer,
mimeType?: string,
originalFilename?: string,
): Promise<string> {
try {
this.logger.debug('Starting Tesseract OCR extraction');
// Tesseract.js kräver en sökväg eller data-URL; vi skriver temporär fil
const ext = this.resolveTempExtension(mimeType, originalFilename);
const tempPath = path.join(os.tmpdir(), `ocr-${Date.now()}${ext}`);
await fs.promises.writeFile(tempPath, buffer);
try {
const result = await Tesseract.recognize(tempPath, 'swe', {
logger: (m) => this.logger.debug(`Tesseract: ${m.status}`),
});
const text = result.data.text || '';
this.logger.debug(`Tesseract extracted ${text.split(/\s+/).length} words`);
return text;
} finally {
try {
await fs.promises.unlink(tempPath);
} catch {
// ignorera om cleanup misslyckas
}
}
} catch (err) {
this.logger.error(`OCR extraction failed: ${String(err)}`);
throw new Error('Kunde inte extrahera text från flyern (pdf-parse + OCR misslyckades).');
}
}
private resolveTempExtension(mimeType?: string, originalFilename?: string): string {
if (mimeType === 'image/png') return '.png';
if (mimeType === 'image/webp') return '.webp';
if (mimeType === 'image/jpeg') return '.jpg';
if (mimeType === 'text/plain') return '.txt';
if (mimeType === 'application/pdf') return '.pdf';
const originalExt = originalFilename ? path.extname(originalFilename).toLowerCase() : '';
if (originalExt) return originalExt;
return '.pdf';
}
}
@@ -0,0 +1,11 @@
import { Type } from 'class-transformer';
import { ArrayMinSize, IsArray, ValidateNested } from 'class-validator';
import { CreateFlyerSelectionDto } from './create-flyer-selection.dto';
export class CreateFlyerSelectionBulkDto {
@IsArray()
@ArrayMinSize(1)
@ValidateNested({ each: true })
@Type(() => CreateFlyerSelectionDto)
items!: CreateFlyerSelectionDto[];
}
@@ -0,0 +1,38 @@
import { Type } from 'class-transformer';
import {
IsIn,
IsInt,
IsNumber,
IsOptional,
IsString,
MaxLength,
Min,
} from 'class-validator';
export class CreateFlyerSelectionDto {
@Type(() => Number)
@IsInt()
@Min(1)
itemId!: number;
@IsOptional()
@Type(() => Number)
@IsNumber({ maxDecimalPlaces: 2 })
@Min(0)
plannedQuantity?: number;
@IsOptional()
@IsString()
@MaxLength(24)
plannedUnit?: string;
@IsOptional()
@IsString()
@IsIn(['low', 'normal', 'high'])
priority?: string;
@IsOptional()
@IsString()
@MaxLength(500)
note?: string;
}
@@ -0,0 +1,22 @@
export type FlyerSelectionResponse = {
id: number;
sessionId: number;
itemId: number;
userId: number;
plannedQuantity: number | null;
plannedUnit: string | null;
priority: string;
note: string | null;
status: string;
createdAt: string;
updatedAt: string;
item: {
id: number;
rawName: string;
normalizedName: string;
price: number | null;
priceUnit: string | null;
matchedProductId: number | null;
matchedProductName: string | null;
};
};
@@ -0,0 +1,11 @@
import { Type } from 'class-transformer';
import { IsArray, IsInt, IsOptional, Min } from 'class-validator';
export class PlanToShoppingListDto {
@IsOptional()
@IsArray()
@Type(() => Number)
@IsInt({ each: true })
@Min(1, { each: true })
itemIds?: number[];
}
@@ -0,0 +1,86 @@
import { Type } from 'class-transformer';
import {
IsArray,
IsIn,
IsInt,
IsNumber,
IsOptional,
IsString,
MaxLength,
Min,
ValidateNested,
} from 'class-validator';
export class ReceiptMatchItemDto {
@IsString()
@MaxLength(191)
rawName!: string;
@IsOptional()
@IsString()
@MaxLength(191)
normalizedName?: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
productId?: number;
@IsOptional()
@Type(() => Number)
@IsNumber({ maxDecimalPlaces: 2 })
@Min(0)
quantity?: number;
@IsOptional()
@IsString()
@MaxLength(24)
unit?: string;
}
export class ReceiptMatchOverrideDto {
@Type(() => Number)
@IsInt()
@Min(0)
rowIndex!: number;
@Type(() => Number)
@IsInt()
@Min(1)
selectionId!: number;
}
export class ReceiptMatchDto {
@IsArray()
@ValidateNested({ each: true })
@Type(() => ReceiptMatchItemDto)
items!: ReceiptMatchItemDto[];
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
sessionId?: number;
@IsOptional()
@IsString()
@MaxLength(16)
weekKey?: string;
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => ReceiptMatchOverrideDto)
overrides?: ReceiptMatchOverrideDto[];
@IsOptional()
@IsString()
@MaxLength(80)
receiptImportBatchId?: string;
@IsOptional()
@IsString()
@IsIn(['receipt_auto', 'receipt_manual'])
boughtSource?: 'receipt_auto' | 'receipt_manual';
}
@@ -0,0 +1,30 @@
export type FlyerMatchStatus = 'auto' | 'ambiguous' | 'unmatched';
export type ReceiptMatchPreviewRow = {
rowIndex: number;
status: FlyerMatchStatus;
confidence: number;
matchedVia: string;
reasonCodes: string[];
selectionId: number | null;
sessionId: number | null;
itemId: number | null;
plannedName: string | null;
plannedProductId: number | null;
plannedProductName: string | null;
};
export type ReceiptMatchPreviewResponse = {
rows: ReceiptMatchPreviewRow[];
autoCount: number;
ambiguousCount: number;
unmatchedCount: number;
candidateSelectionCount: number;
};
export type ReceiptMatchCommitResponse = {
boughtCount: number;
ambiguousCount: number;
unmatchedCount: number;
updatedSelectionIds: number[];
};
@@ -0,0 +1,37 @@
import { Type } from 'class-transformer';
import {
IsIn,
IsNumber,
IsOptional,
IsString,
MaxLength,
Min,
} from 'class-validator';
export class UpdateFlyerSelectionDto {
@IsOptional()
@Type(() => Number)
@IsNumber({ maxDecimalPlaces: 2 })
@Min(0)
plannedQuantity?: number;
@IsOptional()
@IsString()
@MaxLength(24)
plannedUnit?: string;
@IsOptional()
@IsString()
@IsIn(['low', 'normal', 'high'])
priority?: string;
@IsOptional()
@IsString()
@IsIn(['planned', 'bought', 'skipped', 'archived'])
status?: string;
@IsOptional()
@IsString()
@MaxLength(500)
note?: string;
}
@@ -0,0 +1,207 @@
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { normalizeName } from '../common/utils/normalize-name';
import { ReceiptMatchItemDto } from './dto/receipt-match.dto';
import { ReceiptMatchPreviewResponse, ReceiptMatchPreviewRow } from './dto/receipt-match.response';
export type CandidateSelection = {
id: number;
sessionId: number;
itemId: number;
plannedQuantity: Prisma.Decimal | null;
plannedUnit: string | null;
item: {
rawName: string;
normalizedName: string;
matchedProductId: number | null;
matchedProductName: string | null;
};
};
const AUTO_MATCH_THRESHOLD = 0.9;
const AMBIGUOUS_THRESHOLD = 0.7;
@Injectable()
export class FlyerSelectionMatcherService {
matchRows(items: ReceiptMatchItemDto[], candidates: CandidateSelection[]): ReceiptMatchPreviewRow[] {
const remainingSelectionIds = new Set(candidates.map((candidate) => candidate.id));
const byProductId = new Map<number, CandidateSelection[]>();
const byNormalizedName = new Map<string, CandidateSelection[]>();
for (const candidate of candidates) {
const productId = candidate.item.matchedProductId;
if (productId != null) {
const list = byProductId.get(productId) ?? [];
list.push(candidate);
byProductId.set(productId, list);
}
const normalized = normalizeName(candidate.item.normalizedName || candidate.item.rawName);
if (normalized) {
const list = byNormalizedName.get(normalized) ?? [];
list.push(candidate);
byNormalizedName.set(normalized, list);
}
}
const rows: ReceiptMatchPreviewRow[] = [];
for (let rowIndex = 0; rowIndex < items.length; rowIndex++) {
const receiptItem = items[rowIndex];
const candidatesForRow = this.candidatePool(receiptItem, candidates, remainingSelectionIds, byProductId, byNormalizedName);
const best = this.findBest(receiptItem, candidatesForRow);
if (!best) {
rows.push({
rowIndex,
status: 'unmatched',
confidence: 0,
matchedVia: 'none',
reasonCodes: ['no_match'],
selectionId: null,
sessionId: null,
itemId: null,
plannedName: null,
plannedProductId: null,
plannedProductName: null,
});
continue;
}
const status =
best.confidence >= AUTO_MATCH_THRESHOLD
? 'auto'
: best.confidence >= AMBIGUOUS_THRESHOLD
? 'ambiguous'
: 'unmatched';
if (status !== 'unmatched') {
remainingSelectionIds.delete(best.candidate.id);
}
rows.push({
rowIndex,
status,
confidence: Number(best.confidence.toFixed(3)),
matchedVia: best.matchedVia,
reasonCodes: best.reasons,
selectionId: best.candidate.id,
sessionId: best.candidate.sessionId,
itemId: best.candidate.itemId,
plannedName: best.candidate.item.rawName,
plannedProductId: best.candidate.item.matchedProductId,
plannedProductName: best.candidate.item.matchedProductName,
});
}
return rows;
}
toPreviewResponse(rows: ReceiptMatchPreviewRow[], candidateSelectionCount: number): ReceiptMatchPreviewResponse {
const autoCount = rows.filter((row) => row.status === 'auto').length;
const ambiguousCount = rows.filter((row) => row.status === 'ambiguous').length;
const unmatchedCount = rows.filter((row) => row.status === 'unmatched').length;
return { rows, autoCount, ambiguousCount, unmatchedCount, candidateSelectionCount };
}
private candidatePool(
receiptItem: ReceiptMatchItemDto,
allCandidates: CandidateSelection[],
remainingSelectionIds: Set<number>,
byProductId: Map<number, CandidateSelection[]>,
byNormalizedName: Map<string, CandidateSelection[]>,
): CandidateSelection[] {
const pool = new Set<CandidateSelection>();
if (receiptItem.productId != null) {
for (const candidate of byProductId.get(receiptItem.productId) ?? []) {
if (remainingSelectionIds.has(candidate.id)) pool.add(candidate);
}
}
const receiptNormalized = normalizeName(receiptItem.normalizedName ?? receiptItem.rawName);
if (receiptNormalized) {
for (const candidate of byNormalizedName.get(receiptNormalized) ?? []) {
if (remainingSelectionIds.has(candidate.id)) pool.add(candidate);
}
}
if (pool.size > 0) {
return [...pool];
}
return allCandidates.filter((candidate) => remainingSelectionIds.has(candidate.id));
}
private findBest(
receiptItem: ReceiptMatchItemDto,
candidates: CandidateSelection[],
): { candidate: CandidateSelection; confidence: number; matchedVia: string; reasons: string[] } | null {
let best: { candidate: CandidateSelection; confidence: number; matchedVia: string; reasons: string[] } | null = null;
for (const candidate of candidates) {
const evaluated = this.scoreCandidate(receiptItem, candidate);
if (evaluated.confidence <= 0) continue;
if (!best || evaluated.confidence > best.confidence) best = evaluated;
}
return best;
}
private scoreCandidate(receiptItem: ReceiptMatchItemDto, candidate: CandidateSelection) {
const reasons: string[] = [];
const receiptNormalized = normalizeName(receiptItem.normalizedName ?? receiptItem.rawName);
const flyerNormalized = normalizeName(candidate.item.normalizedName || candidate.item.rawName);
if (receiptItem.productId && candidate.item.matchedProductId === receiptItem.productId) {
reasons.push('product_id_exact');
return { candidate, confidence: 1, matchedVia: 'product_id', reasons };
}
if (receiptNormalized && flyerNormalized && receiptNormalized === flyerNormalized) {
let confidence = 0.93;
reasons.push('name_exact');
confidence += this.quantityUnitBonus(receiptItem, candidate, reasons);
return { candidate, confidence: Math.min(0.99, confidence), matchedVia: 'name_exact', reasons };
}
const overlap = this.tokenOverlap(this.tokenize(receiptItem.rawName), this.tokenize(candidate.item.rawName));
if (overlap <= 0) {
return { candidate, confidence: 0, matchedVia: 'none', reasons: ['no_token_overlap'] };
}
let confidence = Math.min(0.89, 0.45 + overlap * 0.45);
reasons.push(`token_overlap:${overlap.toFixed(2)}`);
confidence += this.quantityUnitBonus(receiptItem, candidate, reasons);
confidence = Math.min(0.89, confidence);
return { candidate, confidence, matchedVia: 'token', reasons };
}
private quantityUnitBonus(receiptItem: ReceiptMatchItemDto, candidate: CandidateSelection, reasons: string[]): number {
let bonus = 0;
if (receiptItem.quantity != null && candidate.plannedQuantity != null && Number(candidate.plannedQuantity) === receiptItem.quantity) {
bonus += 0.03;
reasons.push('quantity_match');
}
const receiptUnit = (receiptItem.unit ?? '').trim().toLowerCase();
const plannedUnit = (candidate.plannedUnit ?? '').trim().toLowerCase();
if (receiptUnit && plannedUnit && receiptUnit === plannedUnit) {
bonus += 0.03;
reasons.push('unit_match');
}
return bonus;
}
private tokenize(value: string): string[] {
return value.toLowerCase().split(/[^a-z0-9åäö]+/).map((part) => part.trim()).filter((part) => part.length >= 3);
}
private tokenOverlap(a: string[], b: string[]): number {
if (a.length === 0 || b.length === 0) return 0;
const as = new Set(a);
const bs = new Set(b);
let intersection = 0;
for (const token of as) {
if (bs.has(token)) intersection++;
}
const union = new Set([...as, ...bs]).size;
if (union === 0) return 0;
return intersection / union;
}
}
@@ -0,0 +1,68 @@
import {
Body,
Controller,
Get,
HttpCode,
Post,
Query,
Request,
UnauthorizedException,
} from '@nestjs/common';
import { Throttle } from '@nestjs/throttler';
import { FlyerSelectionResponse } from './dto/flyer-selection.response';
import { ReceiptMatchDto } from './dto/receipt-match.dto';
import {
ReceiptMatchCommitResponse,
ReceiptMatchPreviewResponse,
} from './dto/receipt-match.response';
import { FlyerSelectionService } from './flyer-selection.service';
@Controller('flyer-selections')
export class FlyerSelectionSyncController {
constructor(private readonly flyerSelectionService: FlyerSelectionService) {}
@Get('open')
@Throttle({ default: { ttl: 60_000, limit: 30 } })
async listOpen(
@Query('weekKey') weekKey?: string,
@Request() req?: any,
): Promise<FlyerSelectionResponse[]> {
const userId = this.getUserId(req);
return this.flyerSelectionService.listOpen(userId, weekKey);
}
@Post('receipt-match-preview')
@HttpCode(200)
@Throttle({ default: { ttl: 60_000, limit: 20 } })
async preview(
@Body() dto: ReceiptMatchDto,
@Request() req?: any,
): Promise<ReceiptMatchPreviewResponse> {
const userId = this.getUserId(req);
return this.flyerSelectionService.previewReceiptMatches(userId, dto);
}
@Post('receipt-match-commit')
@HttpCode(200)
@Throttle({ default: { ttl: 60_000, limit: 20 } })
async commit(
@Body() dto: ReceiptMatchDto,
@Request() req?: any,
): Promise<ReceiptMatchCommitResponse> {
const userId = this.getUserId(req);
return this.flyerSelectionService.commitReceiptMatches(userId, dto);
}
private getUserId(req?: any): number {
const userId =
typeof req?.user?.id === 'number'
? req.user.id
: typeof req?.user?.userId === 'number'
? req.user.userId
: undefined;
if (!userId) {
throw new UnauthorizedException('Kunde inte identifiera användaren.');
}
return userId;
}
}
@@ -0,0 +1,108 @@
import {
Body,
Controller,
Delete,
HttpCode,
Param,
ParseIntPipe,
Patch,
Post,
Request,
Get,
UnauthorizedException,
} from '@nestjs/common';
import { Throttle } from '@nestjs/throttler';
import { CreateFlyerSelectionDto } from './dto/create-flyer-selection.dto';
import { CreateFlyerSelectionBulkDto } from './dto/create-flyer-selection-bulk.dto';
import { FlyerSelectionResponse } from './dto/flyer-selection.response';
import { UpdateFlyerSelectionDto } from './dto/update-flyer-selection.dto';
import { FlyerSelectionService } from './flyer-selection.service';
import { PlanToShoppingListDto } from './dto/plan-to-shopping-list.dto';
@Controller('flyer-sessions/:sessionId/selections')
export class FlyerSelectionController {
constructor(private readonly flyerSelectionService: FlyerSelectionService) {}
@Get()
async list(
@Param('sessionId', ParseIntPipe) sessionId: number,
@Request() req?: any,
): Promise<FlyerSelectionResponse[]> {
const userId = this.getUserId(req);
return this.flyerSelectionService.listBySession(sessionId, userId);
}
@Post()
@HttpCode(200)
@Throttle({ default: { ttl: 60_000, limit: 20 } })
async create(
@Param('sessionId', ParseIntPipe) sessionId: number,
@Body() dto: CreateFlyerSelectionDto,
@Request() req?: any,
): Promise<FlyerSelectionResponse> {
const userId = this.getUserId(req);
return this.flyerSelectionService.create(sessionId, userId, dto);
}
@Post('bulk')
@HttpCode(200)
@Throttle({ default: { ttl: 60_000, limit: 10 } })
async createMany(
@Param('sessionId', ParseIntPipe) sessionId: number,
@Body() dto: CreateFlyerSelectionBulkDto,
@Request() req?: any,
): Promise<FlyerSelectionResponse[]> {
const userId = this.getUserId(req);
return this.flyerSelectionService.createMany(sessionId, userId, dto.items);
}
@Patch(':selectionId')
@HttpCode(200)
@Throttle({ default: { ttl: 60_000, limit: 30 } })
async update(
@Param('sessionId', ParseIntPipe) sessionId: number,
@Param('selectionId', ParseIntPipe) selectionId: number,
@Body() dto: UpdateFlyerSelectionDto,
@Request() req?: any,
): Promise<FlyerSelectionResponse> {
const userId = this.getUserId(req);
return this.flyerSelectionService.update(sessionId, selectionId, userId, dto);
}
@Delete(':selectionId')
@HttpCode(204)
@Throttle({ default: { ttl: 60_000, limit: 20 } })
async remove(
@Param('sessionId', ParseIntPipe) sessionId: number,
@Param('selectionId', ParseIntPipe) selectionId: number,
@Request() req?: any,
): Promise<void> {
const userId = this.getUserId(req);
await this.flyerSelectionService.remove(sessionId, selectionId, userId);
}
@Post('plan-to-shopping-list')
@HttpCode(200)
@Throttle({ default: { ttl: 60_000, limit: 20 } })
async planToShoppingList(
@Param('sessionId', ParseIntPipe) sessionId: number,
@Body() dto: PlanToShoppingListDto,
@Request() req?: any,
): Promise<{ created: number; updated: number; processedSelectionIds: number[] }> {
const userId = this.getUserId(req);
return this.flyerSelectionService.planToShoppingList(sessionId, userId, dto.itemIds);
}
private getUserId(req?: any): number {
const userId =
typeof req?.user?.id === 'number'
? req.user.id
: typeof req?.user?.userId === 'number'
? req.user.userId
: undefined;
if (!userId) {
throw new UnauthorizedException('Kunde inte identifiera användaren.');
}
return userId;
}
}
@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../prisma/prisma.module';
import { ShoppingListModule } from '../shopping-list/shopping-list.module';
import { FlyerSelectionMatcherService } from './flyer-selection-matcher.service';
import { FlyerSelectionController } from './flyer-selection.controller';
import { FlyerSelectionSyncController } from './flyer-selection-sync.controller';
import { FlyerSelectionService } from './flyer-selection.service';
@Module({
imports: [PrismaModule, ShoppingListModule],
controllers: [FlyerSelectionController, FlyerSelectionSyncController],
providers: [FlyerSelectionService, FlyerSelectionMatcherService],
exports: [FlyerSelectionService],
})
export class FlyerSelectionModule {}
@@ -0,0 +1,435 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { CreateFlyerSelectionDto } from './dto/create-flyer-selection.dto';
import {
ReceiptMatchCommitResponse,
ReceiptMatchPreviewResponse,
} from './dto/receipt-match.response';
import { ReceiptMatchDto } from './dto/receipt-match.dto';
import { FlyerSelectionResponse } from './dto/flyer-selection.response';
import { UpdateFlyerSelectionDto } from './dto/update-flyer-selection.dto';
import {
CandidateSelection,
FlyerSelectionMatcherService,
} from './flyer-selection-matcher.service';
import { ShoppingListService } from '../shopping-list/shopping-list.service';
@Injectable()
export class FlyerSelectionService {
constructor(
private readonly prisma: PrismaService,
private readonly matcher: FlyerSelectionMatcherService,
private readonly shoppingListService: ShoppingListService,
) {}
async listBySession(sessionId: number, userId: number): Promise<FlyerSelectionResponse[]> {
await this.assertSessionOwnership(sessionId, userId);
const rows = await this.prisma.flyerSelection.findMany({
where: { sessionId, userId },
include: {
item: {
select: {
id: true,
rawName: true,
normalizedName: true,
price: true,
priceUnit: true,
matchedProductId: true,
matchedProductName: true,
},
},
},
orderBy: { createdAt: 'desc' },
});
return rows.map((row) => this.toResponse(row));
}
async create(
sessionId: number,
userId: number,
dto: CreateFlyerSelectionDto,
): Promise<FlyerSelectionResponse> {
await this.assertSessionOwnership(sessionId, userId);
const item = await this.prisma.flyerItem.findUnique({
where: { id: dto.itemId },
select: { id: true, sessionId: true },
});
if (!item || item.sessionId !== sessionId) {
throw new BadRequestException('Vald flyer-rad tillhör inte sessionen.');
}
const created = await this.prisma.flyerSelection.upsert({
where: {
sessionId_itemId: {
sessionId,
itemId: dto.itemId,
},
},
update: {
plannedQuantity:
dto.plannedQuantity == null ? undefined : new Prisma.Decimal(dto.plannedQuantity),
plannedUnit: dto.plannedUnit,
priority: dto.priority ?? 'normal',
note: dto.note,
},
create: {
sessionId,
itemId: dto.itemId,
userId,
plannedQuantity:
dto.plannedQuantity == null ? null : new Prisma.Decimal(dto.plannedQuantity),
plannedUnit: dto.plannedUnit ?? null,
priority: dto.priority ?? 'normal',
note: dto.note ?? null,
},
include: {
item: {
select: {
id: true,
rawName: true,
normalizedName: true,
price: true,
priceUnit: true,
matchedProductId: true,
matchedProductName: true,
},
},
},
});
return this.toResponse(created);
}
async createMany(
sessionId: number,
userId: number,
items: CreateFlyerSelectionDto[],
): Promise<FlyerSelectionResponse[]> {
if (items.length === 0) return [];
await this.assertSessionOwnership(sessionId, userId);
const existingItems = await this.prisma.flyerItem.findMany({
where: {
sessionId,
id: {
in: items.map((item) => item.itemId),
},
},
select: { id: true },
});
const validItemIds = new Set(existingItems.map((item) => item.id));
const invalidItem = items.find((item) => !validItemIds.has(item.itemId));
if (invalidItem) {
throw new BadRequestException(`Flyer-rad ${invalidItem.itemId} tillhör inte sessionen.`);
}
const existingSelections = await this.prisma.flyerSelection.findMany({
where: {
sessionId,
itemId: { in: items.map((item) => item.itemId) },
},
select: { itemId: true },
});
const existingItemIds = new Set(existingSelections.map((item) => item.itemId));
await this.prisma.$transaction(async (tx) => {
const toCreate = items.filter((item) => !existingItemIds.has(item.itemId));
if (toCreate.length > 0) {
await tx.flyerSelection.createMany({
data: toCreate.map((item) => ({
sessionId,
itemId: item.itemId,
userId,
plannedQuantity:
item.plannedQuantity == null ? null : new Prisma.Decimal(item.plannedQuantity),
plannedUnit: item.plannedUnit ?? null,
priority: item.priority ?? 'normal',
note: item.note ?? null,
})),
});
}
const toUpdate = items.filter((item) => existingItemIds.has(item.itemId));
await Promise.all(
toUpdate.map((item) =>
tx.flyerSelection.update({
where: {
sessionId_itemId: {
sessionId,
itemId: item.itemId,
},
},
data: {
plannedQuantity:
item.plannedQuantity == null ? undefined : new Prisma.Decimal(item.plannedQuantity),
plannedUnit: item.plannedUnit,
priority: item.priority ?? 'normal',
note: item.note,
},
}),
),
);
});
const result = await this.prisma.flyerSelection.findMany({
where: {
sessionId,
userId,
itemId: { in: items.map((item) => item.itemId) },
},
include: {
item: {
select: {
id: true,
rawName: true,
normalizedName: true,
price: true,
priceUnit: true,
matchedProductId: true,
matchedProductName: true,
},
},
},
orderBy: { createdAt: 'desc' },
});
return result.map((row) => this.toResponse(row));
}
async update(
sessionId: number,
selectionId: number,
userId: number,
dto: UpdateFlyerSelectionDto,
): Promise<FlyerSelectionResponse> {
await this.assertSessionOwnership(sessionId, userId);
const existing = await this.prisma.flyerSelection.findUnique({
where: { id: selectionId },
select: { id: true, sessionId: true, userId: true },
});
if (!existing || existing.sessionId !== sessionId) {
throw new NotFoundException('FlyerSelection hittades inte.');
}
if (existing.userId !== userId) {
throw new ForbiddenException('Du saknar åtkomst till denna selection.');
}
const updated = await this.prisma.flyerSelection.update({
where: { id: selectionId },
data: {
plannedQuantity:
dto.plannedQuantity == null ? undefined : new Prisma.Decimal(dto.plannedQuantity),
plannedUnit: dto.plannedUnit,
priority: dto.priority,
status: dto.status,
note: dto.note,
},
include: {
item: {
select: {
id: true,
rawName: true,
normalizedName: true,
price: true,
priceUnit: true,
matchedProductId: true,
matchedProductName: true,
},
},
},
});
return this.toResponse(updated);
}
async remove(sessionId: number, selectionId: number, userId: number): Promise<void> {
await this.assertSessionOwnership(sessionId, userId);
const existing = await this.prisma.flyerSelection.findUnique({
where: { id: selectionId },
select: { id: true, sessionId: true, userId: true },
});
if (!existing || existing.sessionId !== sessionId) {
throw new NotFoundException('FlyerSelection hittades inte.');
}
if (existing.userId !== userId) {
throw new ForbiddenException('Du saknar åtkomst till denna selection.');
}
await this.prisma.flyerSelection.delete({ where: { id: selectionId } });
}
async listOpen(userId: number, weekKey?: string): Promise<FlyerSelectionResponse[]> {
const rows = await this.prisma.flyerSelection.findMany({
where: {
userId,
status: 'planned',
session: {
...(weekKey ? { weekKey } : {}),
},
},
include: {
item: {
select: {
id: true,
rawName: true,
normalizedName: true,
price: true,
priceUnit: true,
matchedProductId: true,
matchedProductName: true,
},
},
},
orderBy: { createdAt: 'desc' },
});
return rows.map((row) => this.toResponse(row));
}
async planToShoppingList(
sessionId: number,
userId: number,
itemIds?: number[],
): Promise<{ created: number; updated: number; processedSelectionIds: number[] }> {
await this.assertSessionOwnership(sessionId, userId);
return this.shoppingListService.upsertFromFlyerSelections(sessionId, userId, itemIds);
}
async previewReceiptMatches(userId: number, dto: ReceiptMatchDto): Promise<ReceiptMatchPreviewResponse> {
const candidates = await this.loadCandidateSelections(userId, dto.sessionId, dto.weekKey);
const rows = this.matcher.matchRows(dto.items, candidates);
return this.matcher.toPreviewResponse(rows, candidates.length);
}
async commitReceiptMatches(userId: number, dto: ReceiptMatchDto): Promise<ReceiptMatchCommitResponse> {
const candidates = await this.loadCandidateSelections(userId, dto.sessionId, dto.weekKey);
const previewRows = this.matcher.matchRows(dto.items, candidates);
const overrideByIndex = new Map((dto.overrides ?? []).map((o) => [o.rowIndex, o.selectionId]));
const candidateBySelectionId = new Map(candidates.map((c) => [c.id, c]));
const usedSelectionIds = new Set<number>();
const toUpdateSelectionIds: number[] = [];
for (const row of previewRows) {
if (row.status === 'auto' && row.selectionId != null && !usedSelectionIds.has(row.selectionId)) {
usedSelectionIds.add(row.selectionId);
toUpdateSelectionIds.push(row.selectionId);
continue;
}
const overrideSelectionId = overrideByIndex.get(row.rowIndex);
if (!overrideSelectionId || usedSelectionIds.has(overrideSelectionId)) {
continue;
}
if (!candidateBySelectionId.has(overrideSelectionId)) {
continue;
}
usedSelectionIds.add(overrideSelectionId);
toUpdateSelectionIds.push(overrideSelectionId);
}
if (toUpdateSelectionIds.length > 0) {
await this.prisma.flyerSelection.updateMany({
where: {
id: { in: toUpdateSelectionIds },
userId,
status: 'planned',
},
data: {
status: 'bought',
},
});
}
const summary = this.matcher.toPreviewResponse(previewRows, candidates.length);
return {
boughtCount: toUpdateSelectionIds.length,
ambiguousCount: summary.ambiguousCount,
unmatchedCount: summary.unmatchedCount,
updatedSelectionIds: toUpdateSelectionIds,
};
}
private async assertSessionOwnership(sessionId: number, userId: number): Promise<void> {
const session = await this.prisma.flyerSession.findUnique({
where: { id: sessionId },
select: { id: true, userId: true },
});
if (!session) {
throw new NotFoundException('FlyerSession hittades inte.');
}
if (session.userId !== userId) {
throw new ForbiddenException('Du saknar åtkomst till denna session.');
}
}
private async loadCandidateSelections(
userId: number,
sessionId?: number,
weekKey?: string,
): Promise<CandidateSelection[]> {
if (sessionId != null && weekKey != null) {
throw new BadRequestException('Ange antingen sessionId eller weekKey, inte båda.');
}
return this.prisma.flyerSelection.findMany({
where: {
userId,
status: 'planned',
...(sessionId != null ? { sessionId } : {}),
...(weekKey ? { session: { weekKey } } : {}),
},
select: {
id: true,
sessionId: true,
itemId: true,
plannedQuantity: true,
plannedUnit: true,
item: {
select: {
rawName: true,
normalizedName: true,
matchedProductId: true,
matchedProductName: true,
},
},
},
orderBy: { createdAt: 'desc' },
});
}
private toResponse(row: any): FlyerSelectionResponse {
return {
id: row.id,
sessionId: row.sessionId,
itemId: row.itemId,
userId: row.userId,
plannedQuantity: row.plannedQuantity == null ? null : Number(row.plannedQuantity),
plannedUnit: row.plannedUnit ?? null,
priority: row.priority,
note: row.note ?? null,
status: row.status,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
item: {
id: row.item.id,
rawName: row.item.rawName,
normalizedName: row.item.normalizedName,
price: row.item.price == null ? null : Number(row.item.price),
priceUnit: row.item.priceUnit ?? null,
matchedProductId: row.item.matchedProductId ?? null,
matchedProductName: row.item.matchedProductName ?? null,
},
};
}
}
@@ -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;
}
@@ -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);
}
}
@@ -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 {}
@@ -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;
}
}
@@ -0,0 +1,36 @@
import { CategoryResolverService } from './category-resolver.service';
describe('CategoryResolverService', () => {
const service = new CategoryResolverService();
const categories = [
{ id: 1, name: 'Kött, chark & fågel', path: 'Kött, chark & fågel' },
{ id: 2, name: 'Kött', path: 'Kött, chark & fågel > Kött' },
{ id: 3, name: 'Fläsk', path: 'Kött, chark & fågel > Kött > Fläsk' },
{ id: 4, name: 'Bröd', path: 'Bröd & kakor > Bröd' },
];
it('resolves Fläskytterfilé to pork category', () => {
const categoryId = service.resolveForFlyer({
categories,
signalText: 'Fläskytterfilé Sverige',
categoryHint: null,
matchedProductCategoryId: null,
matchConfidence: 0,
});
expect(categoryId).toBe(3);
});
it('prefers matched product category when confidence is high', () => {
const categoryId = service.resolveForFlyer({
categories,
signalText: 'Något annat',
categoryHint: 'Bröd',
matchedProductCategoryId: 99,
matchConfidence: 0.95,
});
expect(categoryId).toBe(99);
});
});
@@ -0,0 +1,114 @@
import { Injectable } from '@nestjs/common';
import { FlatCategory } from '../categories/categories.service';
type ResolveFlyerCategoryParams = {
categories: FlatCategory[];
signalText: string;
categoryHint: string | null;
matchedProductCategoryId: number | null;
matchConfidence: number;
};
@Injectable()
export class CategoryResolverService {
resolveForFlyer(params: ResolveFlyerCategoryParams): number | null {
if (params.matchedProductCategoryId != null && params.matchConfidence >= 0.9) {
return params.matchedProductCategoryId;
}
const normalizedSignal = normalizeForRules(params.signalText);
if (hasPorkLikeSignal(normalizedSignal)) {
const pork = this.resolvePorkCategory(params.categories);
if (pork) return pork.id;
}
if (hasBreadLikeSignal(normalizedSignal)) {
const bread = this.resolveBreadCategory(params.categories);
if (bread) return bread.id;
}
if (!params.categoryHint) return null;
return this.resolveByHint(params.categories, params.categoryHint)?.id ?? null;
}
private resolveByHint(categories: FlatCategory[], categoryHint: string): FlatCategory | undefined {
const normalizedHint = normalizeForRules(categoryHint);
return categories.find((category) => {
const normalizedName = normalizeForRules(category.name);
const normalizedPath = normalizeForRules(category.path);
return normalizedName === normalizedHint || normalizedPath === normalizedHint;
});
}
private resolvePorkCategory(categories: FlatCategory[]): FlatCategory | undefined {
return (
categories.find(
(category) =>
category.name.toLowerCase() === 'fläsk' &&
category.path.toLowerCase().startsWith('kött, chark & fågel > kött > '),
) ||
categories.find(
(category) =>
category.name.toLowerCase() === 'kött' &&
category.path.toLowerCase() === 'kött, chark & fågel > kött',
) ||
categories.find((category) => category.path.toLowerCase() === 'kött, chark & fågel')
);
}
private resolveBreadCategory(categories: FlatCategory[]): FlatCategory | undefined {
return (
categories.find(
(category) =>
category.name.toLowerCase() === 'rostbröd' &&
category.path.toLowerCase().startsWith('bröd & kakor > bröd > '),
) ||
categories.find(
(category) =>
category.name.toLowerCase() === 'bröd' &&
category.path.toLowerCase() === 'bröd & kakor > bröd',
) ||
categories.find((category) => category.path.toLowerCase() === 'bröd & kakor')
);
}
}
function normalizeForRules(value: string): string {
return value
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, ' ')
.trim();
}
function hasPorkLikeSignal(normalized: string): boolean {
return (
normalized.includes('bacon') ||
normalized.includes('sidflask') ||
normalized.includes('pancetta') ||
normalized.includes('flask') ||
normalized.includes('flaskytterfile') ||
normalized.includes('ytterfile') ||
normalized.includes('karre') ||
normalized.includes('kotlett')
);
}
function hasBreadLikeSignal(normalized: string): boolean {
return (
/\brostbrod\b/.test(normalized) ||
/\brost\s*n\s*toast\b/.test(normalized) ||
/\broast\s*n\s*toast\b/.test(normalized) ||
/\btoastbrod\b/.test(normalized) ||
/\bformbrod\b/.test(normalized) ||
/\blantbrod\b/.test(normalized) ||
/\bfullkornsbrod\b/.test(normalized) ||
/\bfranska\b/.test(normalized) ||
/\blimpa\b/.test(normalized) ||
/\bbrod\b/.test(normalized) ||
/\btoast\b/.test(normalized)
);
}
@@ -0,0 +1,15 @@
export function buildDisplayNameDetailed(params: {
rawName: string;
isBundle: boolean;
bundleItems: string[];
}): string {
const rawName = params.rawName.trim();
if (!params.isBundle) return rawName;
const items = params.bundleItems
.map((item) => item.trim())
.filter((item) => item.length > 0);
if (items.length === 0) return rawName;
return `${rawName} (${items.join(' + ')})`;
}
@@ -0,0 +1,38 @@
export type ImportedItemSignals = {
originCountries: string[];
labels: string[];
qualityFlags: string[];
variant: string | null;
packaging: string | null;
};
export type ImportedItemCandidate = {
rawName: string;
normalizedName: string;
brand: string | null;
weight: string | null;
bundleWeight: string | null;
isBundle: boolean;
bundleItems: string[];
price: number | null;
priceUnit: string | null;
comparisonPrice: number | null;
comparisonUnit: string | null;
categoryHint: string | null;
categoryId: number | null;
matchedProductId: number | null;
matchedProductName: string | null;
matchedVia: string;
matchConfidence: number;
matchReasons: string[];
signals: ImportedItemSignals | null;
displayNameDetailed: string | null;
};
export const EMPTY_IMPORTED_SIGNALS: ImportedItemSignals = {
originCountries: [],
labels: [],
qualityFlags: [],
variant: null,
packaging: null,
};
@@ -0,0 +1,45 @@
import { buildDisplayNameDetailed } from './import-display-name.util';
import { extractImportSignals } from './import-signals.util';
describe('import signals utilities', () => {
it('extracts deterministic origin and eco labels', () => {
const result = extractImportSignals({
rawName: 'Fläskytterfilé (Sverige) EKO',
brand: 'Garant',
offerText: 'Ekologiskt kött från Sverige',
});
expect(result.signals.originCountries).toEqual(['Sverige']);
expect(result.signals.labels).toContain('Ekologisk');
expect(result.signals.qualityFlags).toContain('eco');
expect(result.normalizedMatchName).toBe('flaskytterfile');
});
it('extracts Germany and keeps labels deterministic', () => {
const result = extractImportSignals({
rawName: 'Korv från Tyskland',
offerText: 'Tysk kvalitet',
});
expect(result.signals.originCountries).toEqual(['Tyskland']);
expect(result.signals.labels).toEqual([]);
});
it('builds detailed display name for bundle rows', () => {
expect(
buildDisplayNameDetailed({
rawName: 'Kaptenens Favoriter',
isBundle: true,
bundleItems: ['Chumlax 3x100g', 'Alaska pollock 3x100g'],
}),
).toBe('Kaptenens Favoriter (Chumlax 3x100g + Alaska pollock 3x100g)');
});
it('extracts storpack packaging signal', () => {
const result = extractImportSignals({
rawName: 'Kycklingfilé storpack',
});
expect(result.signals.packaging).toBe('storpack');
});
});
@@ -0,0 +1,103 @@
import { normalizeName } from '../common/utils/normalize-name';
import {
EMPTY_IMPORTED_SIGNALS,
ImportedItemSignals,
} from './import-item.types';
type SignalExtractionInput = {
rawName: string;
brand?: string | null;
offerText?: string | null;
};
const ORIGIN_COUNTRY_PATTERNS: Array<{ label: string; regex: RegExp }> = [
{ label: 'Sverige', regex: /\b(sverige|svensk(t|a)?|sweden)\b/i },
{ label: 'Tyskland', regex: /\b(tyskland|tysk(t|a)?|germany|deutschland)\b/i },
{ label: 'Norge', regex: /\b(norge|norsk(t|a)?)\b/i },
{ label: 'Danmark', regex: /\b(danmark|dansk(t|a)?)\b/i },
{ label: 'Finland', regex: /\b(finland|finsk(t|a)?)\b/i },
];
const LABEL_PATTERNS: Array<{ label: string; qualityFlag: string | null; regex: RegExp }> = [
{ label: 'Ekologisk', qualityFlag: 'eco', regex: /\b(eko|ekologisk(t|a)?|organic)\b/i },
{ label: 'Laktosfri', qualityFlag: 'lactose_free', regex: /\b(laktosfri(tt|a)?|lactose\s*free)\b/i },
{ label: 'Glutenfri', qualityFlag: 'gluten_free', regex: /\b(glutenfri(tt|a)?|gluten\s*free)\b/i },
{ label: 'Vegansk', qualityFlag: 'vegan', regex: /\b(vegansk(t|a)?|vegan)\b/i },
{ label: 'Vegetarisk', qualityFlag: 'vegetarian', regex: /\b(vegetarisk(t|a)?|vegetarian)\b/i },
];
export type SignalExtractionResult = {
signals: ImportedItemSignals;
normalizedMatchName: string;
};
export function extractImportSignals(input: SignalExtractionInput): SignalExtractionResult {
const text = [input.rawName, input.brand ?? '', input.offerText ?? '']
.filter((part) => part.trim().length > 0)
.join(' ');
const origins = ORIGIN_COUNTRY_PATTERNS
.filter((pattern) => pattern.regex.test(text))
.map((pattern) => pattern.label);
const labels = LABEL_PATTERNS
.filter((pattern) => pattern.regex.test(text))
.map((pattern) => pattern.label);
const qualityFlags = LABEL_PATTERNS
.filter((pattern) => pattern.qualityFlag && pattern.regex.test(text))
.map((pattern) => pattern.qualityFlag as string);
const packaging = resolvePackaging(text);
const variant = extractVariant(input.rawName);
const signals: ImportedItemSignals = {
...EMPTY_IMPORTED_SIGNALS,
originCountries: Array.from(new Set(origins)),
labels: Array.from(new Set(labels)),
qualityFlags: Array.from(new Set(qualityFlags)),
variant,
packaging,
};
const normalizedMatchName = normalizeForMatching(input.rawName);
return { signals, normalizedMatchName };
}
export function normalizeForMatching(rawName: string): string {
let cleaned = rawName;
for (const pattern of ORIGIN_COUNTRY_PATTERNS) {
cleaned = cleaned.replace(pattern.regex, ' ');
}
for (const pattern of LABEL_PATTERNS) {
cleaned = cleaned.replace(pattern.regex, ' ');
}
cleaned = cleaned.replace(/[()\[\]]/g, ' ');
cleaned = cleaned.replace(/\s+/g, ' ').trim();
return normalizeName(cleaned) || normalizeName(rawName);
}
function resolvePackaging(text: string): string | null {
const normalized = text.toLowerCase();
if (/\b\d+\s*[x×]\s*\d+\s*(g|kg|ml|cl|dl|l)\b/.test(normalized)) {
return 'multipack';
}
if (/\bstorpack\b/.test(normalized)) {
return 'storpack';
}
if (/\b(2-pack|3-pack|4-pack|5-pack|6-pack|pack)\b/.test(normalized)) {
return 'pack';
}
return null;
}
function extractVariant(rawName: string): string | null {
const variantMatch = rawName.match(/\(([^)]+)\)/);
if (!variantMatch) return null;
const value = variantMatch[1].trim();
return value.length > 0 ? value : null;
}
@@ -36,6 +36,9 @@ export class CreateInventoryDto {
@IsString()
origin?: string;
@IsOptional()
originCountries?: string[];
@IsOptional()
@IsString()
receiptName?: string;
@@ -35,6 +35,13 @@ export class UpdateInventoryDto {
@IsString()
brand?: string;
@IsOptional()
@IsString()
origin?: string;
@IsOptional()
originCountries?: string[];
@IsOptional()
@IsString()
receiptName?: string;
@@ -91,6 +91,7 @@ export class InventoryService {
location: data.location?.trim() || undefined,
brand: data.brand?.trim() || undefined,
origin: data.origin?.trim() || undefined,
originCountries: data.originCountries || undefined,
receiptName: data.receiptName?.trim() || undefined,
suitableFor: data.suitableFor?.trim() || undefined,
comment: data.comment?.trim() || undefined,
@@ -128,6 +129,14 @@ export class InventoryService {
updateData.brand = data.brand.trim();
}
if (typeof data.origin === 'string') {
updateData.origin = data.origin.trim();
}
if (Array.isArray(data.originCountries)) {
updateData.originCountries = data.originCountries;
}
if (typeof data.receiptName === 'string') {
updateData.receiptName = data.receiptName.trim();
}
+27 -2
View File
@@ -18,8 +18,29 @@ export class PrismaService
'deleteMany',
]);
private static isTruthy(value?: string): boolean {
if (!value) {
return false;
}
const normalized = value.trim().toLowerCase();
return normalized === '1' || normalized === 'true' || normalized === 'yes' || normalized === 'on';
}
private static resolveLogConfig(): Prisma.LogLevel[] {
const includeQueryLogs = PrismaService.isTruthy(process.env.PRISMA_LOG_QUERIES);
if (includeQueryLogs) {
return ['query', 'warn', 'error'];
}
return ['warn', 'error'];
}
constructor(private readonly realtimeEvents: RealtimeEventsService) {
super();
super({
log: PrismaService.resolveLogConfig(),
});
const realtimeMiddleware: Prisma.Middleware = async (params, next) => {
const result = await next(params);
@@ -46,6 +67,10 @@ export class PrismaService
await this.$connect();
if (PrismaService.isTruthy(process.env.PRISMA_LOG_QUERIES)) {
this.logger.log('Prisma query logging är aktiverad (PRISMA_LOG_QUERIES=1).');
}
this.logger.log('Databasanslutning etablerad.');
return;
} catch (error) {
@@ -71,4 +96,4 @@ export class PrismaService
async onModuleDestroy() {
await this.$disconnect();
}
}
}
+7 -1
View File
@@ -176,6 +176,12 @@ export class ProductsController {
return this.productsService.updateCategoryMine(req.user.id, id, body.categoryId);
}
@Post('mine/backfill-categories')
@HttpCode(200)
backfillCategoriesMine(@Request() req: { user: { id: number } }) {
return this.productsService.backfillCategoriesMine(req.user.id);
}
@Roles('admin')
@Post('merge')
merge(@Body() body: MergeProductsDto) {
@@ -267,4 +273,4 @@ export class ProductsController {
bulkUpdate(@Body() body: BulkUpdateProductsDto) {
return this.productsService.bulkUpdate(body.ids, { categoryId: body.categoryId });
}
}
}
+64 -1
View File
@@ -664,4 +664,67 @@ export class ProductsService {
select: { id: true, categoryId: true },
});
}
}
async backfillCategoriesMine(userId: number): Promise<{ updated: number; fallbackToOvrigt: number }> {
const [categories, products] = await Promise.all([
this.categoriesService.findFlattened(),
this.prisma.product.findMany({
where: {
ownerId: userId,
isActive: true,
categoryId: null,
},
select: {
id: true,
name: true,
canonicalName: true,
},
}),
]);
if (products.length === 0) {
return { updated: 0, fallbackToOvrigt: 0 };
}
const fallback =
categories.find((category) => category.path.toLowerCase().endsWith(' > övrigt'))
?? categories.find((category) => category.name.toLowerCase() === 'övrigt')
?? categories[0];
let updated = 0;
let fallbackToOvrigt = 0;
for (const product of products) {
let targetCategoryId = fallback?.id;
let usedFallback = true;
try {
const suggestion = await this.aiService.suggestCategory(
product.canonicalName ?? product.name,
categories,
);
if (suggestion?.categoryId) {
targetCategoryId = suggestion.categoryId;
usedFallback = suggestion.usedFallback === true;
}
} catch {
usedFallback = true;
}
if (!targetCategoryId) {
continue;
}
await this.prisma.product.update({
where: { id: product.id },
data: { categoryId: targetCategoryId },
});
updated += 1;
if (usedFallback) {
fallbackToOvrigt += 1;
}
}
return { updated, fallbackToOvrigt };
}
}
@@ -5,13 +5,15 @@ import {
INestApplication,
ValidationPipe,
} from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import request = require('supertest');
import { Test, TestingModule } from '@nestjs/testing';
import request = require('supertest');
import { AiService } from '../ai/ai.service';
import { CategoriesService } from '../categories/categories.service';
import { ProductsController } from './products.controller';
import { ProductsService } from './products.service';
import { ProductsController } from './products.controller';
import { ProductsService } from './products.service';
jest.setTimeout(15000);
class FakeJwtGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
@@ -0,0 +1,115 @@
import { BadRequestException, ServiceUnavailableException } from '@nestjs/common';
import { QuickImportService } from './quick-import.service';
jest.mock('../common/utils/download-image', () => ({
downloadAndOptimizeImage: jest.fn(),
}));
const { downloadAndOptimizeImage } = jest.requireMock('../common/utils/download-image') as {
downloadAndOptimizeImage: jest.Mock;
};
describe('QuickImportService flow', () => {
let service: QuickImportService;
beforeEach(() => {
jest.clearAllMocks();
service = new QuickImportService();
});
it('importFromInput: delegerar till importer och laddar ner extern bild lokalt', async () => {
(global as any).fetch = jest.fn().mockResolvedValue({
ok: true,
json: async () => ({
markdown: '# Lasagne',
source: 'other',
imageUrl: 'https://cdn.example.com/lasagne.jpg',
}),
});
downloadAndOptimizeImage.mockResolvedValue('/app/recipe-images/lasagne.jpg');
const result = await service.importFromInput('https://www.ica.se/recept/lasagne');
expect((global as any).fetch).toHaveBeenCalledTimes(1);
expect((global as any).fetch).toHaveBeenCalledWith(
expect.stringContaining('/api/quick-import'),
expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
}),
);
expect(downloadAndOptimizeImage).toHaveBeenCalledWith(
'https://cdn.example.com/lasagne.jpg',
expect.any(String),
);
expect(result.imageUrl).toBe('/app/recipe-images/lasagne.jpg');
});
it('importFromUpload: skickar form-data och behåller imageUrl när den redan är lokal', async () => {
(global as any).fetch = jest.fn().mockResolvedValue({
ok: true,
json: async () => ({
markdown: '# Köttfärssås',
source: 'image',
imageUrl: '/app/recipe-images/local.jpg',
}),
});
const file = {
buffer: Buffer.from('img'),
mimetype: 'image/jpeg',
originalname: 'receipt.jpg',
} as any;
const result = await service.importFromUpload(file);
expect((global as any).fetch).toHaveBeenCalledTimes(1);
expect(downloadAndOptimizeImage).not.toHaveBeenCalled();
expect(result.imageUrl).toBe('/app/recipe-images/local.jpg');
});
it('importFromInput: kastar BadRequestException vid tom input', async () => {
await expect(service.importFromInput(' ')).rejects.toBeInstanceOf(BadRequestException);
});
it('importFromInput: mappar importer 4xx till BadRequestException', async () => {
(global as any).fetch = jest.fn().mockResolvedValue({
ok: false,
status: 400,
json: async () => ({ message: 'Ogiltig URL' }),
});
await expect(service.importFromInput('hej')).rejects.toBeInstanceOf(BadRequestException);
});
it('importFromUpload: mappar nätverksfel till ServiceUnavailableException', async () => {
(global as any).fetch = jest.fn().mockRejectedValue(new Error('ECONNREFUSED'));
const file = {
buffer: Buffer.from('img'),
mimetype: 'image/jpeg',
originalname: 'receipt.jpg',
} as any;
await expect(service.importFromUpload(file)).rejects.toBeInstanceOf(ServiceUnavailableException);
});
it('lägger imageWarning när bildnedladdning misslyckas', async () => {
(global as any).fetch = jest.fn().mockResolvedValue({
ok: true,
json: async () => ({
markdown: '# Recept',
source: 'other',
imageUrl: 'https://cdn.example.com/recept.jpg',
}),
});
downloadAndOptimizeImage.mockRejectedValue(new Error('timeout'));
const result = await service.importFromInput('https://example.com/recept');
expect(result.imageUrl).toBe('https://cdn.example.com/recept.jpg');
expect(result.imageWarning).toContain('Receptbild kunde inte laddas ner lokalt');
});
});
@@ -0,0 +1,16 @@
import { IsBoolean, IsInt, IsOptional, IsString, MinLength } from 'class-validator';
export class UpdateReceiptAliasDto {
@IsOptional()
@IsString()
@MinLength(1)
receiptName?: string;
@IsOptional()
@IsInt()
productId?: number;
@IsOptional()
@IsBoolean()
isGlobal?: boolean;
}
@@ -1,6 +1,16 @@
import { Body, Controller, Delete, Get, Param, ParseIntPipe, Post } from '@nestjs/common';
import {
Body,
Controller,
Delete,
Get,
Param,
ParseIntPipe,
Patch,
Post,
} from '@nestjs/common';
import { ReceiptAliasService } from './receipt-alias.service';
import { CreateReceiptAliasDto } from './dto/create-receipt-alias.dto';
import { UpdateReceiptAliasDto } from './dto/update-receipt-alias.dto';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
@Controller('receipt-aliases')
@@ -20,6 +30,15 @@ export class ReceiptAliasController {
return this.receiptAliasService.upsert(dto, user.userId, user.role);
}
@Patch(':id')
update(
@Param('id', ParseIntPipe) id: number,
@Body() dto: UpdateReceiptAliasDto,
@CurrentUser() user: { userId: number; role: string },
) {
return this.receiptAliasService.update(id, dto, user.userId, user.role);
}
@Delete(':id')
remove(
@Param('id', ParseIntPipe) id: number,

Some files were not shown because too many files have changed in this diff Show More