1058 lines
44 KiB
Markdown
1058 lines
44 KiB
Markdown
# Teknisk beskrivning av Recipe App
|
||
|
||
> Se [README.md](README.md) för användarinformation och kom-igång-guide.
|
||
> Se [NEXT_STEPS.md](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.:
|
||
```bash
|
||
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.ts` — `getAuthHeaders()` hämtar Bearer-token från session (server-side)
|
||
- `lib/api.ts` — `fetchJson()` 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: **40–100 poäng** (under 40 filtreras bort)
|
||
- Top 5 förslag per ingrediens
|
||
- Sortering: Högsta poäng först
|
||
- **Inventory-preview:** Jämför recept mot inventarie
|
||
- Returnerar status för varje ingrediens: räcker | saknas | enhetskonflikt
|
||
- Automatisk enhetskonvertering vid jämförelse
|
||
- **Normalisering:** `normalize-name()` utility för consistent namn-matching
|
||
|
||
**Produkt-API:**
|
||
- CRUD för produkter (create, read, update, delete)
|
||
- **Duplicate detection:** `findDuplicates()` - Hitta produkter med samma normalizedName
|
||
- **Merge-preview:** `previewMerge()` - Förhandsgranska merge operation (visa inventory-counts, outcome)
|
||
- **Merge operation:** `merge()` - Slå ihop två produkter
|
||
- Flytta alla inventarieföremål från källa till mål
|
||
- Soft-delete källan (isActive = false, deletedAt = nu)
|
||
- Uppdatera recept-ingredienser från källa till mål
|
||
- **Canonical name management:**
|
||
- `updateCanonicalName()` - Uppdatera canonical name för ett produktnamn
|
||
- `backfillCanonical()` - Fylla på canonical names för alla produkter (admin-funktion)
|
||
- **Soft delete & restore:**
|
||
- `remove()` - Soft-delete produkt (isActive = false)
|
||
- `restore()` - Återställ borttagen produkt
|
||
- **Bulk-uppdatering:** `bulkUpdate(ids, data)` — Uppdatera ett godtyckligt antal produkter i ett enda DB-anrop (`updateMany`). Används primärt för bulk-kategorisering i admin-UI. Body: `{ ids: number[], categoryId?: number | null }`
|
||
|
||
**Matplan-API:**
|
||
- **`upsert(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
|
||
```prisma
|
||
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:
|
||
```bash
|
||
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
|
||
```prisma
|
||
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
|
||
```prisma
|
||
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 (1–5)
|
||
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
|
||
```prisma
|
||
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
|
||
```prisma
|
||
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
|
||
```prisma
|
||
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
|
||
```prisma
|
||
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
|
||
```prisma
|
||
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
|
||
```prisma
|
||
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:**
|
||
```markdown
|
||
# 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:**
|
||
```typescript
|
||
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 (40–100 poäng)
|
||
Calculate Levenshtein distance between ingrediens and product.name_normalized
|
||
similarity% = (1 - (distance / max_length)) * 100
|
||
IF similarity% >= 40 THEN score = similarity%
|
||
ELSE filter out
|
||
|
||
Sortering: Högsta poäng först
|
||
Top 5: Max 5 förslag per ingrediens
|
||
```
|
||
|
||
**Svarsobjekt:**
|
||
```json
|
||
{
|
||
"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
|
||
|
||
```typescript
|
||
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)
|
||
|
||
```typescript
|
||
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**
|
||
```dockerfile
|
||
FROM node:22-alpine AS converter-build
|
||
# Bygg recipe-document-converter biblioteket
|
||
```
|
||
|
||
**Stage 2: builder**
|
||
```dockerfile
|
||
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**
|
||
```dockerfile
|
||
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:
|
||
```bash
|
||
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`](../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](../microservice-importer/README.md) för komplett dokumentation och deployment-instruktioner när separation blir aktuell.
|