Files
recipe-app/TEKNISK_BESKRIVNING.md
T

24 KiB
Raw Blame History

Teknisk beskrivning av Recipe App

Se README.md för användarinformation och kom-igång-guide.

Ö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

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)
  • Bygg: Standalone output, körs i Docker-container
  • API-anrop: Fetch mot backend och Next.js API routes
  • Felhantering: Global parseErrorResponse utility, svenska felmeddelanden

Frontend-sidor och komponenter

Sida Fil Funktionalitet
Hem app/page.tsx Startsida
Navigering app/Navigation.tsx Huvudmeny
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
Skapa recept app/recipes/create/page.tsx Receptkreation (manual form)
app/recipes/create/CreateRecipePage.tsx Komponenter för receptskapande
Importera recept app/recipes/import/page.tsx Startpunkt för Markdown-import
app/recipes/import/ImportRecipePage.tsx 3-stegsvyn för Markdown-import
Recipe detail app/recipes/[id]/ Enskilt recept (detaljer, redigering)
Admin: Produkter app/admin/products/page.tsx Produktadmin-panel
AdminProductList.tsx Lista produkter, sök, sortera
NameForm.tsx Redigera produktnamn
CanonicalNameForm.tsx Redigera canonical name
MergePreviewForm.tsx Förhandsgranska merge

API-proxy routes (Next.js)

Route Metod Syfte
/api/parse-markdown-proxy POST Proxies POST /api/recipes/parse-markdown (Markdown-tolkning)
/api/inventory-history-proxy GET Proxies konsumtionshistorik
/api/recipe-preview-proxy GET Proxies receptförhandsvisning
/api/admin/merge-preview-proxy GET Proxies produktmerge-preview
/api/products GET Lista/proxies produkter

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
  • Felhantering: GlobalExceptionFilter (svenska felmeddelanden)
  • Hälsokontroll: /health endpoints
  • Bygg: nest build, körs i Docker-container

Backend-moduler och strukturen läsa

backend/src/
├── app.module.ts                    # Root module
├── main.ts                          # Startpunkt (port 8080)
├── common/
│   ├── filters/
│   │   └── global-exception.filter.ts   # Centraliserad felhantering
│   └── utils/
│       └── normalize-name.ts             # Namnormalisering
├── health/
│   ├── health.controller.ts         # GET /health, /health/db
│   ├── health.service.ts            # Hälsotillstånd-logik
│   └── 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
│   ├── products.service.ts          # Produktlogik
│   ├── products.module.ts
│   └── dto/
│       ├── create-product.dto.ts
│       ├── update-product.dto.ts
│       ├── merge-products.dto.ts
│       └── update-canonical-name.dto.ts
└── 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

Backend-funktioner

Health API:

  • Övergripande systemstatus (uptime, service info)
  • Databasspecifik hälsokontroll (responseTime, connection test)
  • Returnerar statusCode 200 eller 503

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

API-endpoints (fullständig lista)

🏥 Health endpoints

GET  /health                    Övergripande hälsakontroll (200/503)
GET  /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/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
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)

Datamodell (Prisma ORM)

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?         # Produktkategori
  isActive       Boolean @default(true)  # Soft-delete flag
  deletedAt      DateTime?       # Tidpunkt för soft-delete
  createdAt      DateTime @default(now())
  updatedAt      DateTime @updatedAt

  inventoryItems InventoryItem[]
  recipeIngredients RecipeIngredient[]
}

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
  instructions String? @db.Text  # Tillagningsinstruktioner (kan vara långt, stöder Markdown)
  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt

  ingredients RecipeIngredient[]
}

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])
}

Receptimport via Markdown — Detaljerad arkitektur

Syfte

Användaren kan importera ett recept skrivet i Markdown-format istället för att fylla i formularet manuellt. Systemet:

  1. Tolkar Markdown-format (namn, beskrivning, ingredienser, instruktioner)
  2. Matchar varje ingrediens mot produktdatabasen (intelligenta matchningar)
  3. Låter användaren granska förslag och välja rätt produkt
  4. Sparar receptet med valida ingredienser

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: /recipes/import page

Komponenter:

  • ImportRecipePage.tsx — Main client component (3-steps state machine)
  • Använder /api/parse-markdown-proxy för backend-anrop (Next.js proxy)

Steg 1: Klistra in Markdown

  • <textarea> för råtext
  • Knapp: "Tolka recept" → POST /api/parse-markdown-proxy
  • Error handling med svenska meddelanden

Steg 2: Granska och välj

  • Redigerbara fält: namn, beskrivning, instruktioner
  • För varje ingrediens:
    • Visar föreslagna produkter (top 5, prioriterad ordning)
    • Fallback: Dropdown med alla produkter i DB
    • Redigerbara fält: quantity, unit, note
    • Visuell markering (gul ram) för ingredienser utan vald produkt
    • Knapp: "Ta bort ingrediens"
  • Validering: Minst 1 ingrediens måste ha vald produkt

Steg 3: Spara

  • POST /api/recipes med:
    {
      "name": "...",
      "description": "... " eller undefined,
      "instructions": "..." eller undefined,
      "ingredients": [
        { "productId": 12, "quantity": 500, "unit": "g", "note": "vispgrädde" },
        { "productId": 34, "quantity": 1, "unit": "st", "note": undefined }
      ]
    }
    
  • Efter framgång: navigera till receptlistan

Enhetsstöd i UI:

  • Dropdown-alternativ: g, kg, hg, ml, dl, l, st, tsk, msk

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

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

  • Ingen auth i grundutförande (kan enkelt byggas på)
  • Validering: Alla DTO:er valideras med class-validator
  • Felhantering: GlobalExceptionFilter med svenska meddelanden
  • CORS: Proxies hanteras via Next.js API routes

Möjliga utbyggnader

  • Authentication (JWT, OAuth)
  • Multi-user support
  • Shoppinglistor
  • Recept-delning
  • Nutrition facts
  • Allergi-tracking