Files
recipe-app/TEKNISK_BESKRIVNING.md
T
Nils-Johan Gynther 3f242f9a6d
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
docs: add Prisma query logging configuration guide
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

112 KiB

Migrering: Import-funktion → microservice-importer (GENOMFÖRD 2026-04-30)

Recipe-apps importflöde (quick-import, parse-markdown, receipt-import) är nu migrerat till en separat microservice-importer:

  • All URL-skrapning, OCR, PDF-parsning och AI-kvittoparsning sker i microservice-importer (NestJS, Docker, port 3001 internt).
  • Backend-till-backend-integration: recipe-app anropar microservice-importer via HTTP, frontend är oförändrad.
  • OCR via tesseract.js och Alpine-paket.
  • Compose-länkning och healthchecks på plats.
  • Bildoptimering sker fortfarande i recipe-app vid sparande.
  • Lessons learned: Separat import-microservice ger enklare scaling, robustare deploy och tydligare ansvarsfördelning.

Verifiering:

  • Alla endpoints testade (ICA-URL, PDF, bild, markdown, kvitto) och fungerar.
  • Auth och proxy-routes oförändrade.

Se även: README.md för användarflöde, och AI-FUNKTIONER.md för AI-detaljer.

Prisma-migreringar: P3009 recovery och lessons learned

Drift och deploy (2026-05-11)

  • Flutter build-artifacts: Byggda filer i flutter/build/ och .flutter-plugins-dependencies ska inte versionshanteras. Vid deploy på server: kör git restore flutter/build flutter/.flutter-plugins-dependencies och git clean -fd flutter/build innan git pull.
  • Vanliga fel: Om du får felmeddelandet "Your local changes to the following files would be overwritten by merge", beror det på att genererade filer är modifierade lokalt. Se till att alltid rensa dessa innan uppdatering.

Problem: Prisma migrationer kan fastna i failed state (P3009) om en migration körts med fel SQL-citering (t.ex. "User" istället för User i MySQL) eller om deploy avbryts mitt i en migrering.

Symptom:

migrate found failed migrations in the target database, new migrations will not be applied. The `20260506144000_add_ai_engine_enabled` migration ... failed

Lösning/playbook:

  1. Rätta migrationsfilen så att den använder backticks (`) för tabell- och kolumnnamn (MySQL-stil).
  2. Kör:
docker exec recipe-api sh -lc "cd /app && npx prisma migrate resolve --rolled-back 20260506144000_add_ai_engine_enabled --schema prisma/schema.prisma"
docker exec recipe-api sh -lc "cd /app && npx prisma migrate deploy --schema prisma/schema.prisma"
  1. Om deploy klagar på duplicate column (dvs kolumnen finns redan):
docker exec recipe-api sh -lc "cd /app && npx prisma migrate resolve --applied 20260506144000_add_ai_engine_enabled --schema prisma/schema.prisma"
docker exec recipe-api sh -lc "cd /app && npx prisma migrate deploy --schema prisma/schema.prisma"
  1. Verifiera status:
docker exec recipe-api sh -lc "cd /app && npx prisma migrate status --schema prisma/schema.prisma"

Lessons learned:

  • Kontrollera alltid SQL-citering i migrationsfiler (MySQL kräver backticks, inte dubbla citattecken).
  • Vid P3009: använd migrate resolve för att markera migrationen som rolled-back eller applied beroende på DB-läge.
  • Kör alltid migrationskommandon i rätt container/miljö för att undvika env- och version-mismatch.

Session 2026-05-06: User-scoped AI-fallback, admin-toggles och refaktor

Denna session har genomfört en omfattande refaktor och utbyggnad av AI-funktionalitet i Recipe App, med fokus på:

  • User-scoped AI-fallback: AI-förslag för ingrediensmatchning (receptimport) och kategorimatchning (kvittoimport) är nu individuellt aktiverbara per användare. Endast användare med premium-tillgång (adminstyrd toggle) får AI-hints vid import.
  • Admin-toggles: Ny endpoint och backendlogik för att admin ska kunna slå på/av AI-funktioner per användare. Prisma-schema utökat med aiEngineEnabled på User, migration och endpoint (PATCH /users/:id/ai-engine).
  • Premium-scope: Flutter och backend respekterar nu premium-flagga och AI-tillgång i alla relevanta flöden. Endast premiumanvändare får AI-förslag i UI.
  • Rematch och manuell produkt: Flutter har fått stöd för att trigga ommatchning (rematch) av ingredienser och för att skapa produkter manuellt direkt från importgranskningen.
  • Arkitektur: All AI-fallback är fallback-first och aldrig blockerande för import eller sparande. Backend och frontend är strikt separerade vad gäller analys, import och matchning.
  • Lessons learned:
    • Nullable propagation i Prisma och DTO:er kräver noggrannhet för att undvika runtime-fel.
    • Fallback-first AI och tydlig separation av analyskontrakt ger robustare flöden och bättre UX.
    • User-scoped features kräver att all produkt- och matchningslogik filtrerar på ownerId/userId.
    • Admin-toggles och premium-scope måste vara synkade i backend, UI och auth.
    • Manuella migrationer kan krävas vid DB-connectivity-problem (se migrering-MSI.md).

Se även:

Nyheter och förbättringar (2026-05-10)

  • Admin-inventarie: Backend och Flutter har nu fullständigt stöd för admin att hantera, filtrera, sortera, skapa, redigera, slå ihop och ta bort poster i den globala inventarietabellen. Endast admin har tillgång till dessa endpoints och UI-flöden.
  • User-scope och IDOR-skydd: Inventory och produkter är nu strikt user-scopade. Alla operationer kräver och filtrerar på userId. Tester verifierar att åtkomst nekas vid försök till IDOR.
  • Säkerhetshärdning: DTO-validering, guard-ordning, logging, throttling, merge abuse-skydd, och rollbaserad access är implementerat och testat.
  • Optimeringar: DRY i service-lager, striktare query parsing, preview-cache, API-cleanup, och kodduplication eliminerad.
  • Testtäckning: Utökade enhets-, integrations- och säkerhetstester för alla kritiska flöden.

Teknisk beskrivning av Recipe App

Se README.md för användarinformation och kom-igång-guide.
Se NEXT_STEPS.md för förslag på nästa steg i projektet.
Se _archive/microservice-ai/AI-FUNKTIONER.md för planerade AI-funktioner och modellval.

Ytterligare förbättringar (2026-05-11)

  • Produktomkategorisering (user-scope):

    • Ny endpoint PATCH /products/mine/:id/category för att användare ska kunna omkategorisera sina egna produkter (ej globala).
    • Backend-policy: endast produkter märkta med isPrivate: true och där ownerId === userId kan omkategoriseras. Globala produkter är låsta för ändring.
    • DTO-validering och ParseIntPipe skyddar mot ogiltiga id:n och payloads.
    • Service och controller har utökats med tydliga undantag och felhantering.
  • Säkerhetshärdning och testtäckning:

    • Forbidden-meddelanden från backend mappas nu via allowlist i Flutter för att undvika informationsläckage.
    • Utökad testtäckning: service-tester, controller-tester och HTTP endpoint-tester för alla grenar (inklusive ogiltig path-param, forbidden, valid/invalid payload).
    • Testerna verifierar att globala produkter inte kan ändras, att endast ägaren kan omkategorisera, och att felaktiga anrop returnerar rätt statuskod.
    • Testerna körs automatiskt i CI/CD och valideras även efter npm ci (lockfile-baserad installation).
  • Flutter/Frontend:

    • Produktpicker och inventory-flöden har förbättrats: picker kan öppnas även om listan är tom, och det går att skapa produkt direkt från picker.
    • Omkategorisering av produkt sker nu automatiskt vid kategoriändring i inventory-edit/create.
    • Deduplicerad logik för produktmutationer i helper-fil.
    • Error-mapping i Flutter tillåter nu backend-forbidden-meddelanden på allowlist, annars visas generiskt fel.
  • Seed-data och deploy:

    • Nya kategorier tillagda i seed (t.ex. Marmelad, Sylt, Mos, Korvbröd, Grädde).
    • Deploy-scriptet kör seed automatiskt och verifierar att kategoriträdet är uppdaterat.
    • Dokumentation för hur man verifierar seed och hanterar merge-konflikter vid deploy.
  • Kodkvalitet och serverkompatibilitet:

    • Inga absoluta Windows-sökvägar används i kodbasen (validerat via sökning).
    • Alla beroenden är låsta i package-lock.json och testade med Node >=14.18.
    • supertest används med CommonJS-import i tester för maximal kompatibilitet.
    • Testsviten (21 tester för produkter) är grön efter både lokal och serverliknande (npm ci) installation.
  • CI/CD och testinfrastruktur:

    • HTTP endpoint-tester täcker nu även felaktiga path-parametrar och validerar att service inte anropas vid 400.
    • Testerna körs automatiskt i pipeline och måste passera för att deploy ska ske.

Se även:

Dokumentstatus (2026-05-03)

Målgrupp

Detta dokument är skrivet för systemadministratörer och programmerare. Fokus är arkitektur, datamodell, integrationer och driftkonsekvenser.

Tillägg från senaste sessionerna

Nyheter och förbättringar (2026-05-11)

  • Inventarie och baslager: Möjlighet att se, sätta och ändra kategori på produkter direkt i inventarie- och baslagervyn. Identisk, sökbar/autocomplete category-picker i alla relevanta vyer (inventarie, baslager, admin, import). UX-standardisering: samma dropdown och interaktionsmönster överallt.

  • Seed-data: Nya kategorier under Skafferi > Sylt, mos & marmelad: Marmelad, Sylt, Mos tillagda i seed.

  • Navigation/UI: Admin-knappen flyttad från sidomenyn till profilmenyn ovanför logout.

  • Deploy och seed: Förtydligande om att seed-data körs automatiskt vid ./deploy.sh --backend och att seed-kontroll sker i deploy-scriptet. Hur man verifierar att seed körts och att nya kategorier finns.

  • Kodförenklingar/optimeringar: Samtliga tre förenklingar/optimeringar från senaste commit är nu implementerade (se SESSION_2026-05-09_RECEIPT_IMPORT.md för detaljer).

  • Kvittoimport, regelmotor: förbättrad tolkning av multipack och enheter i importerade kvittorader.

  • Kategori-guardrails: utökade regler för bröd/rostbröd samt contradiction guards för att minska felaktiga AI-träffar.

  • Kategori-seed: ny kategori Korvbröd under Fastfoodbröd och Grädde under Matlagning i seed-data.

  • Klientpersistens i Flutter: kvittoimportens arbetssession sparas i klienten och återläses vid behov.

  • Dataperspektiv: kvittosessionen lagras inte server-side; backend-kontraktet är oförändrat för den delen.

  • Kvittokategorisering: förbättrad deterministisk kategorisering med nya regler för pasta, grädde, ägg, juice, godis, och potatis. AI-guardrail justerad för att tillåta medium confidence-kategorisering.

  • Testinfrastruktur: parametriserade enhetstester för kvittoimport (18 testfall) och CI/CD-pipeline med automatiserad testkörning på push.

  • CI/CD: GitHub Actions-integration för automatiserad testkörning vid push och pull request.

  • Node.js versionsparitet: package-lock.json spåras i git för båda repos; Dockerfiles kör npm ci för reproducerbara byggen.

  • PDF-kvittoimport: pdf-parse importeras med require() (CJS); pdfjs-dist/legacy/build/pdf.js som fallback undviker DOMMatrix-fel.

  • Felkods-forwarding: receipt-import.service.ts kastar ServiceUnavailableException vid 503/429 från importer-api (tidigare alltid 400).

  • AI-skippning (PDF): looksLikeReceiptProductLine() i importer-api filtrerar rader utan siffra — minskar Mistral-anrop drastiskt för vanliga PDF-kvitton.

  • Simplified Matching Logic (2026-05-09): Unified matcher konsoliderar receipt-import matching och categorization. Tidigare var logiken splittrad mellan matchProducts() och enrichWithAiCategories(); nu är allt i matchAndEnrichReceiptItem() med explicit steg: Alias lookup → Word-match → Categorization (Rules → AI → Guards → Hard overrides). Bättre trace-logging och debuggability. Context-loading görs en gång per receipt (parallell loading) istället för repeated queries.

Driftnotering

Verifiera efter deploy att seed-körning inkluderar uppdaterat kategoriträd och att kvittoflödet använder den senaste regelbaserade parserlogiken.

Sakerhetsstatus (2026-05-07)

Denna sektion beskriver säkerhetsfunktioner som är implementerade i kodbasen och hur de är implementerade tekniskt.

Nytt 2026-05-07: Inventory user-scope, IDOR-skydd och deploy-hygien

  • Inventory är nu user-scopad: Alla inventory-operationer kräver och filtrerar på userId i backend (schema, migration, service, controller, tester).
  • IDOR-skydd för inventory: Det är nu omöjligt för användare att läsa eller ändra andras inventarieposter. Tester verifierar att åtkomst nekas vid försök till IDOR.
  • .gitignore och deploy-hygien: backend/dist och backend/tsconfig.tsbuildinfo ignoreras och är ej längre spårade i git. .env och .env.* ignoreras, men .env.example finns och är uppdaterad.
  • CI/CD-härdning: npm audit och prisma validate körs i pipeline. Alla tester och byggen måste passera.

Implementerat i backend

  • JWT-skydd globalt: JwtAuthGuard ar registrerad som global APP_GUARD i backend/src/app.module.ts. Alla endpoints kravs pa giltig bearer-token om de inte ar markerade med @Public().
  • Rollbaserad access: RolesGuard ar global guard och laser metadata fran @Roles(...). Endpoints med adminkrav blockerar icke-admin med ForbiddenException.
  • Rate limiting: ThrottlerGuard ar globalt aktiv med standard 120 anrop/minut. Login/register har hardare throttle (10/min) i auth.controller.ts.
  • Input-hardening: global ValidationPipe i backend/src/main.ts med whitelist: true, forbidNonWhitelisted: true, transform: true. Detta tar bort odefinierade falt och nekar payloads med extra fields.
  • Security headers: helmet(...) ar aktiverat i backend/src/main.ts med HSTS, x-frame-options, x-content-type-options, referrer policy och cross-origin policies.
  • CORS-begransning: origin satts via ALLOWED_ORIGIN (fallback host i kod) och credentials ar explicit styrda i main.ts.
  • Losenordshashning: bcryptjs med kostnad 12 i auth.service.ts (bcrypt.hash(password, 12)).
  • User-scope i flera domaner:
    • recipes: controllers/services skickar vidare @CurrentUser().userId och filtrerar pa agarskap.
    • pantry och meal-plan: alla las/skriv-operationer ar user-scopade via userId i service-lagret.
    • receipt-alias: las/skriv/radera styrs av owner/global-regler och aktuell anvandare.

Tidigare risk som nu ar stangd

  • Inventory user-scope ar nu genomford (schema, migration, controller, service och tester).
  • Tidigare IDOR-risk for inventory bedoms som stangd i nuvarande implementation.
  • Kvarvarande fokus ar regressionsskydd via tester och verifiering efter deploy.

Driftrelaterad hardening som finns

  • Secrets injiceras via miljo i compose.yml (t.ex. JWT_SECRET, DATABASE_URL, API-nycklar) och ar inte hardkodade i appkod.
  • Prisma migrationer kor vid containerstart och recovery-playbook for P3009 finns dokumenterad i detta dokument.

Översikt

Recipe App är en fullstack-applikation för hantering av hemmavaror, recept och matplanering. Systemet är byggt med Next.js (frontend), NestJS (backend), Prisma ORM och MariaDB. Applikationen är containeriserad med Docker och använder Caddy som reverse proxy.


Viktigt att komma ihåg vid implementering av nya funktioner och kodning är att inte använda windows sökvägar. Att inte använda c:/dev/recpie-app.... Detta eftersom bygg- och testmiljön är på en remote ubuntu-server. Utveckling sker lokalt och test samt drift sker på remote server. Säkerställ att inga absoluta Windows-sökvägar används i koden, för att stödja bygg och drift på Linux/Ubuntu


Nyheter och förbättringar (2026-05-02)

Nyheter och förbättringar (2026-05-07)

  • Alias-strategi och matchedVia:
    • Backend: ParsedReceiptItem har fått fältet matchedVia: 'alias' | 'wordmatch' | 'ai' | 'none' som sätts i matchningslogiken och returneras till klienten.
    • Flutter: matchedVia visas som badge i UI (Alias/Ordmatch/AI-kategori). Alias-lärande är öppet för alla användare (user-alias), admins kan spara globala alias.
    • Användare kan se och radera sina alias på profilsidan. Admin-panelen visar produkt-ID och tydligare radstruktur.
    • Prioritering: Egna alias (ownerId=userId) används före globala alias (isGlobal=true), därefter ordmatchning och sist AI.
    • Tester: 3 nya tester för matchedVia i backend, totalt 66 tester gröna.
  • Inventory user-scope och IDOR-skydd: InventoryItem-modellen har fått userId, migration har backfillat data, och alla endpoints kräver nu CurrentUser. Service och controller är uppdaterade, och tester för IDOR-skydd är på plats.
  • .gitignore och deploy-hygien: backend/dist och backend/tsconfig.tsbuildinfo är nu ignorerade och ej längre spårade. .env och .env.* ignoreras, .env.example är komplett.
  • CI/CD-härdning: npm audit och prisma validate körs i pipeline. Testsviten (66 tester) och build måste passera.

Alias-strategi och matchedVia (detaljer)

  • Datamodell:
    • ReceiptAlias har fälten ownerId (user-alias), isGlobal (global alias) och prioriteras i matchningslogik.
    • matchedVia sätts i backend och skickas till Flutter.
  • UI-flöde:
    • Vid kvittoimport visas badge för matchkälla.
    • "Spara som alias" är tillgängligt för alla användare (sparar user-alias), admins kan spara globalt.
    • Profilsidan har en alias-lista där användaren kan radera egna alias.
    • Admin-panelen visar alla globala alias, produktnamn och produkt-ID.
  • Prioriteringsordning:
    1. Egna alias (ownerId=userId)
    2. Globala alias (isGlobal=true)
    3. Ordmatchning
    4. AI-kategori

Produkttabellen är omgjord till ett fullständigt user-scope-modell. Beslutet grundar sig på att en global produktkatalog skapade falska matchningar i kvittoimport för nya användare (produkter "hittades" fast användaren aldrig lagt till dem).

Vad som ändrades:

Komponent Förändring
Product.ownerId Int?Int (obligatorisk, non-nullable)
Product.owner onDelete: SetNullonDelete: Cascade
db/seeds/seed_all.sql Innehåller nu enbart kategorier — inga INSERT INTO Product
Migration 20260502160000 Raderar alla globala produkter (ownerId IS NULL), gör FK non-null
receipt-import.service.ts matchProducts(items, userId) filtrerar på ownerId = userId
receipt-import.controller.ts Extraherar userId från JWT och skickar till service

Flöde för nya användare:

  1. Kvittoimport → AI/OCR parsar kvitto → inga produktmatcher (user har inga produkter ännu)
  2. Regelbaserad kategoridetektion + AI-kategorisering körs för alla rader
  3. Användaren bekräftar i Flutter → produkten skapas via POST /products med ownerId = userId
  4. Nästa kvittoimport med samma vara → alias/ordmatch hittar den user-ägda produkten

Framtida mallar (planerat): En mallhanterare i UI kan låta användare seeda sin produktkatalog från fördefinierade livsmedelsmallar utan att det kräver global data i databasen.

Kategorisystem utökat

Nya noder sedan 2026-05-01:

Nivå Kategori
L2 under Bröd & Kakor Kondis & fika
L3 under Kondis & fika Kaffebröd (wienerbröd, donuts, munkar, kanelbullar m.m.)
L2 under Dryck Te & choklad
L3 under Te & choklad Te (chai, vanilla chai, ceylon te m.m.)
L3 under Allergi mejeri Laktosfri mjölk
L3 under Allergi mejeri Filmjölk & Yoghurt
L3 under Allergi mejeri Kvarg & Cottage cheese
L3 under Allergi mejeri Matfett
L3 under Allergi mejeri Allergi matlagning

Regelbaserad kategoridetektion (ruleBasedCategorySuggestion)

Funktionen i receipt-import.service.ts matchar kvittonamn mot nyckelord och returnerar rätt kategori direkt — utan AI-anrop. Täcker:

  • Tete, tea, chai, tepas, tepak
  • Kaffebrödwienerbrod, donut, munk, croissant, kanelbulle, bakelse, semla, dammsugare, kladdkaka, muffin, cupcake, chokladboll
  • Allergi mejeri — kombinationer av mejeri-markörer + allergen/växtbaserade markörer

AI-guardrail

AiService.suggestCategory() remappar low/medium-konfidenspoäng till L1-föräldern istället för att returnera ett potentiellt fel L2/L3. Loggning sker via NestJS Logger.

Förbättrad produktmatchning och alias

matchProducts() i receipt-import.service.ts:

  • Returnerar nu även matchedVia för varje rad: 'alias', 'wordmatch', 'ai' eller 'none'.
  • Alias prioriteras enligt ovan.
  • Flutter visar badge och styr alias-lärande utifrån matchedVia.

findWordMatch() i receipt-import.service.ts:

  • DiakritiksnormaliseringnormalizeToken() konverterar å→a, ä→a, ö→o före jämförelse. Löser t.ex. gradde == grädde.
  • Enstaka lång partiell matchning — ett produktord på ≥5 tecken som är en delmatchning räcker nu som stark signal. Löser t.ex. "Vispgrädde 5dl" → produkt "grädde".

Nyheter och forbattringar (2026-05-04)

  • Receptimport parser centraliserad: parsinglogik for markdownrecept ar flyttad till en gemensam utility i backend (src/common/utils/recipe-parser.ts) och duplicerad parserkod i recipes-service ar borttagen.
  • Parser prestanda/robusthet: regex och enhetslista ar lyfta till modulniva, braktal och intervall (ex. ca 600-700 g) hanteras konsekvent, och ingrediensnamn med eller delas upp i alternativ.
  • Alternativa ingredienser (Option A i drift): RecipeIngredient har nu alternativeProductIds (JSON) for att lagra alternativa produktval per ingrediensrad.
  • Validering vid create/update av recept: backend validerar att valda produkter ar aktiva innan recept sparas.
  • Atomar uppdatering av ingredienser: update() for recept kor nu inom Prisma-transaction (delete + create/update i samma transaktion).
  • Sakrare felhantering vid bildimport: om receptskapande fallerar efter nedladdning av bild stadas orphan-filer bort.
  • Ownerless recept hardening: legacy-recept utan owner claimas av forsta redigerande anvandare; recept med annan owner fortsatter att vara blockerade for obehoriga.
  • Inventory preview med alternativ: lagerkoll kan rakna in alternativa produkter nar primar produkt saknas.
  • Testning: nya enhetstester for recipe-parser lagda; total testsvit verifierad gron (57 tester).

Prisma migration driftlarning (2026-05-04)

Vid deploy av migrationen 20260504220420_add_alternative_product_ids uppstod ett produktionsstopp med Prisma P3009/P3018. Rotorsaker var en tidigare failed migration samt ogiltig SQL-korning i miljo.

Praktisk recovery-sekvens som verifierades:

  1. Kor prisma migrate resolve --rolled-back <migration_name> i samma runtime-miljo som appen (containern), inte i ett shell utan korrekt DB-konfiguration.
  2. Verifiera DATABASE_URL i runtime (container) innan ny deploy.
  3. Kor prisma migrate deploy igen.
  4. Om schema redan ar uppdaterat men migrationshistorik halkat efter: markera migrationen som --applied och deploya pa nytt.

Driftrekommendation:

  • Kor migrationskommandon via app-containern i compose-stacken for att undvika skillnader i Prisma-version, env-laddning och DNS/hostnamn.

Nyheter och förbättringar (2026-04-30)

  • Microservice-importer integreradimporter-api körs nu som intern Docker-tjänst i recipe-app/compose.yml. All URL-skrapning, OCR, PDF-parsning och AI-kvittoparsning delegeras dit. recipe-api behåller Levenshtein-matchning, produktdatabas och AI-kategorisering. Se migrering-MSI.md för fullständig lista över ändrade filer.
  • Bygg-instruktion — Katalogen ../microservice-importer måste finnas sida vid sida med recipe-app på servern. ./deploy.sh bygger båda automatiskt.

Nyheter och förbättringar (2026-04-22)

  • User-scope för pantry och matplan — Prisma-schema, backend och API är migrerade så att alla baslager- och matplansdata är per användare. JWT används för filtrering i alla endpoints.
  • Robust bildimport — Bild-URL normaliseras, laddas ner och optimeras i backend. Fallback till extern URL om nedladdning misslyckas. Bilden kopplas till receptet och raderas vid delete. Diagnostikloggning på alla steg.
  • Importflöde — Quick-import och receipt-import har förbättrats med robust multipart-hantering, timeout, och felhantering. Markdown och bild-url skickas hela vägen till UI.
  • Flutter-parity — Matplan, inventarie, baslager och receptflöden är nu fullt migrerade till Flutter med user-scope och robust felhantering.
  • Felsökningslogg — Se IMPORT_IMAGE_DEBUG_2026-04-22.md för detaljerad felsökningshistorik kring bildimport och importflöde.

Kända begränsningar

  • Bildimport kraver att containrar ar uppdaterade med senaste kod och konfiguration.
  • Avancerad AI-integration (utokad audit/loggning och framtida modellstrategi) ar planerad post-MVP.

  • Privat git-server för denna applikation
  • Ändringar pushas lokalt till denna server:
    git push origin main
    

Bygge och körning på servern

Efter push till Gitea:

  1. SSH in på servern

    ssh user@server
    cd /opt/containers/recipe-app
    
  2. Hämta senaste ändringar

    git pull origin main
    
  3. Bygg och starta applikationen

    docker compose build
    docker compose up -d
    

Alla tjänster (frontend, backend, databas) startas via Docker Compose enligt compose.yml.

Rekommenderat kommandomonster

For att undvika forvirring mellan huvudappen och Flutter-sparat bor dessa kommandon anvandas konsekvent:

Huvudappen (Next.js + API + DB):

docker compose build recipe-frontend recipe-api
docker compose up -d recipe-db recipe-api recipe-frontend

Enbart backend:

docker compose build recipe-api
docker compose up -d recipe-db recipe-api

Flutter-sparat (separat klient):

docker compose -f compose.yml -f compose.flutter.yml build recipe-flutter
docker compose -f compose.yml -f compose.flutter.yml up -d --no-deps recipe-flutter

Tumregel:

  • compose.yml styr huvudappen pa recept.gynther.se.
  • compose.yml + compose.flutter.yml styr Flutter-klienten pa test.gynther.se.
  • Att bygga en image startar inte containern; docker compose up -d ... kravs alltid efter build.

Container- och deployupplägg

  • compose.yml bygger lokala images för frontend och backend
  • pull_policy: never används för appens lokala images för att undvika felaktiga registry-pulls i Portainer
  • Health checks finns för databas, API och frontend
  • depends_on med hälsovillkor används för stabilare startordning i Docker och Portainer
  • Fasta containernamn — alla tjänster har container_name satt i compose.yml, vilket ger förutsebara namn oavsett projektkatalog:
Tjänst Container-namn
Frontend (Next.js) recipe-frontend
Backend (NestJS) recipe-api
Databas (MariaDB) recipe-db

Använd dessa namn vid docker exec, t.ex.:

docker exec recipe-api npx prisma migrate dev --name migration_name
docker exec recipe-db mariadb -uroot -p"LÖSENORD" recipe_app -e "SHOW TABLES;"

Orphan-containers vid blandade compose-filer

Vid arbete med bade huvudappen och Flutter-sparet kan Docker Compose visa varningen om orphan containers, ofta for recipe-flutter.

Detta betyder normalt bara att:

  • en container startades med en annan compose-filskombination tidigare,
  • och att den inte finns med i kommandot du kor just nu.

Exempel:

  • docker compose up -d recipe-frontend kanner inte till recipe-flutter eftersom den bara finns i compose.flutter.yml.
  • docker compose -f compose.yml -f compose.flutter.yml up -d recipe-flutter kanner till Flutter-sparet.

Varningen ar i sig inte ett fel och paverkar inte Prisma-migrationer eller databasens schema.

Stada endast bort orphan-containers om du verkligen vill stoppa dem:

docker compose down --remove-orphans

Obs: detta kan stoppa recipe-flutter, som da maste startas igen med override-filen.


Caddy-konfiguration (reverse proxy)

Caddy sitter framför applikationen och distribuerar trafik. Ordningen på handle-blocken är kritisk — Caddy väljer det första matchande blocket.

(auth) {
    basicauth {
        admin $2a$14$DahHUWD2cKyXJ96sH5VQwuQv1bqmIn0gsdoSaw4mofzfdNY2Y0VsO
    }
}

(common) {
    encode gzip zstd
    header {
        Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
        X-Content-Type-Options "nosniff"
        X-Frame-Options "DENY"
        X-XSS-Protection "1; mode=block"
        Referrer-Policy "strict-origin-when-cross-origin"
        Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=()"
        Cross-Origin-Opener-Policy "same-origin"
        Cross-Origin-Resource-Policy "same-origin"
        Cross-Origin-Embedder-Policy "require-corp"
    }
}

# ============================================
# Import Service (Document Converter) - Standalone UI
# ============================================
import.gynther.se {
    import common
    reverse_proxy recipe-import-service:3000
}

# ============================================
# RECIPE APP + IMPORT SERVICE
# ============================================
recept.gynther.se {
    import common

    # === IMPORT SERVICE (Document Converter) ===
    # Dessa endpoints måste komma FÖRST innan backend reglerna!
    handle /api/recipes/import* {
        reverse_proxy recipe-import-service:3000
    }

    # === RECIPE FRONTEND PROXY ENDPOINTS ===
    # Next.js API routes
    handle /api/inventory-history-proxy {
        reverse_proxy recipe-frontend:3000
    }

    handle /api/admin/merge-preview-proxy {
        reverse_proxy recipe-frontend:3000
    }

    handle /api/recipe-preview-proxy {
        reverse_proxy recipe-frontend:3000
    }

    # === RECIPE BACKEND API ENDPOINTS ===
    # Backend körs på port 8080 (från docker-compose)
    handle /api/products* {
        reverse_proxy recipe-api:8080
    }

    handle /api/inventory* {
        reverse_proxy recipe-api:8080
    }

    handle /api/recipes* {
        reverse_proxy recipe-api:8080
    }

    # === HEALTH CHECKS ===
    handle /health {
        reverse_proxy recipe-api:8080
    }

    # === CATCH ALL ===
    # Övriga /api/* går till frontend
    handle /api/* {
        reverse_proxy recipe-frontend:3000
    }

    # Frontend - catch all remaining routes (port 3000)
    reverse_proxy /* recipe-frontend:3000
}

Kommentarer:

  • Blocket (common) innehÃ¥ller alla grundläggande säkerhetshuvuden och komprimering.
  • Blocket (auth) används för basic auth pÃ¥ utvalda endpoints.
  • Importera (common) i varje site-block för att Ã¥teranvända headers och inställningar.
  • Proxyreglerna styr trafik till rätt backend/frontend-tjänst beroende pÃ¥ path.
  • Kommentarer i filen förklarar ordningen pÃ¥ reglerna och varför vissa hanteras först.

Säkerhetshuvuden

Säkerhetshuvuden är implementerade i tre lager för djupförsvar:

Lager 1: Caddy (globalt för alla tjänster)

Sätts i (common)-blocket och gäller alla domäner som importerar det.

Header Värde Syfte
Strict-Transport-Security max-age=31536000; includeSubDomains; preload Tvingar HTTPS, skyddar mot protocol downgrade
X-Content-Type-Options nosniff Förhindrar MIME-sniffing
X-Frame-Options DENY Skyddar mot clickjacking
X-XSS-Protection 1; mode=block Legacy XSS-skydd (äldre webbläsare)
Referrer-Policy strict-origin-when-cross-origin Begränsar referrer-läckage
Permissions-Policy geolocation=(), microphone=(), camera=(), payment=() Inaktiverar känsliga webbläsar-API:er
Cross-Origin-Opener-Policy same-origin Isolerar browsing context
Cross-Origin-Resource-Policy same-origin Förhindrar cross-origin läsning av resurser
Cross-Origin-Embedder-Policy require-corp Kräver explicit cross-origin permission

Lager 2: NestJS Helmet (backup)

Helmet konfigurerat i backend/src/main.ts som säkerhetsbackup ifall Caddy kringgås eller misslyckas. CSP är inaktiverat i Helmet (contentSecurityPolicy: false) eftersom det hanteras av Next.js.

Aktiveras vid: docker compose up --build backend

Lager 3: Next.js Content Security Policy

CSP sätts i frontend/next.config.js via headers()-funktionen och gäller alla routes (/:path*).

Direktiv Tillåtna källor Motivering
default-src 'self' Restriktiv default
script-src 'self' 'unsafe-eval' 'unsafe-inline' Krävs av Next.js runtime
style-src 'self' 'unsafe-inline' fonts.googleapis.com Inline-stilar + Google Fonts
img-src 'self' data: https: Tillåter externa bilder via HTTPS
font-src 'self' fonts.gstatic.com Google Fonts-filer
connect-src 'self' api.mistral.ai API-anrop inkl. Mistral AI
frame-src 'none' Inga inbäddade frames tillåtna
object-src 'none' Inga plugins (Flash, etc.)
base-uri 'self' Skyddar mot base-tag-injektion
form-action 'self' Formulär får bara posta till samma origin

Notering: 'unsafe-eval' och 'unsafe-inline' i script-src är nödvändiga för Next.js 16 med App Router. Undvik att ta bort dessa utan noggrann testning.

Felsökning av CSP-brott

Om en funktion slutar fungera efter CSP-aktivering:

  1. Öppna webbläsarens devtools → Console för att se CSP-felmeddelanden
  2. Kontrollera vilken domän/resurs som blockeras
  3. Lägg till domänen i rätt direktiv i frontend/next.config.js
  4. Vanliga undantag: WebSockets kräver wss: i connect-src, Service Workers kräver worker-src 'self'

Arkitekturprincip: API routes framför Server Actions

Regel: Använd Next.js API routes (/app/api/...) för all mutation från klientkomponenter. Använd INTE Server Actions för detta.

Bakgrund

Next.js Server Actions returnerar alltid ett RSC-payload (React Server Component flight-format) som svar — även om funktionen bara returnerar ett vanligt JSON-objekt. När en klientkomponent anropar en Server Action via startTransition försöker React tolka svaret som ett siduppdateringspaket. Detta orsakar kraschen "can't reload page" / TypeError: r is not iterable i React 19 om sidans RSC-träd inte kan återskapas korrekt (t.ex. p.g.a. Caddy-routing, auth-state eller timing).

Rätt mönster: Next.js API route

Klientkomponent  →  fetch('/api/admin/...')  →  Next.js API route  →  Backend API
  • API routen körs server-side och har tillgÃ¥ng till sessionen via auth() → kan lägga till auth-headers
  • Returnerar ren JSON — inga RSC-payload-problem
  • Caddy-safe: använd /api/admin/ som prefix (faller igenom till recipe-frontend:3000)
  • Klientkomponenten hanterar UI-state lokalt efter svar (uppdatera/ta bort ur lokal state)

Exempel (se app/api/admin/product/[id]/route.ts):

// API route (server-side, har session)
export async function PATCH(req, { params }) {
  const authHeaders = await getAuthHeaders(); // använder auth()
  const res = await fetch(`${API_BASE}/api/products/${id}`, { method: 'PATCH', headers: authHeaders, ... });
  return Response.json(await res.json());
}

// Klientkomponent
const res = await fetch(`/api/admin/product/${id}`, { method: 'PATCH', body: JSON.stringify(data) });
const updated = await res.json();
setProducts(prev => prev.map(p => p.id === updated.id ? updated : p)); // lokal state-uppdatering

När är Server Actions OK?

Server Actions kan fortfarande användas för operationer som inte anropas från klientkomponenter med startTransition, t.ex.:

  • Form submissions i rena Server Components (inget useTransition)
  • Admin-operationer som ändÃ¥ triggar en helsidsladdning efterÃ¥t

Befintliga undantag att känna till

Dessa Server Actions finns kvar men bör migreras om de orsakar problem:

  • bulkSetCategory — anropas frÃ¥n AdminProductList (klientkomponent)

  • suggestProductCategory / suggestBulkCategories — AI-kategorisering, anropas frÃ¥n klient

  • Framework: Next.js 16.2 (App Router, server + client components)

  • SprÃ¥k: TypeScript 5.4.5

  • UI: React 19.2, ingen CSS-ramverk (ren CSS-in-JS och inline-stilar)

  • Autentisering: Auth.js v5 (next-auth beta), JWT-session, auth() i server components

  • Bygg: Standalone output, körs i Docker-container

  • API-anrop: fetchJson (server-side med auth-headers) + Next.js API route-proxies (client-side)

  • Felhantering: Global parseErrorResponse utility, svenska felmeddelanden

Viktigt: Navigation.tsx är en async server component som anropar auth(). Den får aldrig importeras av client components — rendera den alltid i page.tsx (server component).

Frontend-sidor och komponenter

Sida Fil Funktionalitet
Hem app/page.tsx Startsida
Navigering app/Navigation.tsx Huvudmeny, inloggad användare, länk till profil; länkarna "⚙️ Admin", "⏳ Förslag" och "👥 Användare" visas enbart om sessionens roll är admin
Inloggning app/login/page.tsx Inloggningssida med Auth.js Credentials
Profil app/profil/page.tsx Flikbaserad profil-/adminsida (server component): läser ?tab=-param, kontrollerar admin-roll via auth(), laddar rätt flik dynamiskt
app/profil/ProfileTabs.tsx Klientkomponent: fliknavigering med Link-basad URL-routing (?tab=profil|anvandare|databas)
app/profil/tabs/MinProfilTab.tsx Profilformulär (förnamn, efternamn, e-post)
app/profil/tabs/AnvandareTab.tsx Server component: hämtar användarlista, renderar AnvandareClient
app/profil/tabs/AnvandareClient.tsx Klientkomponent: skapa/ta bort användare, rollbyte, plan-byte (Free/Paid ✨ → isPremium), e-postbyte, lösenordsåterställning med kopierings-modal
app/profil/tabs/DatabsTab.tsx Server component: produktdatabas (importerar admin/products-komponenter)
Inventorie app/inventory/page.tsx Lista, filtrera, sortera varor
InventoryList.tsx Ritning av inventarieföremål
InventoryForm.tsx Skapa nytt inventarieföremål
InventoryEditForm.tsx Redigera inventarieföremål
InventoryConsumeForm.tsx Konsumera (brukat) inventarieföremål
InventoryConsumptionHistory.tsx Visa konsumtionshistorik
ProductForm.tsx Välja produkt för inventarieföremål
actions.ts Server actions för inventarie
Recept app/recipes/page.tsx Lista recept
RecipePreview.tsx Receptförhandsvisning med inventariestatus
Lägg till recept app/recipes/create/page.tsx Server component med Navigation
app/recipes/create/CreateRecipeClient.tsx Klientkomponent: snabbimport + metodval
Skriv in recept app/recipes/write/page.tsx Server component med Navigation
app/recipes/write/WriteRecipePage.tsx Markdown-baserat receptskapande (3-steg): Markdown-inmatning → ingrediensgranskning (produktval + portionsantal) → spara
Importera från fil app/recipes/import/page.tsx Startpunkt för fil/länk-import
app/recipes/import/ImportFilePage.tsx Fil-/länk-import (PDF, URL, etc)
Matplan app/matplan/page.tsx Matplanering (server component)
app/matplan/MealPlanClient.tsx Veckovy, receptval per dag, portionsjustering, inköpslista, inventariejämförelse
Kvittoimport app/import/page.tsx Server component med Navigation + flikvy
app/import/ImportTabsClient.tsx Klientkomponent: kvitto/recept-flikar
Admin: Användare app/admin/users/page.tsx Redirect till /profil?tab=anvandare
Admin: Produkter app/admin/products/page.tsx Produktadmin-panel
AdminProductList.tsx Lista produkter, sök, sortera, filter okategoriserade, bulk-select + bulk-kategorisering; AI-bulk-knapp ("✨ AI-kategorisera") med bekräftelsemodal
EditProductForm.tsx Inline redigering: name, canonicalName, kategori (hierarkisk dropdown), brand, taggar; "✨ Fråga AI"-knapp med suggestion-chip (grön = hög, gul = fallback)
ResetProductsButton.tsx Knapp för att rensa all produktdata
MergePreviewForm.tsx Förhandsgranska merge
actions.ts Server actions: updateProduct, deleteProduct, resetAllProducts, bulkSetCategory, suggestProductCategory, suggestBulkCategories, setProductStatus
Admin: Väntande produkter app/admin/products/pending/page.tsx Server component, auth-skyddad, hämtar pending-produkter
PendingProductsClient.tsx Tabell: Produkt / Kategori (AI) / Föreslagen av / Datum / Åtgärd; "✓ Godkänn" / "✕ Avvisa"-knappar
Baslager app/baslager/page.tsx Visa och hantera baslager (server component)
AddToPantryForm.tsx Lägg till produkt i baslager (dropdown)
PantryList.tsx Visa baslager grupperat per kategori
actions.ts Server actions: addPantryItem, removePantryItem

API-proxy routes (Next.js)

Alla proxy-routes använder withAuth(handler)-wrappern (från lib/with-auth.ts) som läser auth-token via request.auth och vidarebefordrar Authorization: Bearer <token> till backend. withAuth löser kompatibilitetsproblem med auth() standalone i Next.js 16 + NextAuth beta.28.

Route Metod Syfte
/api/admin-users GET, POST Hämtar alla användare / skapar ny användare (kräver admin-roll i session)
/api/admin-users/[id] PATCH, DELETE, PUT Ändrar roll / tar bort användare / byter e-post; om body innehåller isPremium → anropar PATCH /api/users/:id/premium (kräver admin-roll i session)
/api/admin-users/[id]/reset-password POST Återställer lösenord och returnerar tillfälligt lösenord + meddelandetext (kräver admin-roll)
/api/auth/[...nextauth] GET, POST Auth.js handlers (login, logout, session)
/api/products GET Produktlista (auth-wrappat med auth(req))
/api/categories GET Kategorihierarki (publik, proxies /api/categories/tree)
/api/profile GET, PATCH Hämta/uppdatera användarprofil
/api/recipes GET, POST Lista recept + spara nytt
/api/quick-import-proxy POST URL-, PDF- och bildimport
/api/parse-markdown-proxy POST Markdown-tolkning för skriv-in-recept
/api/inventory-history-proxy GET Konsumtionshistorik
/api/recipe-preview-proxy GET Receptförhandsvisning
/api/admin/merge-preview-proxy GET Produktmerge-preview
/api/receipt-import-proxy POST Kvittoimport via Mistral AI
/api/meal-plan-proxy GET, POST, DELETE Matplanering (veckovy, upsert, ta bort)
/api/meal-plan-shopping-proxy GET Inköpslista för datumintervall
/api/meal-plan-compare-proxy GET Inventariejämförelse för datumintervall
/api/user-products GET, POST, DELETE Användarspecifika produkter

Autentisering (Auth.js v5)

  • auth.ts — NextAuth-konfiguration med Credentials provider; sparar accessToken, userId, username och role i JWT-token och session
  • proxy.ts — Next.js 16 kräver att auth-middleware heter proxy.ts (inte middleware.ts). Skyddar alla routes utom /login, /register och /api/*: ej inloggade redirectas till /login, och inloggade icke-admins blockeras frÃ¥n /admin/* (redirect till /). Täcker alltsÃ¥ bÃ¥de autentisering och rollbaserad accesskontroll i ett enda fil.
  • lib/auth-headers.ts — getAuthHeaders() hämtar Bearer-token frÃ¥n session (server-side, används i Next.js API route-proxies)
  • lib/api.ts — fetchJson() lägger automatiskt till auth-headers server-side, redirectar till /login vid 401
  • lib/with-auth.ts — withAuth(handler)-wrapper för Next.js Route Handlers som behöver autentisering via request.auth. Löser kompatibilitetsproblemet med auth() standalone i Next.js 16 + NextAuth beta.
  • lib/use-auth-fetch.ts — useAuthFetch()-hook för klientkomponenter. Returnerar en fetch-funktion som automatiskt lägger till Authorization: Bearer <token>. Används i komponenter som gör anrop till endpoints som Caddy routar direkt till NestJS.
  • app/Providers.tsx — Client component som wrappa appen med SessionProvider (krävs för att useSession() ska fungera)

Kritisk regel: Caddy-routing och klientkomponenter

Caddy routar /api/recipes*, /api/products*, /api/inventory* och /api/pantry* direkt till NestJS — Next.js route handlers körs aldrig för dessa sökvägar. Det innebär att klientkomponenter ('use client') som gör fetch() till dessa endpoints inte kan lita på att withAuth eller getAuthHeaders lägger till token automatiskt.

Lösning: använd alltid useAuthFetch() i klientkomponenter:

import { useAuthFetch } from '../../../lib/use-auth-fetch';

// I komponentfunktionen:
const authFetch = useAuthFetch();

// Anrop — Content-Type och Authorization sätts automatiskt:
const res = await authFetch('/api/recipes/1', { method: 'PATCH', body: JSON.stringify(data) });
const res = await authFetch('/api/recipes', { method: 'POST', body: JSON.stringify(body) });
const res = await authFetch(`/api/recipes/${id}`, { method: 'DELETE' });

Hooken hämtar token via useSession() (kräver att SessionProvider finns i layout) och är memoizerad med useCallback.

Endpoints som kräver useAuthFetch() i klientkod (Caddy-direktrouting):

Prefix Destination Kräver useAuthFetch i klient?
/api/recipes* recipe-api:8080 direkt ✅ Ja
/api/products* recipe-api:8080 direkt ✅ Ja
/api/inventory* recipe-api:8080 direkt ✅ Ja
/api/pantry* recipe-api:8080 direkt ✅ Ja
/api/admin/* recipe-frontend:3000 via catch-all Nej (withAuth hanterar)
/api/auth/* recipe-frontend:3000 via catch-all Nej
/api/categories recipe-frontend:3000 via catch-all Nej

⚠️ Återkommande misstag (2026-04-21): Vid implementationen av InventoryView och PantryView (klientkomponenter i Databas-tabben) användes plain fetch('/api/inventory') och fetch('/api/pantry') utan token. NestJS returnerade 401 och komponenterna visade "Kunde inte hämta inventarie". Felet uppstod trots att en Next.js API-route för /api/inventory redan existerade — Caddy routar dessa paths förbi Next.js helt. Regel: varje ny klientkomponent som hämtar data från /api/inventory*, /api/pantry*, /api/recipes* eller /api/products* måste använda useAuthFetch().

  • Svenska felmeddelanden via lib/error-handler.ts (parseErrorResponse)
  • Centraliserad API-access via lib/api.ts (fetchJson) — för server-side anrop
  • Klientside auth-fetch via lib/use-auth-fetch.ts (useAuthFetch) — för klientkomponenter som pratar direkt med NestJS
  • Typade inventory/recipe data i features/inventory/types.ts

Backend (NestJS)

  • Framework: NestJS 10.3
  • SprÃ¥k: TypeScript 5.4.5
  • Databas: MariaDB 11 (via Prisma 6.12.0 ORM)
  • API: REST, validering med class-validator
  • Autentisering: JWT (7 dagars token), JwtAuthGuard skyddar alla routes globalt, RolesGuard kontrollerar rollkrav, @Public() dekorator för öppna endpoints
  • Rollbaserad behörighet: @Roles('admin') dekoratorn via SetMetadata; RolesGuard kastar 403 om rätt roll saknas
  • Felhantering: GlobalExceptionFilter (svenska felmeddelanden)
  • Hälsokontroll: /health endpoints
  • Bygg: nest build, körs i Docker-container

Backend-moduler och strukturen

backend/src/
├── app.module.ts                    # Root module
├── main.ts                          # Startpunkt (port 8080, global prefix "api")
├── auth/
│   ├── auth.controller.ts           # POST /api/auth/login, POST /api/auth/register
│   ├── auth.service.ts              # validateUser, login (JWT-signering inkl. role + isPremium)
│   ├── auth.module.ts
│   ├── jwt.strategy.ts              # Passport JWT-strategi (returnerar userId, username, role, isPremium)
│   ├── jwt-auth.guard.ts            # Global guard (skyddar allt utom @Public)
│   ├── roles.guard.ts               # Guard som kontrollerar @Roles() metadata; kastar 403
│   └── decorators/
│       ├── public.decorator.ts      # @Public() – markerar öppen endpoint
│       ├── current-user.decorator.ts # @CurrentUser() – extraherar {userId, username, role, isPremium}
│       └── roles.decorator.ts       # @Roles('admin') – sätter rollkrav via SetMetadata
├── ai/
│   ├── ai.service.ts                # AiService: suggestCategory() — anropar Mistral API med kategorikontext
│   └── ai.module.ts                 # Exporterar AiService (importeras av ProductsModule, ReceiptImportModule)
├── users/
│   ├── users.controller.ts          # GET /api/users/me, PATCH /api/users/me
│   │                                # GET /api/users (admin), PATCH /api/users/:id/role (admin)
│   │                                # POST /api/users (admin), DELETE /api/users/:id (admin)
│   │                                # POST /api/users/:id/reset-password (admin), PATCH /api/users/:id/email (admin)
│   │                                # PATCH /api/users/:id/premium (admin)
│   ├── users.service.ts             # findByUsername, findById, create, updateProfile
│   │                                # findAll, setRole, adminCreate, deleteUser, resetPassword, updateEmail, setPremium
│   ├── admin-bootstrap.service.ts   # OnApplicationBootstrap: skapar/uppdaterar 4 seed-användare
│   └── users.module.ts
├── categories/
│   ├── categories.controller.ts     # GET /api/categories, GET /api/categories/tree (@Public)
│   ├── categories.service.ts        # findAll (flat), findTree (hierarkisk), findFlattened (med full path)
│   └── categories.module.ts
├── common/
│   ├── filters/
│   │   └── global-exception.filter.ts   # Centraliserad felhantering
│   └── utils/
│       └── normalize-name.ts             # Namnormalisering
├── health/
│   ├── health.controller.ts         # GET /health, /health/db (@Public)
│   ├── health.service.ts
│   └── health.module.ts
├── inventory/
│   ├── inventory.controller.ts      # CRUD endpoints
│   ├── inventory.service.ts         # CRUD + konsumtion
│   ├── inventory.module.ts
│   └── dto/
│       ├── create-inventory.dto.ts
│       ├── update-inventory.dto.ts
│       └── consume-inventory.dto.ts
├── prisma/
│   ├── prisma.service.ts            # PrismaClient wrapper
│   └── prisma.module.ts
├── products/
│   ├── products.controller.ts       # CRUD, merge, duplicates, reset-all
│   │                                # GET /products/pending (admin)
│   │                                # GET /products/:id/suggest-category (premium/admin)
│   │                                # POST /products/ai-categorize-bulk (admin)
│   │                                # PATCH /products/:id/status (admin)
│   ├── products.service.ts          # Produktlogik inkl. resetAll(), findPending(), setStatus()
│   ├── products.module.ts           # Importerar AiModule + CategoriesModule
│   └── dto/
│       ├── create-product.dto.ts
│       ├── update-product.dto.ts
│       ├── merge-products.dto.ts
│       └── update-canonical-name.dto.ts
├── quick-import/                    # 🆕 Snabbimport-modul
│   ├── quick-import.controller.ts   # POST /api/quick-import
│   ├── quick-import.service.ts      # ICA-skrapning, URL-parsing
│   ├── quick-import.module.ts       # Module definition
│   └── parsers/
│       ├── base.parser.ts           # Abstract RecipeParser class
│       ├── ica.parser.ts            # ICA.se-specifik parser (JSON-LD)
│       └── generic.parser.ts        # Fallback-parser (HTML + JSON-LD)
├── meal-plan/
│   ├── meal-plan.controller.ts      # GET/POST/DELETE + shopping-list + inventory-compare
│   ├── meal-plan.service.ts         # Upsert, shoppingList (portionsskalad), inventoryCompare
│   ├── meal-plan.module.ts
│   └── dto/
│       └── create-meal-plan-entry.dto.ts  # { date, recipeId, servings? }
├── receipt-import/
│   ├── receipt-import.controller.ts  # POST /api/receipt-import (multipart, kräver JWT)
│   │                                  # Skickar isPremium till service (premium/admin → AI-enrichment)
│   ├── receipt-import.service.ts     # Mistral AI-anrop, bildtolkning, enrichWithAiCategories()
│   ├── receipt-import.module.ts      # Importerar AiModule + CategoriesModule
│   └── dto/
│       └── parsed-receipt-item.dto.ts  # Inkl. categorySuggestion?: CategorySuggestion
├── receipt-alias/
│   ├── receipt-alias.controller.ts   # CRUD /api/receipt-alias
│   ├── receipt-alias.service.ts
│   └── dto/
└── recipes/
    ├── recipes.controller.ts        # Recept endpoints
    ├── recipes.service.ts           # Recept + Markdown-parsing
    ├── recipes.module.ts
    └── dto/
        ├── create-recipe.dto.ts
        ├── parse-markdown.dto.ts
        └── create-recipe-ingredient.dto.ts└── pantry/
    ├── pantry.controller.ts         # GET/POST/DELETE /api/pantry
    ├── pantry.service.ts            # Baslagerlogik
    ├── pantry.module.ts
    └── dto/
        └── create-pantry-item.dto.ts```

### Backend-funktioner

**Health API:**
- Övergripande systemstatus (uptime, service info)
- Databasspecifik hälsokontroll (responseTime, connection test)
- Returnerar statusCode 200 eller 503

**Quick-Import API:** 📌 (Även tillgänglig via [Microservice Importer](../microservice-importer/))
- **Endpoint:** `POST /api/quick-import`
- **Input:**
  - JSON-body med `input` för URL eller servermonterad filsökväg
  - `multipart/form-data` med `file` för uppladdad PDF eller bild
- **Stödda format:** PDF, PNG, JPG, JPEG, WEBP, BMP samt receptlänkar
- **Process:**
  1. Typdetektering av URL, PDF eller bild
  2. URL-import via site-specifik eller generisk parser
  3. PDF-import via `pdf-parse`
  4. Bildimport via `tesseract.js` OCR (`swe+eng`)
  5. Normalisering till Markdown-format för vidare receptgranskning
- **Parser-arkitektur:**
  - **Base Parser** (`RecipeParser`): Abstract class med gemensam parseIngredientLine()-logik
    - Hanterar bråkmängder (1 1/2 dl), parentetiska noter, unit-validering
    - Kända enheter: g, kg, hg, mg, ml, dl, l, tl, st, tsk, msk, krm, port, efter smak, förp, klyfta, m.fl.
  - **ICA Parser** (`IcaRecipeParser`): Prioriterar JSON-LD structured data, fallback HTML
  - **Generic Parser** (`GenericRecipeParser`): Försöker alla webbplatser (JSON-LD → HTML)
- **Output:** Markdown-format recepttext med `source: 'ica' | 'pdf' | 'image' | 'other'`

**Inventarie-API:**
- CRUD för inventarieföremål (produktreferens, kvantitet, enhet, plats, märke, bäst före, mm)
- Konsumtionshistorik-tracking (registrera brukat amount och kommentar)
- Sortering: efter plats, bäst före-datum, namn (A–Ö)
- Filtrera utgående varor
- **Enhetskonvertering:** Stöd för viktenheter (g/kg), volymenheter (ml/dl), portionsenheter (tsk/msk)
  - Normalisering av enheter (t.ex. "tesked" → "tsk", "gram" → "g")
  - Konverteringsregler per enhet-typ
  - Kan endast konvertera inom samma enhet-typ (error om blandning)

**Recept-API:**
- CRUD för recept och ingredienser
- **Parse-markdown endpoint:** Tolkar Markdown-format, matchar ingredienser mot databas
- **Matchningsalgoritm (3 nivåer):**
  1. Exakt match (normalizedName eller canonicalName efter normalisering): **100 poäng**
  2. Delsträng-match (ingrediens i produktnamn eller vice versa): **70 poäng**
  3. Levenshtein-distans-baserad likhet: **40–100 poäng** (under 40 filtreras bort)
  - Top 5 förslag per ingrediens
  - Sortering: Högsta poäng först
- **Inventory-preview:** Jämför recept mot inventarie
  - Returnerar status för varje ingrediens: räcker | saknas | enhetskonflikt
  - Automatisk enhetskonvertering vid jämförelse
- **Normalisering:** `normalize-name()` utility för consistent namn-matching

**Produkt-API:**
- CRUD för produkter (create, read, update, delete)
- **Duplicate detection:** `findDuplicates()` - Hitta produkter med samma normalizedName
- **Merge-preview:** `previewMerge()` - Förhandsgranska merge operation (visa inventory-counts, outcome)
- **Merge operation:** `merge()` - Slå ihop två produkter
  - Flytta alla inventarieföremål från källa till mål
  - Soft-delete källan (isActive = false, deletedAt = nu)
  - Uppdatera recept-ingredienser från källa till mål
- **Canonical name management:**
  - `updateCanonicalName()` - Uppdatera canonical name för ett produktnamn
  - `backfillCanonical()` - Fylla på canonical names för alla produkter (admin-funktion)
- **Soft delete & restore:**
  - `remove()` - Soft-delete produkt (isActive = false)
  - `restore()` - Återställ borttagen produkt
- **Bulk-uppdatering:** `bulkUpdate(ids, data)` — Uppdatera ett godtyckligt antal produkter i ett enda DB-anrop (`updateMany`). Används primärt för bulk-kategorisering i admin-UI. Body: `{ ids: number[], categoryId?: number | null }`

**Matplan-API:**
- **`upsert(userId, dto)`** — Skapar eller uppdaterar en `MealPlanEntry` för inloggad användare och ett givet datum (unik per `userId + date`). Sparar `recipeId` och valfritt `servings`.
- **`findByRange(userId, from, to)`** — Hämtar alla planerade dagar för inloggad användare i ett datumintervall, inkl. receptinfo.
- **`shoppingList(userId, from, to)`** — Aggregerar ingrediensmängder för inloggad användares planerade recept i intervallet.
  - Om `entry.servings` och `recipe.servings` är satta beräknas en skala: `scale = entry.servings / recipe.servings`
  - Ingrediensmängder multipliceras med skalan innan aggregering
  - Returnerar lista av `{ productName, quantity, unit }`
- **`inventoryCompare(from, to)`** — Kör samma aggregering som `shoppingList` men jämför sedan varje ingrediens mot:
- **`inventoryCompare(userId, from, to)`** — Kör samma aggregering som `shoppingList` men jämför sedan varje ingrediens mot:
  1. **Pantry (baslager):** Om produkten finns i `PantryItem` för `userId` returneras `status: 'pantry'`, `missing: 0` — varan räknas alltid som tillgänglig oavsett inventariet.
  - Sorteringsordning: `missing` → `enough` → `pantry`
  - Frontend visar 📦-ikon och ”(baslager)” för pantry-varor; de visas aldrig som ”saknas” i inköpslistan.

**Kvittoimport-API:**
- **`parseReceipt(file, isPremium)`** — Tar emot en bild eller PDF (max 15 MB), skickar den till Mistral AI för tolkning och returnerar en lista av kandidatprodukter med namn, kvantitet och enhet.
- Alias-matchning: före returneringen slås varje rånamn upp mot `ReceiptAlias`-tabellen och mot `Product.normalizedName`. Träffar kopplas automatiskt till rätt produkt-ID.
- **AI-kategorisuggestion (premium):** Om `isPremium = true` (eller admin) och en vara varken alias-matchas eller ordbaserat matchas anropas `AiService.suggestCategory()`. Svaret inkluderas som `categorySuggestion` i retur-DTO:n och visas som ett lila "✨"-chip i frontend.
- Stödda MIME-typer: `image/jpeg`, `image/png`, `image/webp`, `image/heic`, `image/heif`, `application/pdf`

**AI-API (`AiService`):**
- **`suggestCategory(productName, categories: FlatCategory[])`** — Skickar produktnamnet och en flat lista av alla kategorier (med full sökväg, t.ex. "Mejeri och ägg > Mjölk och grädde") till Mistral API (`mistral-small-2603`). Returnerar `CategorySuggestion`.
- **Svar-typ `CategorySuggestion`:** `{ categoryId, categoryName, path, confidence: 'high'|'medium'|'low', usedFallback: boolean }`
- **Fallback-strategi:** Om AI returnerar ett ogiltigt kategori-ID eller om anropet misslyckas: försök hitta "Övrigt"-underkategori → fallback till rot-"Övrigt" (id 221) med `confidence: 'low'` och `usedFallback: true`
- **Integration:** `AiModule` exporterar `AiService` och importeras av `ProductsModule` och `ReceiptImportModule`
- **CategoriesService tillägg:** `findFlattened()` bygger en flat lista med `{ id, name, path }` där `path` är den fullständiga kategorivägen (används som kontext till Mistral)

---

## API-endpoints (fullständig lista)

### 🏥 Health endpoints

GET /api/health Övergripande hälsakontroll (200/503) GET /api/health/db Databasspecifik hälsa + responseTime


### 📦 Inventarie-endpoints

GET /api/inventory Lista inventarieföremål Params: ?location=... &sort=... GET /api/inventory/expiring Utgångna/snart utgångna varor POST /api/inventory Skapa nytt inventarieföremål PATCH /api/inventory/:id Uppdatera inventarieföremål POST /api/inventory/:id/consume Konsumera (registrera brukat amount) GET /api/inventory/:id/consumption-history Konsumtionshistorik


### 🍽️ Recept-endpoints

POST /api/quick-import Snabbimport från URL, PDF eller bild Body: { input: string } eller multipart-form med file POST /api/recipes/parse-markdown Tolka Markdown-recept (matchningslogik) GET /api/recipes Lista alla recept POST /api/recipes Skapa nytt recept GET /api/recipes/:id Hämta specifikt recept PATCH /api/recipes/:id Uppdatera recept DELETE /api/recipes/:id Ta bort recept (204 No Content) GET /api/recipes/:id/inventory-preview Jämför recept mot inventarie


### 🏷️ Produkt-endpoints

GET /api/products Lista alla aktiva produkter POST /api/products Skapa ny produkt GET /api/products/:id Hämta specifik produkt PATCH /api/products/:id Uppdatera produktens namn, canonicalName eller kategori DELETE /api/products/:id Soft-delete produkt POST /api/products/:id/restore Återställ raderad produkt

GET /api/products/duplicates Lista duplicerade namn (grupperade) GET /api/products/merge-preview Förhandsgranska merge ?sourceProductId=X &targetProductId=Y POST /api/products/merge Slå ihop två produkter PATCH /api/products/:id/canonical-name Uppdatera canonical name POST /api/products/backfill-canonical Backfill canonical names (admin) POST /api/products/reset-all Rensa all produktdata (admin) POST /api/products/bulk-update Uppdatera flera produkter (t.ex. sätt kategori) Body: { ids: number[], categoryId?: number | null }

GET /api/products/pending Lista pending-produkter (admin) GET /api/products/:id/suggest-category AI-förslag på kategori för produkt (premium/admin) POST /api/products/ai-categorize-bulk Kör AI-kategorisering på alla okategoriserade (admin) Returnerar lista av { productId, productName, suggestion: CategorySuggestion } PATCH /api/products/:id/status Sätt produktstatus active/rejected (admin) Body: { status: 'active' | 'rejected' }


### Kategori-endpoints

GET /api/categories Flat lista av alla kategorier (@Public) GET /api/categories/tree Hierarkiskt träd (@Public)


### Användar-endpoints

POST /api/auth/login Logga in, returnerar JWT inkl. role + isPremium (@Public) GET /api/users/me Hämta inloggad användares profil (inkl. role, isPremium) PATCH /api/users/me Uppdatera firstName, lastName, email GET /api/users Lista alla användare (kräver admin-roll) PATCH /api/users/:id/role Ändra roll för användare (kräver admin-roll) PATCH /api/users/:id/premium Sätt isPremium true/false (kräver admin-roll) Body: { isPremium: boolean }


### Baslager-endpoints

GET /api/pantry Lista inloggad användares baslager (inkl. produktinfo) POST /api/pantry Lägg till produkt i baslagret (kräver JWT) DELETE /api/pantry/:id Ta bort produkt från baslagret (kräver JWT, ägarskap)


> Alla anrop kräver JWT. Varje användare ser bara sitt eget baslager.

### 🗓️ Matplan/matsedel-endpoints

GET /api/meal-plan?from=YYYY-MM-DD&to=YYYY-MM-DD Lista planerade recept för datumintervall (per inloggad användare) POST /api/meal-plan Skapa eller uppdatera post (upsert per datum, per användare) Body: { date, recipeId, servings? } DELETE /api/meal-plan/:date Ta bort recept för ett specifikt datum (per inloggad användare)

GET /api/meal-plan/shopping-list?from=...&to=... Generera inköpslista för veckan (per inloggad användare) Skalad proportionellt efter portionsjustering GET /api/meal-plan/inventory-compare?from=...&to=... Jämför inköpslista mot inventarie (användarens baslager) Returnerar status per ingrediens: räcker | saknas | enhetskonflikt


> Alla anrop kräver JWT. Varje användare ser och hanterar bara sin egen matplan.

### 🧾 Kvitto-endpoints

POST /api/receipt-import Tolka kvittobild (JPEG, PNG, WebP, HEIC, PDF) Multipart-form med "file"; max 15 MB Returnerar lista av { name, quantity, unit, productId?, confidence }

GET /api/receipt-alias Lista alla kvitto-alias POST /api/receipt-alias Skapa nytt alias (receiptName → productId) DELETE /api/receipt-alias/:id Ta bort alias


---

## Datamodell (Prisma ORM)

### User
```prisma
model User {
  id           Int      @id @default(autoincrement())
  username     String   @unique
  email        String   @unique
  firstName    String?
  lastName     String?
  passwordHash String
  role         String   @default("user")       # "user" eller "admin"
  isPremium    Boolean  @default(false)         # Styr tillgång till AI-premium-funktioner
  createdAt    DateTime @default(now())
  updatedAt    DateTime @updatedAt
}

Bootstrap-användare: När backend startar kör AdminBootstrapService.onApplicationBootstrap() och skapar eller uppdaterar fyra användare baserade på miljövariabler:

Användarnamn Roll isPremium E-post Miljövariabel
Nadmin admin false nadmin@localhost ADMIN_NADMIN_PASSWORD
Padmin admin false padmin@localhost ADMIN_PADMIN_PASSWORD
user1 user false user1@localhost SEED_USER1_PASSWORD
user2 user false user2@localhost SEED_USER2_PASSWORD

Om en användare redan finns men har fel roll rättas rollen automatiskt. Om miljövariabeln saknas hoppas den användaren över med en varning i loggen.

Category

model Category {
  id       Int        @id @default(autoincrement())
  name     String
  parentId Int?
  parent   Category?  @relation("CategoryTree", ...)  # Förälder (null = toppnivå)
  children Category[] @relation("CategoryTree")       # Underkategorier
  products Product[]

  @@unique([name, parentId])
}

Hierarkin har 3 nivåer: Huvudkategori → Underkategori → Typ
Exempelträd: Mejeri, ost & ägg → Mjölk → Standard mjölk

Kategori- och produktseed

All seed-data för kategorier och produkter hanteras av db/seeds/seed_all.sql — den enda sanningskällan.

Vad filen gör:

  1. TRUNCATE Category + nollställer Product.categoryId — rensar alla ev. gamla/duplicerade kategorier
  2. Bygger upp hela kategoriträdet (nivå 1–3) med rena INSERT — exakt en rad per kategori, inga dubbletter möjliga
  3. INSERT IGNORE ~190 produkter — hoppar över produkter som redan finns
  4. UPDATE Product SET categoryId — kopplar produkterna till rätt kategori via JOIN-subqueries

Körs manuellt på servern:

DB_PASS=$(grep MARIADB_ROOT_PASSWORD .env | cut -d= -f2)
docker exec -i recipe-db mariadb -uroot -p"$DB_PASS" recipe_app < db/seeds/seed_all.sql

OBS: Migrationen 20260417310000_add_category_tree/migration.sql innehåller fortfarande gamla kategori-INSERTs från ursprungsimplementationen. Dessa kör en gång vid prisma migrate deploy men seed_all.sql nollställer och skriver över dem vid nästa körning. Se NEXT_STEPS.md för planerat refactor-arbete.

OBS: Om InventoryItem-rader pekar på produkter som inte längre finns (t.ex. efter TRUNCATE av Product) uppstår Prisma-felet "Field product is required". Rensa orphan-rader med:

DELETE FROM InventoryItem WHERE productId NOT IN (SELECT id FROM Product);

Product

model Product {
  id             Int             @id @default(autoincrement())
  name           String          # Visningsnamn
  normalizedName String @unique  # Normaliserat namn (lowercase, utan skiljetecken)
  canonicalName  String?         # Canonical namn för receptmatchning
  category       String?         # Fritext-kategori (äldre fält, ersätts av categoryRef)
  subcategory    String?         # Fritext-underkategori (äldre fält)
  brand          String?         # Varumärke
  categoryId     Int?            # FK till Category (ny hierarki)
  categoryRef    Category?       # Relation till Category
  status         String @default("active")  # "active" | "pending" | "rejected"
  isActive       Boolean @default(true)
  deletedAt      DateTime?
  createdAt      DateTime @default(now())
  updatedAt      DateTime @updatedAt

  inventoryItems    InventoryItem[]
  recipeIngredients RecipeIngredient[]
  tags              ProductTag[]
  nutrition         Nutrition?
}

Produktstatus:

  • active — normal produkt synlig i alla listor (default)
  • pending — föreslagen produkt som väntar pÃ¥ admin-godkännande; visas pÃ¥ /admin/products/pending
  • rejected — avvisad produkt; visas inte i vanliga listor

InventoryItem

model InventoryItem {
  id             Int      @id @default(autoincrement())
  productId      Int      # Foreign key till Product
  quantity       Decimal @db.Decimal(10, 2)  # Kvantitet (decimal för precision)
  unit           String   # Enhet (g, kg, ml, dl, st, tsk, msk, etc)
  brand          String?  # Varumärke
  location       String?  # Lagerplats (Kyl, Frys, Skafferi, etc)
  purchaseDate   DateTime?  # Köpdatum
  opened         Boolean?   # Markering för öppnad produkt
  suitableFor    String?    # Lämplighetsmärkning (t.ex. "vegetarian")
  bestBeforeDate DateTime?  # Bäst före-datum
  comment        String?    # Fri kommentar
  createdAt      DateTime @default(now())
  updatedAt      DateTime @updatedAt

  product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
  consumptions   InventoryConsumption[]

  @@index([productId])
}

InventoryConsumption

model InventoryConsumption {
  id              Int           @id @default(autoincrement())
  inventoryItemId Int           # Foreign key till InventoryItem
  amountUsed      Decimal       @db.Decimal(10, 2)  # Konsumerad kvantitet
  comment         String?       # Kommentar
  createdAt       DateTime      @default(now())

  inventoryItem   InventoryItem @relation(fields: [inventoryItemId], references: [id])
}

Recipe

model Recipe {
  id          Int      @id @default(autoincrement())
  name        String   # Receptnamn
  description String?  # Receptbeskrivning
  servings    Int?     # Antal portioner receptet är dimensionerat för
  imageUrl    String?  # URL till receptbild (valfritt)
  instructions String? @db.Text  # Tillagningsinstruktioner (kan vara långt, stöder Markdown)
  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt

  ingredients  RecipeIngredient[]
  mealPlanEntries MealPlanEntry[]
}

servings är grundportionsantalet — matplanen använder det för att skala ingrediensmängder om användaren anger ett avvikande portionsantal per dag.

RecipeIngredient

model RecipeIngredient {
  id        Int      @id @default(autoincrement())
  recipeId  Int      # Foreign key till Recipe
  productId Int      # Foreign key till Product
  quantity  Decimal  @db.Decimal(10, 2)  # Receptkvantitet
  unit      String   # Enhet enligt recept
  note      String?  # Ingrediensnot (t.ex. variation)
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  recipe Recipe @relation(fields: [recipeId], references: [id])
  product Product @relation(fields: [productId], references: [id])
}

PantryItem

model PantryItem {
  id        Int      @id @default(autoincrement())
  userId    Int                              # FK till User — baslager är per användare
  productId Int                              # FK till Product
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  user      User     @relation(fields: [userId], references: [id], onDelete: Cascade)
  product   Product  @relation(fields: [productId], references: [id], onDelete: Cascade)

  @@unique([userId, productId])  # En produkt kan bara finnas en gång per användare
  @@index([userId])
}

Beslut 2026-04-22: Baslager är user-scopat. Varje inloggad användare har ett eget baslager. Den gamla globala modellen (@unique([productId])) ersattes med den sammansatta unika nyckeln (userId, productId). Se migrationssektionen nedan.

MealPlanEntry

model MealPlanEntry {
  id        Int      @id @default(autoincrement())
  userId    Int                              # FK till User — matplan är per användare
  date      DateTime # Datum för planerad måltid
  recipeId  Int      # Foreign key till Recipe
  servings  Int?     # Justerat portionsantal för den dagen (null = använd receptets grundvärde)
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  user   User   @relation(fields: [userId], references: [id], onDelete: Cascade)
  recipe Recipe @relation(fields: [recipeId], references: [id])

  @@unique([userId, date])  # Bara ett recept per dag och användare
  @@index([userId])
}

Beslut 2026-04-22: Matplan är user-scopat. Den gamla globala modellen (@@unique([date])) ersattes med @@unique([userId, date]). Se migrationssektionen nedan.

Portionsskalning: Om servings är satt och skiljer sig från recipe.servings beräknar shoppingList() och inventoryCompare() en skala: scale = entry.servings / recipe.servings. Alla ingrediensmängder multipliceras med denna faktor.

ReceiptAlias

model ReceiptAlias {
  id          Int      @id @default(autoincrement())
  receiptName String   @unique  # Namn som kvittosystemet returnerar (råtext)
  productId   Int               # FK till matchad Product
  createdAt   DateTime @default(now())

  product Product @relation(...)
}

Kvitto-alias lagrar mappningar från kvittots råtext till produkt-ID. När Mistral AI returnerar t.ex. "ICA Kvarg Jordg" slås det upp mot alias-tabellen. Om träff hoppas manuell matchning över.



Arkitektur: Privata produkter och kvittoimport produktval (2026-05-01)

Bakgrund

Kvittoimportflödet (Fas 6b) behövde ett sätt för användare att koppla kvittorader till produkter i systemet. Tre problem identifierades:

  1. Produktsökning gav nollträffar när AI-kategorins namn (t.ex. "Frukt & Grönt") matades in som söksträng i produktfältet — produkter heter inte samma sak som kategorier.
  2. AI-föreslagna kategorier var ofta L1/L2 (t.ex. "Mat") men produkter är kopplade till L3-noder. Att filtrera på bara det exakta kategori-ID:t gav 0 produkter.
  3. Saknad produkt — om varan på kvittot inte finns i systemet alls (t.ex. "Röda äpplen") hade användaren ingen väg framåt.

Lösning 1: Tvåstegs-picker (Kategori → Produkt)

En ny widget CategoryThenProductPicker (i lib/core/ui/) orkestrerar ett tvåstegsflöde:

Steg 1 — Kategoriträdet: Användaren bläddrar i L1 → L2 → L3 eller söker fritext. Sökresultaten visar hela sökvägen som brödsmula. Alla icke-lövnoder (L1, L2) har en "Välj"-knapp som fallback om man vill stoppa på mellannivå.

Steg 2 — Produktpicker: ProductPickerField.showSheet() öppnas filtrerad på den valda kategorins ID plus alla ättlingars ID:n (insamlade rekursivt via _collectIds()). Det innebär att valet av "Mat > Frukt & Grönt" ger alla produkter i samtliga L3-underkategorier.

L1: Mat
  L2: Frukt & Grönt          ← välj här → ger produkter från alla L3 nedan
    L3: Äpplen                ← välj här → ger bara äpple-produkter
    L3: Bananer
    L3: Citrusfrukter

Lösning 2: AI-direkthopp

Om preselectedCategoryId finns (AI-förslag) hoppar CategoryThenProductPicker.show() direkt till steg 2 utan att visa trädet. Användaren klickar på AI-chip → produktpickern öppnas direkt med rätt kategorifilter. Om kategorin inte hittas i trädet visas trädet som fallback.

Lösning 3: Privata produkter

Problemet: POST /products (admin) skapar globala produkter. Vanliga användare ska kunna skapa egna produkter utan att smutsa ned den globala produktkatalogen.

Modell: Product fick ett nytt fält isPrivate: Boolean @default(false). Privata produkter är kopplade till en specifik användare via ownerId och syns bara för den användaren.

normalizedName-prefix: För att undvika kollision i det unika indexet prefixas privata produkters normaliserade namn med private:{userId}:. Det innebär att "röda äpplen" för användare 5 lagras som private:5:roda applen och kolliderar varken med en global produkt med samma namn eller en annan användares privata produkt.

Nya endpoints:

Endpoint Åtkomst Beskrivning
GET /products Publik Returnerar bara globala produkter (isPrivate: false)
GET /products/mine Inloggad Returnerar inloggad användares privata produkter
POST /products/private Inloggad Skapar en privat produkt kopplad till inloggad användare

Migration: backend/prisma/migrations/20260501000000_add_product_is_private/migration.sql

Flutter-integration

  • _loadProducts() i receipt_import_tab.dart laddar GET /products och GET /products/mine parallellt via Future.wait och slår ihop till en lista.
  • _EditDialogState håller en lokal _localProducts-kopia som utökas om en ny produkt skapas under dialogen — utan ytterligare nätverksanrop.
  • "Skapa ny"-dialogen i produktpickern är förifylld med den aktuella söksträngen för snabbt namngivande.

Arkitektur: User-scope för baslager och matplan (2026-04-22)

Designregel: User-scope vid ny funktionalitet

All data som tillhör en specifik användare måste ha ett userId-fält med FK till User och user-filtrering i service-lagret. Följ detta mönster vid all ny funktionsutveckling:

  1. Databasschema: userId Int med @relation till User, icke-null. Sammansatt @@unique([userId, ...]) om raden ska vara unik per användare.
  2. Migration: Backfill till första användaren för befintliga rader innan kolumnen sätts NOT NULL.
  3. Backend service: Alla findAll/findOne/create/delete filtrerar eller sätter where: { userId }. Returnera aldrig andra användares data.
  4. Controller: Extrahera userId via @CurrentUser() eller @Request() req — aldrig från body (säkerhetsrisk).
  5. Globala vs. privata resurser: Produkter och kategorier är globala. Inventarie, baslager, matplan och användarspecifika produkter (isPrivate: true) är user-scopade.
  6. Privata produkter (isPrivate: true): skapas via POST /products/private, visas via GET /products/mine. Normaliserat namn prefixas med private:{userId}: för att undvika kollision med globala produkter.

Kontrollera alltid: Kan två användare se varandras data? Om ja — lägg till userId-scope.

Bakgrund och beslut

Baslager (PantryItem) och matplan (MealPlanEntry) var ursprungligen globala — delade av alla användare. Det skapade problem när flera användare loggade in eftersom de såg och påverkade varandras data.

Beslut 2026-04-22: Baslager och matplan är user-scopade. Varje inloggad användare har ett eget, isolerat baslager och en egen matplan.

Databasschema-ändringar

Båda modellerna fick ett userId-fält med FK till User och nya sammansatta unika nycklar:

Modell Gammal unik nyckel Ny unik nyckel
PantryItem @@unique([productId]) @@unique([userId, productId])
MealPlanEntry @@unique([date]) @@unique([userId, date])

User-modellen fick relationslistor: pantryItems PantryItem[] och mealPlanEntries MealPlanEntry[].

Migration

Migration: backend/prisma/migrations/20260422130000_user_scope_pantry_meal_plan/migration.sql

Viktiga steg i migrationens SQL:

  1. Lägg till userId-kolonn (nullable) i PantryItem och MealPlanEntry.
  2. Backfill userId till (SELECT id FROM User LIMIT 1) för alla befintliga rader.
  3. Sätt userId NOT NULL.
  4. Lägg till icke-unik index PantryItem_productId_idx innan det gamla unika indexet tas bort (MySQL/MariaDB kräver att ett FK-index finns kvar när ett annat tas bort).
  5. Droppa gammalt unikt index PantryItem_productId_key.
  6. Lägg till nytt sammansatt unikt index @@unique([userId, productId]) och FK till User.
  7. Droppa MealPlanEntry_date_key, lägg till @@unique([userId, date]) och FK till User.

Känd fallgrop: MariaDB ger fel 1553 om ett unikt index som används av en FK-constraint droppas utan att ett ersättande index finns. Migrationens steg 4 (lägga till PantryItem_productId_idx innan drop) är lösningen på detta.

Prisma-kommentarsyntax: Inline #-kommentarer på @@unique-rader är ogiltig Prisma-syntax. Använd //-kommentarer på en separat rad ovanför attributet.

Controller/Service-mönster

Alla pantry- och matplan-endpoints extraherar userId via @CurrentUser()-dekoratorn:

// pantry.controller.ts
@Get()
findAll(@CurrentUser() user: { userId: number }) {
  return this.pantryService.findAll(user.userId);
}

@Post()
create(@CurrentUser() user: { userId: number }, @Body() dto: CreatePantryItemDto) {
  return this.pantryService.create(user.userId, dto);
}

@Delete(':id')
remove(@CurrentUser() user: { userId: number }, @Param('id') id: string) {
  return this.pantryService.remove(user.userId, +id);
}

Service-metoderna skickar alltid userId i Prisma where-klausuler:

// pantry.service.ts
findAll(userId: number) {
  return this.prisma.pantryItem.findMany({ where: { userId }, include: { product: true } });
}

create(userId: number, dto: CreatePantryItemDto) {
  // Kontrollera duplikat per användare
  const existing = await this.prisma.pantryItem.findUnique({
    where: { userId_productId: { userId, productId: dto.productId } }
  });
  if (existing) return existing;
  return this.prisma.pantryItem.create({ data: { userId, productId: dto.productId } });
}

remove(userId: number, id: number) {
  const item = await this.prisma.pantryItem.findFirst({ where: { id, userId } });
  if (!item) throw new NotFoundException();
  return this.prisma.pantryItem.delete({ where: { id } });
}

Påverkan på Next.js-frontenden

Inga funktionella ändringar krävdes i Next.js-frontenden. Alla anrop till /api/pantry och /api/meal-plan går via auth-proxies (withAuth) som automatiskt vidarebefordrar användarens Bearer-token. Backend extraherar userId från token via JWT-strategin.

Dubblerad död kod i PantryView.tsx och PantryList.tsx städades bort i samband med arbetet.

Receptimport och receptskaping — Detaljerad arkitektur

Syfte och struktur

Recipe App erbjuder tre vägar för att lägga till recept:

  1. Snabbimport — Klistra in ICA-länk för automatisk skrapning (ny feature)
  2. Skriv in recept (/recipes/write) — Markdown-baserad inmatning där användaren skriver receptet i enkelt format
  3. Importera från fil (/recipes/import) — Ladda upp PDF, bild eller länk och få en första Markdown-version automatiskt

Alla vägar möjliggör automatisk matchning av ingredienser mot databasen.

Strukturöversikt

Snabbimport-fältet

Frontend: /recipes/create/page.tsx

  • Ovanför de tvÃ¥ huvudvalen visas ett gult inmatningsfält för snabbimport
  • Användaren klistrar in en ICA-receptlänk eller filsökväg
  • Vid submit:
    1. Frontend skickar till /api/quick-import-proxy
    2. Proxy proxiar till backend POST /api/quick-import
    3. Backend returnerar Markdown-text
    4. Frontend sparar i sessionStorage('recipeMarkdown')
    5. Omdirigera till /recipes/write med förifylld Markdown

Backend: QuickImportService (ny modul)

  • Ansvarig för URL-import, PDF-tolkning, bild-OCR och Markdown-normalisering
  • Huvudmetoder:
    • importFromInput(input: string) — Detekterar URL eller serverfilsökväg
    • importFromUpload(file) — Hanterar uppladdad PDF eller bildfil
  • URL-specifik logik:
    • Validerar URL
    • Fetchar HTML via fetch() med User-Agent
    • Väljer site-specifik parser eller generisk fallback
    • Konverterar resultatet till Markdown-format
  • PDF-logik:
    • Extraherar text med pdf-parse
    • Stoppar om ingen läsbar text hittas
  • Bildlogik:
    • OCR via tesseract.js
    • Svensk och engelsk sprÃ¥kmodell (swe+eng)
  • Error-strategi:
    • 400 Bad Request — Tomt input eller saknad fil
    • 400 Bad Request — Ostödd filtyp eller ingen läsbar text
    • 503 Service Unavailable — Misslyckad PDF- eller OCR-behandling
    • 400 Bad Request — HTML-parsing eller hämtning misslyckades

API-endpoint:

POST /api/quick-import
Input:  { input: string }
Output: { markdown: string, source: 'ica' | 'pdf' | 'other' }

Proxy-route (Next.js):

  • /api/quick-import-proxy — Proxies till backend
  • Hanterar error-konvertering (BE HTTP → FE error message)
  • Returnerar Markdown eller JSON-error

Markdown-format och parsningsregler

Markdown-format och parsningsregler

Format:

# Receptnamn

Valfri beskrivning av receptet.

## Ingredienser
- 500 g köttfärs
- 1 st lök
- 2.5 msk tomatpuré
- 1 dl grädde (vispgrädde)
- salt

## Tillvägagångssätt
Stek löken i lite smör. Tillsätt köttfärsen…

Parsningsregler i detalj:

Element Tolkning
# Rubrik Receptnamn (första H1)
Text mellan H1 och ## Ingredienser Receptbeskrivning (flera rader OK, valfritt)
## Ingredienser Ingrediens markerare (case-insensitive)
- ANTAL ENHET NAMN Ingrediens: quantity=ANTAL, unit=ENHET, name=NAMN
- ANTAL NAMN Ingrediens utan enhet: unit sätts till "st"
- NAMN Ingrediens utan kvantitet: quantity=0, unit=""
(text i parentes) Ingrediensnot (sparas, t.ex. "vispgrädde")
## Tillvägagångssätt / ## Instruktioner Instruktionssektion markerare
Text under instruktioner Tillagningsinstruktioner (flera rader OK)

Exempel ingrediensparsning:

"- 500 g köttfärs"              → {quantity: 500, unit: "g", rawName: "köttfärs"}
"- 1,5 dl grädde (vispgrädde)"  → {quantity: 1.5, unit: "dl", rawName: "grädde", note: "vispgrädde"}
"- 3 ägg"                       → {quantity: 3, unit: "st", rawName: "ägg"}
"- salt"                        → {quantity: 0, unit: "", rawName: "salt"}

Komponenter och dataflöde

2. Backend: POST /api/recipes/parse-markdown endpoint

  • @prisma/client — Databasaccess
  • Common utils: normalize-name.ts

Processflöde:

1. Motta: ParseMarkdownDto { markdown: string }
2. Anropa parseRecipeMarkdown() → ParsedRecipe
3. Hämta alla aktiva produkter från DB
4. För varje ingrediens:
   a. Normalisera: lowercase + trim + remove accents/punctuation
   b. Matchningsalgoritm (se nedan)
   c. Top 5 förslag sortera efter score
5. Returnera ParsedRecipe + suggestions

Matchningsalgoritm:

Normalisering: lowercase + trim + åäö-handling + skilljetecken-borttagning

1. EXAKT MATCH (100 poäng)
   IF (ingrediens == product.canonicalName_normalized) OR 
      (ingrediens == product.normalizedName_normalized)
   THEN score = 100

2. DELSTRÄNG-MATCH (70 poäng)
   IF (ingrediens IN product.name_normalized) OR
      (product.name_normalized IN ingrediens)
   THEN score = 70

3. LEVENSHTEIN-LIKHET (40–100 poäng)
   Calculate Levenshtein distance between ingrediens and product.name_normalized
   similarity% = (1 - (distance / max_length)) * 100
   IF similarity% >= 40 THEN score = similarity%
   ELSE filter out

Sortering: Högsta poäng först
Top 5: Max 5 förslag per ingrediens

Svarsobjekt:

{
  "name": "Köttfärssås",
  "description": "En klassisk…",
  "instructions": "Stek löken…",
  "ingredients": [
    {
      "rawName": "köttfärs",
      "quantity": 500,
      "unit": "g",
      "note": null,
      "suggestions": [
        { "productId": 12, "productName": "Köttfärs", "score": 100 },
        { "productId": 34, "productName": "Blandfärs", "score": 65 },
        { "productId": 56, "productName": "Nötfärs", "score": 55 }
      ]
    }
  ]
}

3. Frontend: Receptskapsidor

Huvudmeny: /recipes/create/page.tsx

  • Presenterar tvÃ¥ val-kort (card-baserad UI)
  • "Skriv in recept" → /recipes/write
  • "Importera frÃ¥n fil/länk" → /recipes/import

Skriv in recept: /recipes/write/WriteRecipePage.tsx

  • Main client component (3-steps state machine)
  • Samma logik som tidigare ImportRecipePage
  • Steg 1: Markdown-inmatning
  • Steg 2: Granska ingredienser, välj produkter
  • Steg 3: Spara recept
  • Använder /api/parse-markdown-proxy för backend-anrop

Importera från fil: /recipes/import/ImportFilePage.tsx

  • Tabs/toggle mellan tvÃ¥ metoder:
    1. Fil-upload — Dra-och-släpp eller välja PDF/TXT/DOCX
    2. URL-import — Ange länk till receptsida
  • Placeholder för framtida integration
  • Visar tips för att använda "Skriv in recept" tills dessa funktioner är klara

4. API-proxy-route (Next.js)

/api/parse-markdown-proxy/route.ts

  • POST-endpoint
  • Proxies anrop till backend POST /api/recipes/parse-markdown
  • Hanterar CORS, headers, error-svarsöversättning

Matplanering och portionsjustering — Detaljerad arkitektur

Syfte

Matplaneringsfunktionen låter användaren planera veckans måltider dag för dag och generera en inköpslista automatiskt. Portionsjusteringen gör det möjligt att anpassa mängden per dag utan att ändra receptet — t.ex. laga en dubbel sats en dag.

Dataflöde

Användaren väljer recept + portionsantal för ett datum
  → POST /api/meal-plan  { date, recipeId, servings }
  → MealPlanEntry upserteras (unik per datum)

Veckovy hämtar alla poster i intervallet
  → GET /api/meal-plan?from=...&to=...

Inköpslista genereras
  → GET /api/meal-plan/shopping-list?from=...&to=...
  → Varje ingredient × scale (entry.servings / recipe.servings, eller 1 om ej satt)
  → Aggregerat per produkt + enhet

Inventariejämförelse
  → GET /api/meal-plan/inventory-compare?from=...&to=...
  → Samma aggregering, sedan jämförs mot aktuell inventarie
  → Status: räcker | saknas | enhetskonflikt

Frontend: MealPlanClient

  • Veckovy renderar en kolumn per dag med aktuellt recept
  • Om receptet har servings satt visas ett portionsinmatningsfält direkt i dagsvyn
  • Avviker inmatat portionsantal frÃ¥n receptets grundvärde visas en Ã¥terställningsknapp (↩ N portioner)
  • handleServingsChange() POSTar direkt till backend och uppdaterar lokal state utan sidomladdning

Portionsskalning i backend

const scale = recipeServings && entryServings
  ? entryServings / recipeServings
  : 1;

// Exempel: recept för 4, vill laga 6 → scale = 1.5
// 200 g pasta → 300 g pasta i inköpslistan

Enhetskonvertering (backendsida)

Stödda enhetstyper

Typ Enheter Bassystem
Vikt g, kg gram (g)
Volym ml, dl milliliter (ml)
Portioner tsk, msk tesked (tsk), där 1 msk = 3 tsk
Stycken st kan inte konverteras

Normalisering (inom RecipesService.normalizeUnit())

Input Output Typ
"tesked", "test" "tsk" Portion
"matsled", "matsked" "msk" Portion
"gram" "g" Vikt
"kilogram", "kilo", "kg" "kg" Vikt
"milliliter" "ml" Volym
"deciliter" "dl" Volym
"stycke" "st" Styck
(other) (as-is) (as-is)

Konverteringslogik (convertUnit() metod)

convertUnit(quantity: number, fromUnit: string, toUnit: string, productName: string): number {
  // 1. Validering (quantity > 0, units inte tomma)
  // 2. Normalisera båda enheter
  // 3. Om identiska efter normalisering → return quantity
  // 4. Bestäm enhetstyp för båda
  // 5. Om olika typer → throw Error
  // 6. Konvertera via basenhet:
  //    quantity * factor[fromUnit] / factor[toUnit]
}

Användning i getInventoryPreview()

Då receptjämförelse jämförs mot inventarie:

För varje ingrediens:
  1. Hämta alla inventarieföremål för denna produkt
  2. Gruppera efter enhet:
     - Samma enhet som ingrediens → summera direkt
     - Annan enhet → försök konvertera
  3. Om konvertering misslyckas (t.ex. st ↔ g) → hoppa över denna post
  4. Summera totalt available (samma + konverterad)
  5. Returnera status: räcker | saknas | enhetskonflikt

Infrastruktur & DevOps

Docker Compose setup

Services:

  • recipe-frontend — Next.js container (port 3000)
  • recipe-api — NestJS backend (port 8080)
  • db — MariaDB database (port 3306)
  • proxy — (valfritt) Caddy reverse proxy

Volumer:

  • Database data persistence
  • Build layers caching

Networks:

  • External proxy network (för Caddy integrering, valfritt)
  • Intern recipe-network mellan services

Backend-Dockerfile (3-stage build)

Stage 2: builder

FROM node:22-alpine AS builder
# Installera backend-deps
# Kopiera converter från stage 1
# Generera Prisma-klient
# Bygg NestJS-appen (nest build)

Stage 3: runner

FROM node:22-alpine AS runner
# Minimal production image
# Enbart dist/, node_modules/, prisma/

Build-kommando:

docker compose build recipe-api
docker compose up -d recipe-api

Frontend-Dockerfile

Standard Next.js build → standalone output

Miljövariabler

Konfigureras via .env eller docker compose up:

  • DATABASE_URL — MariaDB-anslutning (backend)
  • PORT — Backend port (default 8080)
  • Ev. Caddy-konfiguration

UTF-8 och lokalisering

  • Database: utf8mb4
  • Backend: Normaliseringsfunktion hanterar åäö
  • Frontend: Svenska felmeddelanden och UI-text
  • Endpoints: Svenska benämningar och kategori-namn

Säkerhet & Utbyggbarhet

  • Autentisering: JWT-baserad, 7 dagars token. Auth.js v5 (Credentials provider) i frontend. Alla backend-routes skyddas av JwtAuthGuard — öppna endpoints markeras med @Public().
  • Middleware: middleware.ts skyddar alla Next.js-routes utom /login, /register och /api/auth. Oinloggade användare omdirigeras automatiskt.
  • Validering: Alla DTO:er valideras med class-validator. Inkommande fält i server actions bör kompletteringsvalideras (se teknisk skuld E).
  • Felhantering: GlobalExceptionFilter fÃ¥ngar alla oupphanterade fel och returnerar svenska felmeddelanden.
  • CORS: API-anrop proxias via Next.js API routes — klientkod nÃ¥r aldrig backenden direkt.
  • Filuppladdning: Multer med memoryStorage och MIME-typvalidering; max 15 MB för kvittoimport.

Möjliga utbyggnader

  • Användarroller (user / admin) — rollbaserad guard, skyddade admin-routes
  • Delade recept / recept-export
  • Push-notifieringar för utgÃ¥ngna varor
  • Nutrition-baserat receptförslag
  • Allergi-tracking per användare

Databaskonfiguration och seeding

Databaskonfiguration

MariaDB 11 hostas i Docker-container (recipe-db) enligt compose.yml. Databasen använder:

  • Teckenuppsättning: UTF-8 (utf8mb4) för korrekt stöd för svenska tecken
  • Sortering: utf8mb4_swedish_ci för svensk alfabetisk sortering
  • Anslutning: Via Prisma ORM frÃ¥n backend-tjänsten

Initialisering:

  1. db/init/001-init.sql körs vid första container-start (grundläggande tablestatus)
  2. Prisma migrations körs automatiskt vid backend-start
  3. db/seeds/seed_all.sql körs manuellt för att fylla på initiala kategorier och produkter

seed_all.sql — Kategori- och produktdatabas

Filplats: db/seeds/seed_all.sql

Syfte: seed_all.sql är ensam sanningskälla för kategoriträdet. Den innehåller:

  1. Kategoristruktur — Hierarkiskt träd från nivå 1 (toppkategorier) till nivå 3 (underkategorier)
  2. Initiala produkter — 200+ standardprodukter (köttfärs, ost, frukt, etc.)
  3. Kategoritilldelning — Kopplar produkterna till rätt kategorier

Struktur:

Steg Vad Detaljer
RESET Förberedelse SET foreign_key_checks = 0 → TRUNCATE TABLE Category → UPDATE Product SET categoryId = NULL
STEG 1 Kategorier NIVÅ 1-3 INSERT INTO Category för alla toppkategorier, nivå 2 och nivå 3-kategorier. Använder SELECT med JOINs för hierarkisk struktur.
STEG 2 Produkter INSERT IGNORE INTO Product — Befintliga produkter hoppas över (inga dubletter).
STEG 3 Kategoritilldelning UPDATE Product ... WHERE name IN (...) för att tilldela rätt categoryId baserat på produktnamn.

Köra seed_all.sql:

# Hämta lösenordet från .env och kör kommandot:
DB_PASS=$(grep MARIADB_ROOT_PASSWORD .env | cut -d= -f2)
docker exec recipe-db mariadb -uroot -p"$DB_PASS" recipe_app -e "SHOW TABLES;"

Viktigt: Denna kommando kan köras hur många gånger som helst:

  • TRUNCATE raderar alla befintliga kategorier (men inte produkterna själva)
  • INSERT IGNORE förhindrar dubbletter av produkter
  • Befintliga produkt-data (namn, märke, etc.) pÃ¥verkas inte — endast categoryId uppdateras

Fördelar med denna design:

  • ✅ Reproducerbar — idempotent, kan köras flera gÃ¥nger
  • ✅ Enkel migrering — alla ändringar i kategoriträdet är i en fil
  • ✅ Ingen datarisk — produkter försvinner aldrig, enbart omkategorisering
  • ✅ Admin-gränssnittet kan senare modifiera kategorier interaktivt (seed_all är enbart för initial setup)

Kategoristruktur

Nivå 1 (Toppkategorier, exempel):

  • Bröd & Kakor
  • Dryck
  • Färdigmat
  • Fryst
  • Frukt & Grönt
  • Glass, godis & snacks
  • Kött, chark & fÃ¥gel
  • Mejeri, ost & ägg
  • Skafferi
  • Fisk & Skaldjur
  • Vegetariskt

Nivå 2 (Underkategorier, exempel):

  • Bröd & Kakor > Bröd, Matbröd, Rostbröd,Fastfoodbröd, Kex & Kakor, Knäckebröd & Skorpor
  • Mejeri, ost & ägg > Ost, Allergi mejeri, Mjölk, Filmjölk & Yoghurt, Matlagning, Smör/margarin & jäst, Havre-/Soja-/Risdryck, Kvarg & Cottage cheese
  • Frukt & Grönt > Grönsaker

Nivå 3 (Specifika kategorier, exempel):

  • Bröd & Kakor > Bröd > Matbröd, Rostbröd
  • Mejeri, ost & ägg > Mjölk > Standard mjölk
  • Frukt & Grönt > Grönsaker > Sallad & KÃ¥l

Uppdatering av kategorier

Nya kategorier läggs till direkt i db/seeds/seed_all.sql. Efter ändringar:

# Kör seed_all.sql igen för att uppdatera kategoriträdet
docker exec -i recipe-db mariadb -uroot -p"$DB_PASS" recipe_app < db/seeds/seed_all.sql

Produkterna behålls och omkategoriseras enligt de nya UPDATE-satserna.


Framtida projekt - Companion-projekt

Microservice Importer

Recipe App har ett companion-projekt för receptimport: microservice-importer

Nuläge

Quick-import-funktionen är för närvarande integrerad i Recipe App med full funktionalitet.

Framtida möjlighet

I framtiden kan snabbimport-logiken extraheras till en standalone microservice för:

  • Oberoende scaling
  • Enklare API-integration med andra system
  • Lägre komplexitet (ingen databaskonfiguration)
  • Docker kontainer

Se microservice-importer README för komplett dokumentation och deployment-instruktioner när separation blir aktuell.

Arkitektur för Microservice Importer

Se ovan samt nedan: Gemensam arkitektur för companion-projekt

Microservice Shopping/Todo-lista

En möjlig framtida utveckling är ett separat companion-projekt till Recipe App i form av en standalone microservice för shopping/todo-lista. Denna tjänst ska:

  • Vara helt oberoende av Recipe App, men kunna interagera med den
  • Erbjuda sömlös integrering mellan sig själv och Recipe App
  • Inte kräva nÃ¥gon avancerad databaskonfiguration, utan endast använda en enkel filbaserad databaslösning inom appen
  • Docker kontainer

Syftet är att möjliggöra delad eller fristående användning av shopping/todo-listor, med enkel integration för användare som vill koppla ihop funktionaliteten med Recipe App.

Arkitektur för Microservice Shopping/Todo-lista

Se ovan samt nedan: Gemensam arkitektur för companion-projekt

Gemensam arkitektur för companion-projekten

Databasval: Companion-projekten ska använda SQLite som databas, eftersom det möjliggör återanvändning av datamodell och queries mellan backend och native app (iOS/Android). SQLite stöds direkt i Node.js, iOS och Android och gör det enkelt att dela databasstruktur och logik mellan olika plattformar. Gemensam teknisk grund och säkerhet: Både microservice-importer och companion-projektet för shopping/todo-lista ska dela teknisk grund med recipe-app, särskilt vad gäller säkerhetslösningar och API-hantering. Detta innebär:

  • JWT-baserad autentisering och rollhantering, med tokens som används för att skydda API-endpoints
  • API-design enligt REST-principer, med tydlig separation mellan autentiserade och publika endpoints
  • Proxy-lösning (Caddy) för att styra trafik och säkerställa att rätt endpoints skyddas
  • Ã…teranvändning av auth-middleware och utility-funktioner för tokenhantering
  • Felhantering och svar i JSON-format

I recipe-app används Auth.js v5 (NextAuth) för autentisering, JWT-sessioner, och globala guards i backend (NestJS) för att skydda alla API-routes. Frontend och backend kommunicerar via tydliga API-routes, och klientkomponenter använder utility-funktioner för att alltid skicka med rätt auth-token. Samma principer och kodmönster ska tillämpas i båda microservices för enhetlig säkerhet och API-hantering.

Databasval: Microservice-importer ska använda SQLite som databas, av samma skäl som companion-projektet för shopping/todo-lista: enkel filbaserad setup, portabilitet och möjlighet att återanvända datamodell och queries mellan backend och native app (iOS/Android).


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-12: Alias-scopehantering (admin), kategori-chip, beroendehärdning och quality-gates

Alias-scopehantering (admin → global)

  • Ny backend-regel: Admin kan ändra ett privat alias till globalt via PATCH /receipt-alias/:id. Vanliga användare blockeras med ForbiddenException om de försöker ändra isGlobal.
  • Global → privat skyddad: Om ett globalt alias saknar ownerId returneras BadRequestException vid försök att göra det privat — systemet hindrar föräldralösa privata alias.
  • DTO utökat: UpdateReceiptAliasDto har fått isGlobal?: boolean med @IsBoolean().
  • Admin-UI: Admin alias-panelen visar nu en switch för att höja ett privat alias till globalt. Switchen är låst (disabled) om aliaset redan är globalt — riktning är enbart privat → global. Bekräftelse-chip visas temporärt (6 s) i listan efter scope-byte.
  • Tester: Nya tester täcker:
    • Admin kan höja privat → global.
    • Vanlig användare blockeras från scope-ändring.
    • Global → privat utan owner ger BadRequestException.
    • Controller delegerar korrekt DTO med isGlobal till service.

Kategori-chip

  • Fallback-chip för tom kategoriväg returnerar nu SizedBox.shrink() globalt i den gemensamma chip-buildern — "okänd"-chip visas inte längre i admin- eller importpaneler.

Beroendehärdning (Nest 11 + multer 2.1.1)

  • Backend uppgraderat till NestJS 11 (@nestjs/common/core/platform-express/testing/jwt/passport 11.x).
  • multer uppgraderat till 2.1.1 — eliminerar tidigare high-severity CVE i transitivt beroende.
  • @types/express uppgraderat till 5.0.5, @nestjs/cli 11.0.21, @nestjs/schematics 11.1.0.
  • JWT_SECRET fail-fast check tillagd i jwt.strategy.ts; JWT_SECRET satt i CI-env.
  • npm audit --audit-level=high rapporterar 0 sårbarheter.

Quality-gates (npm scripts + CI)

Nya scripts i backend/package.json:

Script Kommando
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

CI-workflow (.gitea/workflows/test.yml) uppdaterad:

  • Alla Prisma-steg använder nu npm run prisma:validate.
  • Audit-steg använder npm run audit:high.
  • Nytt typecheck-steg tillagt i både PR-snabbjobb och push-fulljobb.
  • npm ci används genomgående (var npm install i delar av flödet).

Prisma Query Logging

För att aktivera Prisma query logging i testmiljön:

  1. Sätt miljövariabeln PRISMA_LOG_QUERIES=1 i din miljö.

    • För Docker Compose: lägg till i .env-filen eller direkt i compose.yml under environment.
    • Exempel för compose.yml:
      environment:
        PRISMA_LOG_QUERIES: "1"
      
  2. Starta om containern så att den nya miljövariabeln laddas:

    docker compose up -d recipe-api
    
  3. Verifiera att loggar visas i backendens konsol eller loggfil. Loggarna inkluderar alla SQL-frågor, parametrar och exekveringstider.

Notera:

  • Aktivera endast i test/staging, inte i produktion.
  • Loggarna kan vara omfattande och påverka prestanda.
  • Variabeln är avsiktligt inte dokumenterad i huvudkonfigurationen för att undvika oavsiktlig aktivering.