Files
recipe-app/TEKNISK_BESKRIVNING.md
T

58 KiB
Raw Blame History

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 AI-FUNKTIONER.md för planerade AI-funktioner och modellval.

Ö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.


Versionsinformation

Delsystem Teknik Version
Frontend Next.js 16.2
React 19.2
TypeScript 5.4.5
Node 22.x (@types/node 22.15.29)
Backend NestJS 10.3
Prisma 6.12.0
TypeScript 5.4.5
Node 22.x (@types/node 22.15.29)
Databas MariaDB 11
Proxy Caddy 2.x
Container Docker 24+
Converter Node.js (TypeScript) Noll externa beroenden

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;"

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.

recept.gynther.se {
    import common

    # === IMPORT SERVICE (Document Converter) ===
    # Måste komma FÖRE backend-reglerna
    handle /api/recipes/import* {
        reverse_proxy recipe-import-service:3000
    }

    # === NEXT.JS PROXY-ROUTES (frontend-hanterade) ===
    # Dessa routes innehåller server-side logic (auth, aggregering, mm)
    # och måste gå till frontend, INTE backend
    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
    }

    # === BACKEND API-ENDPOINTS ===
    handle /api/products* {
        reverse_proxy recipe-api:8080
    }

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

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

    handle /health {
        reverse_proxy recipe-api:8080
    }

    # === CATCH-ALL: Övriga /api/* → frontend ===
    # Fångar upp alla Next.js API routes som inte explicit
    # listats ovan, t.ex. /api/admin/*, /api/auth/*, /api/categories, mm.
    handle /api/* {
        reverse_proxy recipe-frontend:3000
    }

    # Alla övriga requests → frontend
    reverse_proxy /* recipe-frontend:3000
}

Viktig regel: Caddy-routing och Next.js API routes

Caddy-regeln handle /api/products* fångar upp ALLT som börjar med /api/products — inklusive sökvägar som /api/products-create eller /api/products-update. Det innebär att Next.js API routes som ska hanteras av frontend-containern INTE får ha sökvägar som börjar med products, inventory, recipes eller andra prefix som Caddy skickar till backend.

Next.js API routes som kräver server-side auth och ska gå via frontend måste ha prefix som hamnar i catch-all-blocket handle /api/* → recipe-frontend:3000. Exempel på säkra prefix:

Prefix Caddy-regel Destination
/api/admin/* catch-all recipe-frontend:3000
/api/categories catch-all recipe-frontend:3000
/api/auth/* catch-all recipe-frontend:3000
/api/products* explicit regel recipe-api:8080 ⚠️
/api/inventory* explicit regel recipe-api:8080 ⚠️

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 — Skyddar alla routes utom /login, /register och /api/*; blockerar /admin/* om sessionens role inte är admin. Next.js 16 använder proxy.ts istället för middleware.ts.
  • lib/auth-headers.tsgetAuthHeaders() hämtar Bearer-token från session (server-side, används i Next.js API route-proxies)
  • lib/api.tsfetchJson() lägger automatiskt till auth-headers server-side, redirectar till /login vid 401
  • lib/with-auth.tswithAuth(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.tsuseAuthFetch()-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* och /api/inventory* 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/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
  • 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: **40100 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(dto)`** — Skapar eller uppdaterar en `MealPlanEntry` för ett givet datum (unik per dag). Sparar `recipeId` och valfritt `servings`.
- **`findByRange(from, to)`** — Hämtar alla planerade dagar i ett datumintervall, inkl. receptinfo.
- **`shoppingList(from, to)`** — Aggregerar ingrediensmängder för alla 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 aktuellt inventarielager. Returnerar status per ingrediens: `räcker | saknas | enhetskonflikt`.

**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 alla baslagerartiklar (inkl. produktinfo) POST /api/pantry Lägg till produkt i baslagret DELETE /api/pantry/:id Ta bort produkt från baslagret


### 🗓️ Matplan-endpoints

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

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


### 🧾 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 → Laktosfri mjölk

Kategori-seed

Kategorier seedas på två sätt:

  1. Migrationen 20260417310000_add_category_tree/migration.sql — seedar grundläggande kategorier vid prisma migrate deploy (körs bara en gång).

  2. db/seeds/categories_supplement.sql — idempotent supplementfil med ytterligare kategorier (använder INSERT IGNORE). Körs automatiskt av deploy.sh vid varje deploy:

    docker exec -i recipe-db mariadb -uroot -p"$MARIADB_ROOT_PASSWORD" "$MARIADB_DATABASE" \
      < db/seeds/categories_supplement.sql
    

    Filen är säker att köra flera gånger — befintliga kategorier hoppas över. Lägg till nya kategorier i slutet av filen och kör deploy.sh för att applicera dem.

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())
  productId Int      @unique              # En produkt kan bara finnas en gång i baslagret
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

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

MealPlanEntry

model MealPlanEntry {
  id        Int      @id @default(autoincrement())
  date      DateTime # Datum för planerad måltid (en per dag)
  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

  recipe Recipe @relation(fields: [recipeId], references: [id])

  @@unique([date])   # Bara ett recept per dag
}

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.


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 Ingred 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

1. recipe-document-converter/ bibliotek

Modulstruktur:

  • src/parser.ts — Markdown-parser med ingrediensparsning
  • src/index.ts — Biblioteksexport
  • package.json — npm-paket (noll externa beroenden)
  • tsconfig.json — TypeScript-konfiguration

Huvudexport:

export function parseRecipeMarkdown(markdown: string): ParsedRecipe

interface ParsedRecipe {
  name: string;
  description: string;          # Kan vara tom
  instructions: string;          # Kan vara tom
  ingredients: ParsedIngredient[];
}

interface ParsedIngredient {
  rawName: string;     # Extraherat ingrediensnamn
  quantity: number;    # Numerisk kvantitet (eller 0 om ingen)
  unit: string;        # Enhet (eller "" om ingen)
  note: string | null; # Text i parentes (eller null)
}

Ingrediensparsning i detalj:

  • Försöker matcha regex (\d+(?:[.,]\d+)?)\s+(\S+)\s+(.+) för "ANTAL ENHET NAMN"
  • Fallback: (\d+(?:[.,]\d+)?)\s+(.+) för "ANTAL NAMN" (unit → "st")
  • Fallback: bara namn, quantity=0, unit=""
  • Kommatecken i tal: ,. (t.ex. "1,5" blir 1.5)
  • Parentes-extraktion: Matchar sista (text) i raden

Byggning:

  • TypeScript → JavaScript via tsc
  • Separerad npm-modul
  • Kompileras i Docker-build steg 1 (converter-build)

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

Klassbank:

  • recipe-document-converter — Markdown-parser
  • @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 (40100 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 1: converter-build

FROM node:22-alpine AS converter-build
# Bygg recipe-document-converter biblioteket

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/

Viktigt: backend/package.json har "recipe-document-converter": "file:../recipe-document-converter" för lokal dev. I Docker ignoreras denna; convertern kopieras in från stage 1.

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

Framtida arkitektur: 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)

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