Files
recipe-app/TEKNISK_BESKRIVNING.md
T

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

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

Frontend

  • 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
Inloggning app/login/page.tsx Inloggningssida med Auth.js Credentials
Profil app/profil/page.tsx Redigera firstName, lastName, email
app/profil/ProfileClient.tsx Klientkomponent för profilformulär
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: Produkter app/admin/products/page.tsx Produktadmin-panel
AdminProductList.tsx Lista produkter, sök, sortera, filter okategoriserade, bulk-select + bulk-kategorisering
EditProductForm.tsx Inline redigering: name, canonicalName, kategori (hierarkisk dropdown), brand, taggar
ResetProductsButton.tsx Knapp för att rensa all produktdata
MergePreviewForm.tsx Förhandsgranska merge
actions.ts Server actions: updateProduct, deleteProduct, resetAllProducts, bulkSetCategory
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 läser auth-token via auth() (Auth.js v5) och vidarebefordrar Authorization: Bearer <token> till backend.

Route Metod Syfte
/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
  • middleware.ts — Skyddar alla routes utom /login, /register och /api/auth
  • lib/auth-headers.tsgetAuthHeaders() hämtar Bearer-token från session (server-side)
  • lib/api.tsfetchJson() lägger automatiskt till auth-headers server-side, redirectar till /login vid 401

Frontend utbyggbarhet

  • Svenska felmeddelanden via lib/error-handler.ts (parseErrorResponse)
  • Centraliserad API-access via lib/api.ts (fetchJson)
  • 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, @Public() dekorator för öppna endpoints
  • 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
│   ├── auth.service.ts              # validateUser, login (JWT-signering)
│   ├── auth.module.ts
│   ├── jwt.strategy.ts              # Passport JWT-strategi
│   ├── jwt-auth.guard.ts            # Global guard (skyddar allt utom @Public)
│   └── decorators/
│       └── public.decorator.ts      # @Public()  markerar öppen endpoint
├── users/
│   ├── users.controller.ts          # GET/PATCH /api/users/me
│   ├── users.service.ts             # findByUsername, create, updateProfile
│   └── users.module.ts
├── categories/
│   ├── categories.controller.ts     # GET /api/categories, GET /api/categories/tree (@Public)
│   ├── categories.service.ts        # findAll (flat), findTree (hierarkisk)
│   └── 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
│   ├── products.service.ts          # Produktlogik inkl. resetAll()
│   ├── products.module.ts
│   └── 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)
│   ├── receipt-import.service.ts     # Mistral AI-anrop, bildtolkning
│   └── dto/
│       └── parsed-receipt-item.dto.ts
├── 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)`** — Tar emot en bildel 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.
- Stödda MIME-typer: `image/jpeg`, `image/png`, `image/webp`, `image/heic`, `image/heif`, `application/pdf`

---

## 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 }


### 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 (@Public) GET /api/users/me Hämta inloggad användares profil PATCH /api/users/me Uppdatera firstName, lastName, email


### 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
  createdAt    DateTime @default(now())
  updatedAt    DateTime @updatedAt
}

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
  isActive       Boolean @default(true)
  deletedAt      DateTime?
  createdAt      DateTime @default(now())
  updatedAt      DateTime @updatedAt

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

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)
  priority       Int?     # Prioritetsordning
  purchaseDate   DateTime?  # Köpdatum
  opened         Boolean?   # Markering för öppnad produkt
  shelfNote      String?    # Lagringsnot
  suitableFor    String?    # Lämplighetsmärkning (t.ex. "vegetarian")
  isOnSale       Boolean?   # Är på rea
  priceLevel     Int?       # Priskategori (15)
  bestBeforeDate DateTime?  # Bäst före-datum
  proteinType    String?    # Proteintyp (t.ex. "beef", "chicken")
  isLeftover     Boolean?   # Är från tidigare lagnning
  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.