Files
recipe-app/TEKNISK_BESKRIVNING.md
T

1437 lines
65 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.
> Se [AI-FUNKTIONER.md](AI-FUNKTIONER.md) för planerade AI-funktioner och modellval.
## Översikt
Recipe App är en fullstack-applikation för hantering av hemmavaror, recept och matplanering. Systemet är byggt med Next.js (frontend), NestJS (backend), Prisma ORM och MariaDB. Applikationen är containeriserad med Docker och använder Caddy som reverse proxy.
---
## Versionsinformation
| Delsystem | Teknik | Version |
|-------------|----------------|-----------------|
| Frontend | Next.js | 16.2 |
| | React | 19.2 |
| | TypeScript | 5.4.5 |
| | Node | 22.x (@types/node 22.15.29) |
| Backend | NestJS | 10.3 |
| | Prisma | 6.12.0 |
| | TypeScript | 5.4.5 |
| | Node | 22.x (@types/node 22.15.29) |
| Databas | MariaDB | 11 |
| Proxy | Caddy | 2.x |
| Container | Docker | 24+ |
| Converter | Node.js (TypeScript) | Noll externa beroenden |
---
## Utvecklingsmiljö och deployment
### Infrastruktur
**Utvecklingsmiljön är en remote server** med följande struktur:
- **Server:** Dedikerad maskin med Docker installerat
- **SSH-baserad utveckling:** All utveckling, bygge och körning av applikationen sker via SSH-kommandon på servern
- **Lokal förberedelse:** Mindre arbetsuppgifter kan förberedas lokalt och sedan pushas till servern för slutförande
### Arbetsflöde
```
Lokal maskin
├─── Git-ändringar (commit) ──┐
│ │
└─── Push till Gitea-server ──→ Privat Gitea-instans
(i Docker-container)
Remote server
├─── git pull ────────────────┤
│ │
└─── docker compose build ─────┤
│ │
└─── docker compose up ───────┘
```
### Git-server (Gitea)
- **Gitea** körs i en Docker-container på servern
- **Privat git-server** för denna applikation
- Ändringar pushas lokalt till denna server:
```bash
git push origin main
```
### Bygge och körning på servern
Efter push till Gitea:
1. **SSH in på servern**
```bash
ssh user@server
cd /opt/containers/recipe-app
```
2. **Hämta senaste ändringar**
```bash
git pull origin main
```
3. **Bygg och starta applikationen**
```bash
docker compose build
docker compose up -d
```
Alla tjänster (frontend, backend, databas) startas via Docker Compose enligt `compose.yml`.
---
## 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;"
```
---
## Caddy-konfiguration (reverse proxy)
Caddy sitter framför applikationen och distribuerar trafik. Ordningen på `handle`-blocken är kritisk — Caddy väljer det första matchande blocket.
```caddy
recept.gynther.se {
import common
# === IMPORT SERVICE (Document Converter) ===
# Måste komma FÖRE backend-reglerna
handle /api/recipes/import* {
reverse_proxy recipe-import-service:3000
}
# === NEXT.JS PROXY-ROUTES (frontend-hanterade) ===
# Dessa routes innehåller server-side logic (auth, aggregering, mm)
# och måste gå till frontend, INTE backend
handle /api/inventory-history-proxy {
reverse_proxy recipe-frontend:3000
}
handle /api/admin/merge-preview-proxy {
reverse_proxy recipe-frontend:3000
}
handle /api/recipe-preview-proxy {
reverse_proxy recipe-frontend:3000
}
# === BACKEND API-ENDPOINTS ===
handle /api/products* {
reverse_proxy recipe-api:8080
}
handle /api/inventory* {
reverse_proxy recipe-api:8080
}
handle /api/recipes* {
reverse_proxy recipe-api:8080
}
handle /health {
reverse_proxy recipe-api:8080
}
# === CATCH-ALL: Övriga /api/* → frontend ===
# Fångar upp alla Next.js API routes som inte explicit
# listats ovan, t.ex. /api/admin/*, /api/auth/*, /api/categories, mm.
handle /api/* {
reverse_proxy recipe-frontend:3000
}
# Alla övriga requests → frontend
reverse_proxy /* recipe-frontend:3000
}
```
### Viktig regel: Caddy-routing och Next.js API routes
> **Caddy-regeln `handle /api/products*` fångar upp ALLT som börjar med `/api/products`** — inklusive sökvägar som `/api/products-create` eller `/api/products-update`. Det innebär att Next.js API routes som ska hanteras av frontend-containern **INTE** får ha sökvägar som börjar med `products`, `inventory`, `recipes` eller andra prefix som Caddy skickar till backend.
Next.js API routes som kräver server-side auth och ska gå via frontend måste ha prefix som hamnar i catch-all-blocket `handle /api/* → recipe-frontend:3000`. Exempel på säkra prefix:
| Prefix | Caddy-regel | Destination |
|--------|-------------|-------------|
| `/api/admin/*` | catch-all | `recipe-frontend:3000` |
| `/api/categories` | catch-all | `recipe-frontend:3000` |
| `/api/auth/*` | catch-all | `recipe-frontend:3000` |
| `/api/products*` | explicit regel | `recipe-api:8080` ⚠️ |
| `/api/inventory*` | explicit regel | `recipe-api:8080` ⚠️ |
---
## Arkitekturprincip: API routes framför Server Actions
> **Regel: Använd Next.js API routes (`/app/api/...`) för all mutation från klientkomponenter. Använd INTE Server Actions för detta.**
### Bakgrund
Next.js Server Actions returnerar alltid ett **RSC-payload** (React Server Component flight-format) som svar — även om funktionen bara returnerar ett vanligt JSON-objekt. När en klientkomponent anropar en Server Action via `startTransition` försöker React tolka svaret som ett siduppdateringspaket. Detta orsakar kraschen **"can't reload page"** / `TypeError: r is not iterable` i React 19 om sidans RSC-träd inte kan återskapas korrekt (t.ex. p.g.a. Caddy-routing, auth-state eller timing).
### Rätt mönster: Next.js API route
```
Klientkomponent → fetch('/api/admin/...') → Next.js API route → Backend API
```
- API routen körs server-side och har tillgång till sessionen via `auth()` → kan lägga till auth-headers
- Returnerar ren JSON — inga RSC-payload-problem
- Caddy-safe: använd `/api/admin/` som prefix (faller igenom till `recipe-frontend:3000`)
- Klientkomponenten hanterar UI-state lokalt efter svar (uppdatera/ta bort ur lokal state)
**Exempel** (se [app/api/admin/product/[id]/route.ts](frontend/app/api/admin/product/%5Bid%5D/route.ts)):
```ts
// API route (server-side, har session)
export async function PATCH(req, { params }) {
const authHeaders = await getAuthHeaders(); // använder auth()
const res = await fetch(`${API_BASE}/api/products/${id}`, { method: 'PATCH', headers: authHeaders, ... });
return Response.json(await res.json());
}
// Klientkomponent
const res = await fetch(`/api/admin/product/${id}`, { method: 'PATCH', body: JSON.stringify(data) });
const updated = await res.json();
setProducts(prev => prev.map(p => p.id === updated.id ? updated : p)); // lokal state-uppdatering
```
### När är Server Actions OK?
Server Actions kan fortfarande användas för operationer som **inte anropas från klientkomponenter med `startTransition`**, t.ex.:
- Form submissions i rena Server Components (inget `useTransition`)
- Admin-operationer som ändå triggar en helsidsladdning efteråt
### Befintliga undantag att känna till
Dessa Server Actions finns kvar men bör migreras om de orsakar problem:
- `bulkSetCategory` — anropas från `AdminProductList` (klientkomponent)
- `suggestProductCategory` / `suggestBulkCategories` — AI-kategorisering, anropas från klient
- **Framework:** Next.js 16.2 (App Router, server + client components)
- **Språk:** TypeScript 5.4.5
- **UI:** React 19.2, ingen CSS-ramverk (ren CSS-in-JS och inline-stilar)
- **Autentisering:** Auth.js v5 (next-auth beta), JWT-session, `auth()` i server components
- **Bygg:** Standalone output, körs i Docker-container
- **API-anrop:** `fetchJson` (server-side med auth-headers) + Next.js API route-proxies (client-side)
- **Felhantering:** Global parseErrorResponse utility, svenska felmeddelanden
> **Viktigt:** `Navigation.tsx` är en async server component som anropar `auth()`. Den får aldrig importeras av client components — rendera den alltid i `page.tsx` (server component).
### Frontend-sidor och komponenter
| Sida | Fil | Funktionalitet |
|------|-----|---|
| **Hem** | `app/page.tsx` | Startsida |
| **Navigering** | `app/Navigation.tsx` | Huvudmeny, inloggad användare, länk till profil; länkarna "⚙️ Admin", "⏳ Förslag" och "👥 Användare" visas enbart om sessionens roll är `admin` |
| **Inloggning** | `app/login/page.tsx` | Inloggningssida med Auth.js Credentials |
| **Profil** | `app/profil/page.tsx` | Flikbaserad profil-/adminsida (server component): läser `?tab=`-param, kontrollerar admin-roll via `auth()`, laddar rätt flik dynamiskt |
| | `app/profil/ProfileTabs.tsx` | Klientkomponent: fliknavigering med Link-basad URL-routing (`?tab=profil\|anvandare\|databas`) |
| | `app/profil/tabs/MinProfilTab.tsx` | Profilformulär (förnamn, efternamn, e-post) |
| | `app/profil/tabs/AnvandareTab.tsx` | Server component: hämtar användarlista, renderar AnvandareClient |
| | `app/profil/tabs/AnvandareClient.tsx` | Klientkomponent: skapa/ta bort användare, rollbyte, plan-byte (Free/Paid ✨ → isPremium), e-postbyte, lösenordsåterställning med kopierings-modal |
| | `app/profil/tabs/DatabsTab.tsx` | Server component: produktdatabas (importerar admin/products-komponenter) |
| **Inventorie** | `app/inventory/page.tsx` | Lista, filtrera, sortera varor |
| | `InventoryList.tsx` | Ritning av inventarieföremål |
| | `InventoryForm.tsx` | Skapa nytt inventarieföremål |
| | `InventoryEditForm.tsx` | Redigera inventarieföremål |
| | `InventoryConsumeForm.tsx` | Konsumera (brukat) inventarieföremål |
| | `InventoryConsumptionHistory.tsx` | Visa konsumtionshistorik |
| | `ProductForm.tsx` | Välja produkt för inventarieföremål |
| | `actions.ts` | Server actions för inventarie |
| **Recept** | `app/recipes/page.tsx` | Lista recept |
| | `RecipePreview.tsx` | Receptförhandsvisning med inventariestatus |
| **Lägg till recept** | `app/recipes/create/page.tsx` | Server component med Navigation |
| | `app/recipes/create/CreateRecipeClient.tsx` | Klientkomponent: snabbimport + metodval |
| **Skriv in recept** | `app/recipes/write/page.tsx` | Server component med Navigation |
| | `app/recipes/write/WriteRecipePage.tsx` | Markdown-baserat receptskapande (3-steg): Markdown-inmatning → ingrediensgranskning (produktval + portionsantal) → spara |
| **Importera från fil** | `app/recipes/import/page.tsx` | Startpunkt för fil/länk-import |
| | `app/recipes/import/ImportFilePage.tsx` | Fil-/länk-import (PDF, URL, etc) |
| **Matplan** | `app/matplan/page.tsx` | Matplanering (server component) |
| | `app/matplan/MealPlanClient.tsx` | Veckovy, receptval per dag, portionsjustering, inköpslista, inventariejämförelse |
| **Kvittoimport** | `app/import/page.tsx` | Server component med Navigation + flikvy |
| | `app/import/ImportTabsClient.tsx` | Klientkomponent: kvitto/recept-flikar |
| **Admin: Användare** | `app/admin/users/page.tsx` | Redirect till `/profil?tab=anvandare` |
| **Admin: Produkter** | `app/admin/products/page.tsx` | Produktadmin-panel |
| | `AdminProductList.tsx` | Lista produkter, sök, sortera, filter okategoriserade, bulk-select + bulk-kategorisering; AI-bulk-knapp ("✨ AI-kategorisera") med bekräftelsemodal |
| | `EditProductForm.tsx` | Inline redigering: name, canonicalName, kategori (hierarkisk dropdown), brand, taggar; "✨ Fråga AI"-knapp med suggestion-chip (grön = hög, gul = fallback) |
| | `ResetProductsButton.tsx` | Knapp för att rensa all produktdata |
| | `MergePreviewForm.tsx` | Förhandsgranska merge |
| | `actions.ts` | Server actions: updateProduct, deleteProduct, resetAllProducts, bulkSetCategory, suggestProductCategory, suggestBulkCategories, setProductStatus |
| **Admin: Väntande produkter** | `app/admin/products/pending/page.tsx` | Server component, auth-skyddad, hämtar pending-produkter |
| | `PendingProductsClient.tsx` | Tabell: Produkt / Kategori (AI) / Föreslagen av / Datum / Åtgärd; "✓ Godkänn" / "✕ Avvisa"-knappar |
| **Baslager** | `app/baslager/page.tsx` | Visa och hantera baslager (server component) |
| | `AddToPantryForm.tsx` | Lägg till produkt i baslager (dropdown) |
| | `PantryList.tsx` | Visa baslager grupperat per kategori |
| | `actions.ts` | Server actions: addPantryItem, removePantryItem |
### API-proxy routes (Next.js)
Alla proxy-routes använder `withAuth(handler)`-wrappern (från `lib/with-auth.ts`) som läser auth-token via `request.auth` och vidarebefordrar `Authorization: Bearer <token>` till backend. `withAuth` löser kompatibilitetsproblem med `auth()` standalone i Next.js 16 + NextAuth beta.28.
| Route | Metod | Syfte |
|-------|-------|-------|
| `/api/admin-users` | GET, POST | Hämtar alla användare / skapar ny användare (kräver admin-roll i session) |
| `/api/admin-users/[id]` | PATCH, DELETE, PUT | Ändrar roll / tar bort användare / byter e-post; om body innehåller `isPremium` → anropar `PATCH /api/users/:id/premium` (kräver admin-roll i session) |
| `/api/admin-users/[id]/reset-password` | POST | Återställer lösenord och returnerar tillfälligt lösenord + meddelandetext (kräver admin-roll) |
| `/api/auth/[...nextauth]` | GET, POST | Auth.js handlers (login, logout, session) |
| `/api/products` | GET | Produktlista (auth-wrappat med `auth(req)`) |
| `/api/categories` | GET | Kategorihierarki (publik, proxies `/api/categories/tree`) |
| `/api/profile` | GET, PATCH | Hämta/uppdatera användarprofil |
| `/api/recipes` | GET, POST | Lista recept + spara nytt |
| `/api/quick-import-proxy` | POST | URL-, PDF- och bildimport |
| `/api/parse-markdown-proxy` | POST | Markdown-tolkning för skriv-in-recept |
| `/api/inventory-history-proxy` | GET | Konsumtionshistorik |
| `/api/recipe-preview-proxy` | GET | Receptförhandsvisning |
| `/api/admin/merge-preview-proxy` | GET | Produktmerge-preview |
| `/api/receipt-import-proxy` | POST | Kvittoimport via Mistral AI |
| `/api/meal-plan-proxy` | GET, POST, DELETE | Matplanering (veckovy, upsert, ta bort) |
| `/api/meal-plan-shopping-proxy` | GET | Inköpslista för datumintervall |
| `/api/meal-plan-compare-proxy` | GET | Inventariejämförelse för datumintervall |
| `/api/user-products` | GET, POST, DELETE | Användarspecifika produkter |
### Autentisering (Auth.js v5)
- `auth.ts` — NextAuth-konfiguration med Credentials provider; sparar `accessToken`, `userId`, `username` och `role` i JWT-token och session
- `proxy.ts` — Skyddar alla routes utom `/login`, `/register` och `/api/*`; blockerar `/admin/*` om sessionens `role` inte är `admin`. **Next.js 16 använder `proxy.ts` istället för `middleware.ts`.**
- `lib/auth-headers.ts` — `getAuthHeaders()` hämtar Bearer-token från session (server-side, används i Next.js API route-proxies)
- `lib/api.ts` — `fetchJson()` lägger automatiskt till auth-headers server-side, redirectar till `/login` vid 401
- `lib/with-auth.ts` — `withAuth(handler)`-wrapper för Next.js Route Handlers som behöver autentisering via `request.auth`. Löser kompatibilitetsproblemet med `auth()` standalone i Next.js 16 + NextAuth beta.
- `lib/use-auth-fetch.ts` — `useAuthFetch()`-hook för klientkomponenter. Returnerar en `fetch`-funktion som automatiskt lägger till `Authorization: Bearer <token>`. Används i komponenter som gör anrop till endpoints som Caddy routar direkt till NestJS.
- `app/Providers.tsx` — Client component som wrappa appen med `SessionProvider` (krävs för att `useSession()` ska fungera)
### Kritisk regel: Caddy-routing och klientkomponenter
Caddy routar `/api/recipes*`, `/api/products*` och `/api/inventory*` **direkt till NestJS** — Next.js route handlers körs aldrig för dessa sökvägar. Det innebär att klientkomponenter (`'use client'`) som gör `fetch()` till dessa endpoints **inte kan lita på att withAuth eller getAuthHeaders lägger till token automatiskt**.
**Lösning: använd alltid `useAuthFetch()` i klientkomponenter:**
```ts
import { useAuthFetch } from '../../../lib/use-auth-fetch';
// I komponentfunktionen:
const authFetch = useAuthFetch();
// Anrop — Content-Type och Authorization sätts automatiskt:
const res = await authFetch('/api/recipes/1', { method: 'PATCH', body: JSON.stringify(data) });
const res = await authFetch('/api/recipes', { method: 'POST', body: JSON.stringify(body) });
const res = await authFetch(`/api/recipes/${id}`, { method: 'DELETE' });
```
Hooken hämtar token via `useSession()` (kräver att `SessionProvider` finns i layout) och är memoizerad med `useCallback`.
**Endpoints som kräver `useAuthFetch()` i klientkod (Caddy-direktrouting):**
| Prefix | Destination | Kräver useAuthFetch i klient? |
|--------|-------------|-------------------------------|
| `/api/recipes*` | `recipe-api:8080` direkt | ✅ Ja |
| `/api/products*` | `recipe-api:8080` direkt | ✅ Ja |
| `/api/inventory*` | `recipe-api:8080` direkt | ✅ Ja |
| `/api/admin/*` | `recipe-frontend:3000` via catch-all | Nej (withAuth hanterar) |
| `/api/auth/*` | `recipe-frontend:3000` via catch-all | Nej |
| `/api/categories` | `recipe-frontend:3000` via catch-all | Nej |
- Svenska felmeddelanden via `lib/error-handler.ts` (`parseErrorResponse`)
- Centraliserad API-access via `lib/api.ts` (`fetchJson`) — för server-side anrop
- Klientside auth-fetch via `lib/use-auth-fetch.ts` (`useAuthFetch`) — för klientkomponenter som pratar direkt med NestJS
- Typade inventory/recipe data i `features/inventory/types.ts`
---
## Backend (NestJS)
- **Framework:** NestJS 10.3
- **Språk:** TypeScript 5.4.5
- **Databas:** MariaDB 11 (via Prisma 6.12.0 ORM)
- **API:** REST, validering med class-validator
- **Autentisering:** JWT (7 dagars token), `JwtAuthGuard` skyddar alla routes globalt, `RolesGuard` kontrollerar rollkrav, `@Public()` dekorator för öppna endpoints
- **Rollbaserad behörighet:** `@Roles('admin')` dekoratorn via `SetMetadata`; `RolesGuard` kastar 403 om rätt roll saknas
- **Felhantering:** GlobalExceptionFilter (svenska felmeddelanden)
- **Hälsokontroll:** /health endpoints
- **Bygg:** `nest build`, körs i Docker-container
### Backend-moduler och strukturen
```
backend/src/
├── app.module.ts # Root module
├── main.ts # Startpunkt (port 8080, global prefix "api")
├── auth/
│ ├── auth.controller.ts # POST /api/auth/login, POST /api/auth/register
│ ├── auth.service.ts # validateUser, login (JWT-signering inkl. role + isPremium)
│ ├── auth.module.ts
│ ├── jwt.strategy.ts # Passport JWT-strategi (returnerar userId, username, role, isPremium)
│ ├── jwt-auth.guard.ts # Global guard (skyddar allt utom @Public)
│ ├── roles.guard.ts # Guard som kontrollerar @Roles() metadata; kastar 403
│ └── decorators/
│ ├── public.decorator.ts # @Public() markerar öppen endpoint
│ ├── current-user.decorator.ts # @CurrentUser() extraherar {userId, username, role, isPremium}
│ └── roles.decorator.ts # @Roles('admin') sätter rollkrav via SetMetadata
├── ai/
│ ├── ai.service.ts # AiService: suggestCategory() — anropar Mistral API med kategorikontext
│ └── ai.module.ts # Exporterar AiService (importeras av ProductsModule, ReceiptImportModule)
├── users/
│ ├── users.controller.ts # GET /api/users/me, PATCH /api/users/me
│ │ # GET /api/users (admin), PATCH /api/users/:id/role (admin)
│ │ # POST /api/users (admin), DELETE /api/users/:id (admin)
│ │ # POST /api/users/:id/reset-password (admin), PATCH /api/users/:id/email (admin)
│ │ # PATCH /api/users/:id/premium (admin)
│ ├── users.service.ts # findByUsername, findById, create, updateProfile
│ │ # findAll, setRole, adminCreate, deleteUser, resetPassword, updateEmail, setPremium
│ ├── admin-bootstrap.service.ts # OnApplicationBootstrap: skapar/uppdaterar 4 seed-användare
│ └── users.module.ts
├── categories/
│ ├── categories.controller.ts # GET /api/categories, GET /api/categories/tree (@Public)
│ ├── categories.service.ts # findAll (flat), findTree (hierarkisk), findFlattened (med full path)
│ └── categories.module.ts
├── common/
│ ├── filters/
│ │ └── global-exception.filter.ts # Centraliserad felhantering
│ └── utils/
│ └── normalize-name.ts # Namnormalisering
├── health/
│ ├── health.controller.ts # GET /health, /health/db (@Public)
│ ├── health.service.ts
│ └── health.module.ts
├── inventory/
│ ├── inventory.controller.ts # CRUD endpoints
│ ├── inventory.service.ts # CRUD + konsumtion
│ ├── inventory.module.ts
│ └── dto/
│ ├── create-inventory.dto.ts
│ ├── update-inventory.dto.ts
│ └── consume-inventory.dto.ts
├── prisma/
│ ├── prisma.service.ts # PrismaClient wrapper
│ └── prisma.module.ts
├── products/
│ ├── products.controller.ts # CRUD, merge, duplicates, reset-all
│ │ # GET /products/pending (admin)
│ │ # GET /products/:id/suggest-category (premium/admin)
│ │ # POST /products/ai-categorize-bulk (admin)
│ │ # PATCH /products/:id/status (admin)
│ ├── products.service.ts # Produktlogik inkl. resetAll(), findPending(), setStatus()
│ ├── products.module.ts # Importerar AiModule + CategoriesModule
│ └── dto/
│ ├── create-product.dto.ts
│ ├── update-product.dto.ts
│ ├── merge-products.dto.ts
│ └── update-canonical-name.dto.ts
├── quick-import/ # 🆕 Snabbimport-modul
│ ├── quick-import.controller.ts # POST /api/quick-import
│ ├── quick-import.service.ts # ICA-skrapning, URL-parsing
│ ├── quick-import.module.ts # Module definition
│ └── parsers/
│ ├── base.parser.ts # Abstract RecipeParser class
│ ├── ica.parser.ts # ICA.se-specifik parser (JSON-LD)
│ └── generic.parser.ts # Fallback-parser (HTML + JSON-LD)
├── meal-plan/
│ ├── meal-plan.controller.ts # GET/POST/DELETE + shopping-list + inventory-compare
│ ├── meal-plan.service.ts # Upsert, shoppingList (portionsskalad), inventoryCompare
│ ├── meal-plan.module.ts
│ └── dto/
│ └── create-meal-plan-entry.dto.ts # { date, recipeId, servings? }
├── receipt-import/
│ ├── receipt-import.controller.ts # POST /api/receipt-import (multipart, kräver JWT)
│ │ # Skickar isPremium till service (premium/admin → AI-enrichment)
│ ├── receipt-import.service.ts # Mistral AI-anrop, bildtolkning, enrichWithAiCategories()
│ ├── receipt-import.module.ts # Importerar AiModule + CategoriesModule
│ └── dto/
│ └── parsed-receipt-item.dto.ts # Inkl. categorySuggestion?: CategorySuggestion
├── receipt-alias/
│ ├── receipt-alias.controller.ts # CRUD /api/receipt-alias
│ ├── receipt-alias.service.ts
│ └── dto/
└── recipes/
├── recipes.controller.ts # Recept endpoints
├── recipes.service.ts # Recept + Markdown-parsing
├── recipes.module.ts
└── dto/
├── create-recipe.dto.ts
├── parse-markdown.dto.ts
└── create-recipe-ingredient.dto.ts└── pantry/
├── pantry.controller.ts # GET/POST/DELETE /api/pantry
├── pantry.service.ts # Baslagerlogik
├── pantry.module.ts
└── dto/
└── create-pantry-item.dto.ts```
### Backend-funktioner
**Health API:**
- Övergripande systemstatus (uptime, service info)
- Databasspecifik hälsokontroll (responseTime, connection test)
- Returnerar statusCode 200 eller 503
**Quick-Import API:** 📌 (Även tillgänglig via [Microservice Importer](../microservice-importer/))
- **Endpoint:** `POST /api/quick-import`
- **Input:**
- JSON-body med `input` för URL eller servermonterad filsökväg
- `multipart/form-data` med `file` för uppladdad PDF eller bild
- **Stödda format:** PDF, PNG, JPG, JPEG, WEBP, BMP samt receptlänkar
- **Process:**
1. Typdetektering av URL, PDF eller bild
2. URL-import via site-specifik eller generisk parser
3. PDF-import via `pdf-parse`
4. Bildimport via `tesseract.js` OCR (`swe+eng`)
5. Normalisering till Markdown-format för vidare receptgranskning
- **Parser-arkitektur:**
- **Base Parser** (`RecipeParser`): Abstract class med gemensam parseIngredientLine()-logik
- Hanterar bråkmängder (1 1/2 dl), parentetiska noter, unit-validering
- Kända enheter: g, kg, hg, mg, ml, dl, l, tl, st, tsk, msk, krm, port, efter smak, förp, klyfta, m.fl.
- **ICA Parser** (`IcaRecipeParser`): Prioriterar JSON-LD structured data, fallback HTML
- **Generic Parser** (`GenericRecipeParser`): Försöker alla webbplatser (JSON-LD → HTML)
- **Output:** Markdown-format recepttext med `source: 'ica' | 'pdf' | 'image' | 'other'`
**Inventarie-API:**
- CRUD för inventarieföremål (produktreferens, kvantitet, enhet, plats, märke, bäst före, mm)
- Konsumtionshistorik-tracking (registrera brukat amount och kommentar)
- Sortering: efter plats, bäst före-datum, namn (A–Ö)
- Filtrera utgående varor
- **Enhetskonvertering:** Stöd för viktenheter (g/kg), volymenheter (ml/dl), portionsenheter (tsk/msk)
- Normalisering av enheter (t.ex. "tesked" → "tsk", "gram" → "g")
- Konverteringsregler per enhet-typ
- Kan endast konvertera inom samma enhet-typ (error om blandning)
**Recept-API:**
- CRUD för recept och ingredienser
- **Parse-markdown endpoint:** Tolkar Markdown-format, matchar ingredienser mot databas
- **Matchningsalgoritm (3 nivåer):**
1. Exakt match (normalizedName eller canonicalName efter normalisering): **100 poäng**
2. Delsträng-match (ingrediens i produktnamn eller vice versa): **70 poäng**
3. Levenshtein-distans-baserad likhet: **40100 poäng** (under 40 filtreras bort)
- Top 5 förslag per ingrediens
- Sortering: Högsta poäng först
- **Inventory-preview:** Jämför recept mot inventarie
- Returnerar status för varje ingrediens: räcker | saknas | enhetskonflikt
- Automatisk enhetskonvertering vid jämförelse
- **Normalisering:** `normalize-name()` utility för consistent namn-matching
**Produkt-API:**
- CRUD för produkter (create, read, update, delete)
- **Duplicate detection:** `findDuplicates()` - Hitta produkter med samma normalizedName
- **Merge-preview:** `previewMerge()` - Förhandsgranska merge operation (visa inventory-counts, outcome)
- **Merge operation:** `merge()` - Slå ihop två produkter
- Flytta alla inventarieföremål från källa till mål
- Soft-delete källan (isActive = false, deletedAt = nu)
- Uppdatera recept-ingredienser från källa till mål
- **Canonical name management:**
- `updateCanonicalName()` - Uppdatera canonical name för ett produktnamn
- `backfillCanonical()` - Fylla på canonical names för alla produkter (admin-funktion)
- **Soft delete & restore:**
- `remove()` - Soft-delete produkt (isActive = false)
- `restore()` - Återställ borttagen produkt
- **Bulk-uppdatering:** `bulkUpdate(ids, data)` — Uppdatera ett godtyckligt antal produkter i ett enda DB-anrop (`updateMany`). Används primärt för bulk-kategorisering i admin-UI. Body: `{ ids: number[], categoryId?: number | null }`
**Matplan-API:**
- **`upsert(dto)`** — Skapar eller uppdaterar en `MealPlanEntry` för ett givet datum (unik per dag). Sparar `recipeId` och valfritt `servings`.
- **`findByRange(from, to)`** — Hämtar alla planerade dagar i ett datumintervall, inkl. receptinfo.
- **`shoppingList(from, to)`** — Aggregerar ingrediensmängder för alla planerade recept i intervallet.
- Om `entry.servings` och `recipe.servings` är satta beräknas en skala: `scale = entry.servings / recipe.servings`
- Ingrediensmängder multipliceras med skalan innan aggregering
- Returnerar lista av `{ productName, quantity, unit }`
- **`inventoryCompare(from, to)`** — Kör samma aggregering som `shoppingList` men jämför sedan varje ingrediens mot aktuellt inventarielager. Returnerar status per ingrediens: `räcker | saknas | enhetskonflikt`.
**Kvittoimport-API:**
- **`parseReceipt(file, isPremium)`** — Tar emot en bild eller PDF (max 15 MB), skickar den till Mistral AI för tolkning och returnerar en lista av kandidatprodukter med namn, kvantitet och enhet.
- Alias-matchning: före returneringen slås varje rånamn upp mot `ReceiptAlias`-tabellen och mot `Product.normalizedName`. Träffar kopplas automatiskt till rätt produkt-ID.
- **AI-kategorisuggestion (premium):** Om `isPremium = true` (eller admin) och en vara varken alias-matchas eller ordbaserat matchas anropas `AiService.suggestCategory()`. Svaret inkluderas som `categorySuggestion` i retur-DTO:n och visas som ett lila "✨"-chip i frontend.
- Stödda MIME-typer: `image/jpeg`, `image/png`, `image/webp`, `image/heic`, `image/heif`, `application/pdf`
**AI-API (`AiService`):**
- **`suggestCategory(productName, categories: FlatCategory[])`** — Skickar produktnamnet och en flat lista av alla kategorier (med full sökväg, t.ex. "Mejeri och ägg > Mjölk och grädde") till Mistral API (`mistral-small-2603`). Returnerar `CategorySuggestion`.
- **Svar-typ `CategorySuggestion`:** `{ categoryId, categoryName, path, confidence: 'high'|'medium'|'low', usedFallback: boolean }`
- **Fallback-strategi:** Om AI returnerar ett ogiltigt kategori-ID eller om anropet misslyckas: försök hitta "Övrigt"-underkategori → fallback till rot-"Övrigt" (id 221) med `confidence: 'low'` och `usedFallback: true`
- **Integration:** `AiModule` exporterar `AiService` och importeras av `ProductsModule` och `ReceiptImportModule`
- **CategoriesService tillägg:** `findFlattened()` bygger en flat lista med `{ id, name, path }` där `path` är den fullständiga kategorivägen (används som kontext till Mistral)
---
## API-endpoints (fullständig lista)
### 🏥 Health endpoints
```
GET /api/health Övergripande hälsakontroll (200/503)
GET /api/health/db Databasspecifik hälsa + responseTime
```
### 📦 Inventarie-endpoints
```
GET /api/inventory Lista inventarieföremål
Params: ?location=... &sort=...
GET /api/inventory/expiring Utgångna/snart utgångna varor
POST /api/inventory Skapa nytt inventarieföremål
PATCH /api/inventory/:id Uppdatera inventarieföremål
POST /api/inventory/:id/consume Konsumera (registrera brukat amount)
GET /api/inventory/:id/consumption-history Konsumtionshistorik
```
### 🍽️ Recept-endpoints
```
POST /api/quick-import Snabbimport från URL, PDF eller bild
Body: { input: string } eller multipart-form med file
POST /api/recipes/parse-markdown Tolka Markdown-recept (matchningslogik)
GET /api/recipes Lista alla recept
POST /api/recipes Skapa nytt recept
GET /api/recipes/:id Hämta specifikt recept
PATCH /api/recipes/:id Uppdatera recept
DELETE /api/recipes/:id Ta bort recept (204 No Content)
GET /api/recipes/:id/inventory-preview Jämför recept mot inventarie
```
### 🏷️ Produkt-endpoints
```
GET /api/products Lista alla aktiva produkter
POST /api/products Skapa ny produkt
GET /api/products/:id Hämta specifik produkt
PATCH /api/products/:id Uppdatera produktens namn, canonicalName eller kategori
DELETE /api/products/:id Soft-delete produkt
POST /api/products/:id/restore Återställ raderad produkt
GET /api/products/duplicates Lista duplicerade namn (grupperade)
GET /api/products/merge-preview Förhandsgranska merge
?sourceProductId=X &targetProductId=Y
POST /api/products/merge Slå ihop två produkter
PATCH /api/products/:id/canonical-name Uppdatera canonical name
POST /api/products/backfill-canonical Backfill canonical names (admin)
POST /api/products/reset-all Rensa all produktdata (admin)
POST /api/products/bulk-update Uppdatera flera produkter (t.ex. sätt kategori)
Body: { ids: number[], categoryId?: number | null }
GET /api/products/pending Lista pending-produkter (admin)
GET /api/products/:id/suggest-category AI-förslag på kategori för produkt (premium/admin)
POST /api/products/ai-categorize-bulk Kör AI-kategorisering på alla okategoriserade (admin)
Returnerar lista av { productId, productName, suggestion: CategorySuggestion }
PATCH /api/products/:id/status Sätt produktstatus active/rejected (admin)
Body: { status: 'active' | 'rejected' }
```
### Kategori-endpoints
```
GET /api/categories Flat lista av alla kategorier (@Public)
GET /api/categories/tree Hierarkiskt träd (@Public)
```
### Användar-endpoints
```
POST /api/auth/login Logga in, returnerar JWT inkl. role + isPremium (@Public)
GET /api/users/me Hämta inloggad användares profil (inkl. role, isPremium)
PATCH /api/users/me Uppdatera firstName, lastName, email
GET /api/users Lista alla användare (kräver admin-roll)
PATCH /api/users/:id/role Ändra roll för användare (kräver admin-roll)
PATCH /api/users/:id/premium Sätt isPremium true/false (kräver admin-roll)
Body: { isPremium: boolean }
```
### Baslager-endpoints
```
GET /api/pantry Lista alla baslagerartiklar (inkl. produktinfo)
POST /api/pantry Lägg till produkt i baslagret
DELETE /api/pantry/:id Ta bort produkt från baslagret
```
### 🗓️ Matplan-endpoints
```
GET /api/meal-plan?from=YYYY-MM-DD&to=YYYY-MM-DD Lista planerade recept för datumintervall
POST /api/meal-plan Skapa eller uppdatera post (upsert per datum)
Body: { date, recipeId, servings? }
DELETE /api/meal-plan/:date Ta bort recept för ett specifikt datum
GET /api/meal-plan/shopping-list?from=...&to=... Generera inköpslista för veckan
Skalad proportionellt efter portionsjustering
GET /api/meal-plan/inventory-compare?from=...&to=... Jämför inköpslista mot inventarie
Returnerar status per ingrediens: räcker | saknas | enhetskonflikt
```
### 🧾 Kvitto-endpoints
```
POST /api/receipt-import Tolka kvittobild (JPEG, PNG, WebP, HEIC, PDF)
Multipart-form med "file"; max 15 MB
Returnerar lista av { name, quantity, unit, productId?, confidence }
GET /api/receipt-alias Lista alla kvitto-alias
POST /api/receipt-alias Skapa nytt alias (receiptName → productId)
DELETE /api/receipt-alias/:id Ta bort alias
```
---
## Datamodell (Prisma ORM)
### User
```prisma
model User {
id Int @id @default(autoincrement())
username String @unique
email String @unique
firstName String?
lastName String?
passwordHash String
role String @default("user") # "user" eller "admin"
isPremium Boolean @default(false) # Styr tillgång till AI-premium-funktioner
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
```
**Bootstrap-användare:** När backend startar kör `AdminBootstrapService.onApplicationBootstrap()` och skapar eller uppdaterar fyra användare baserade på miljövariabler:
| Användarnamn | Roll | isPremium | E-post | Miljövariabel |
|---|---|---|---|---|
| Nadmin | admin | false | nadmin@localhost | `ADMIN_NADMIN_PASSWORD` |
| Padmin | admin | false | padmin@localhost | `ADMIN_PADMIN_PASSWORD` |
| user1 | user | false | user1@localhost | `SEED_USER1_PASSWORD` |
| user2 | user | false | user2@localhost | `SEED_USER2_PASSWORD` |
Om en användare redan finns men har fel roll rättas rollen automatiskt. Om miljövariabeln saknas hoppas den användaren över med en varning i loggen.
### Category
```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- och produktseed
All seed-data för kategorier och produkter hanteras av **`db/seeds/seed_all.sql`** — den enda sanningskällan.
**Vad filen gör:**
1. **TRUNCATE `Category`** + nollställer `Product.categoryId` — rensar alla ev. gamla/duplicerade kategorier
2. **Bygger upp hela kategoriträdet** (nivå 13) med rena `INSERT` — exakt en rad per kategori, inga dubbletter möjliga
3. **`INSERT IGNORE` ~190 produkter** — hoppar över produkter som redan finns
4. **`UPDATE Product SET categoryId`** — kopplar produkterna till rätt kategori via JOIN-subqueries
**Körs manuellt på servern:**
```bash
DB_PASS=$(grep MARIADB_ROOT_PASSWORD .env | cut -d= -f2)
docker exec -i recipe-db mariadb -uroot -p"$DB_PASS" recipe_app < db/seeds/seed_all.sql
```
> **OBS:** Migrationen `20260417310000_add_category_tree/migration.sql` innehåller fortfarande gamla kategori-INSERTs från ursprungsimplementationen. Dessa kör en gång vid `prisma migrate deploy` men seed_all.sql nollställer och skriver över dem vid nästa körning. Se NEXT_STEPS.md för planerat refactor-arbete.
> **OBS:** Om `InventoryItem`-rader pekar på produkter som inte längre finns (t.ex. efter TRUNCATE av Product) uppstår Prisma-felet "Field product is required". Rensa orphan-rader med:
> ```sql
> DELETE FROM InventoryItem WHERE productId NOT IN (SELECT id FROM Product);
> ```
### 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
status String @default("active") # "active" | "pending" | "rejected"
isActive Boolean @default(true)
deletedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
inventoryItems InventoryItem[]
recipeIngredients RecipeIngredient[]
tags ProductTag[]
nutrition Nutrition?
}
```
**Produktstatus:**
- `active` — normal produkt synlig i alla listor (default)
- `pending` — föreslagen produkt som väntar på admin-godkännande; visas på `/admin/products/pending`
- `rejected` — avvisad produkt; visas inte i vanliga listor
### InventoryItem
```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)
purchaseDate DateTime? # Köpdatum
opened Boolean? # Markering för öppnad produkt
suitableFor String? # Lämplighetsmärkning (t.ex. "vegetarian")
bestBeforeDate DateTime? # Bäst före-datum
comment String? # Fri kommentar
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
consumptions InventoryConsumption[]
@@index([productId])
}
```
### InventoryConsumption
```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` | Ingrediens markerare (case-insensitive) |
| `- ANTAL ENHET NAMN` | Ingrediens: quantity=ANTAL, unit=ENHET, name=NAMN |
| `- ANTAL NAMN` | Ingrediens utan enhet: unit sätts till "st" |
| `- NAMN` | Ingrediens utan kvantitet: quantity=0, unit="" |
| `(text i parentes)` | Ingrediensnot (sparas, t.ex. "vispgrädde") |
| `## Tillvägagångssätt` / `## Instruktioner` | Instruktionssektion markerare |
| Text under instruktioner | Tillagningsinstruktioner (flera rader OK) |
**Exempel ingrediensparsning:**
```
"- 500 g köttfärs" → {quantity: 500, unit: "g", rawName: "köttfärs"}
"- 1,5 dl grädde (vispgrädde)" → {quantity: 1.5, unit: "dl", rawName: "grädde", note: "vispgrädde"}
"- 3 ägg" → {quantity: 3, unit: "st", rawName: "ägg"}
"- salt" → {quantity: 0, unit: "", rawName: "salt"}
```
### Komponenter och dataflöde
#### 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 (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:**
```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
---
## Databaskonfiguration och seeding
### Databaskonfiguration
**MariaDB 11** hostas i Docker-container (`recipe-db`) enligt `compose.yml`. Databasen använder:
- **Teckenuppsättning:** UTF-8 (utf8mb4) för korrekt stöd för svenska tecken
- **Sortering:** utf8mb4_swedish_ci för svensk alfabetisk sortering
- **Anslutning:** Via Prisma ORM från backend-tjänsten
**Initialisering:**
1. `db/init/001-init.sql` körs vid första container-start (grundläggande tablestatus)
2. Prisma migrations körs automatiskt vid backend-start
3. `db/seeds/seed_all.sql` körs manuellt för att fylla på initiala kategorier och produkter
### seed_all.sql — Kategori- och produktdatabas
**Filplats:** `db/seeds/seed_all.sql`
**Syfte:**
`seed_all.sql` är ensam sanningskälla för kategoriträdet. Den innehåller:
1. **Kategoristruktur** — Hierarkiskt träd från nivå 1 (toppkategorier) till nivå 3 (underkategorier)
2. **Initiala produkter** — 200+ standardprodukter (köttfärs, ost, frukt, etc.)
3. **Kategoritilldelning** — Kopplar produkterna till rätt kategorier
**Struktur:**
| Steg | Vad | Detaljer |
|------|-----|----------|
| **RESET** | Förberedelse | `SET foreign_key_checks = 0` → `TRUNCATE TABLE Category` → `UPDATE Product SET categoryId = NULL` |
| **STEG 1** | Kategorier NIVÅ 1-3 | INSERT INTO Category för alla toppkategorier, nivå 2 och nivå 3-kategorier. Använder SELECT med JOINs för hierarkisk struktur. |
| **STEG 2** | Produkter | `INSERT IGNORE INTO Product` — Befintliga produkter hoppas över (inga dubletter). |
| **STEG 3** | Kategoritilldelning | UPDATE Product ... WHERE name IN (...) för att tilldela rätt categoryId baserat på produktnamn. |
**Köra seed_all.sql:**
```bash
# Hämta lösenordet från .env och kör kommandot:
DB_PASS=$(grep MARIADB_ROOT_PASSWORD .env | cut -d= -f2)
docker exec recipe-db mariadb -uroot -p"$DB_PASS" recipe_app -e "SHOW TABLES;"
```
**Viktigt:** Denna kommando kan köras **hur många gånger som helst**:
- `TRUNCATE` raderar alla befintliga kategorier (men inte produkterna själva)
- `INSERT IGNORE` förhindrar dubbletter av produkter
- Befintliga produkt-data (namn, märke, etc.) påverkas inte — endast `categoryId` uppdateras
**Fördelar med denna design:**
- ✅ Reproducerbar — idempotent, kan köras flera gånger
- ✅ Enkel migrering — alla ändringar i kategoriträdet är i en fil
- ✅ Ingen datarisk — produkter försvinner aldrig, enbart omkategorisering
- ✅ Admin-gränssnittet kan senare modifiera kategorier interaktivt (seed_all är enbart för initial setup)
### Kategoristruktur
**Nivå 1 (Toppkategorier):**
- Bröd & Kakor
- Dryck
- Färdigmat
- Fryst
- Frukt & Grönt
- Glass, godis & snacks
- Kött, chark & fågel
- Mejeri, ost & ägg
- Skafferi
- Fisk & Skaldjur *(tillagd senare)*
- Vegetariskt *(tillagd senare)*
**Nivå 2 (Underkategorier, exempel):**
- Bröd & Kakor > Bröd, Fastfoodbröd, Kex & Kakor, Knäckebröd & Skorpor
- Mejeri, ost & ägg > Ost, Allergi mejeri, Mjölk, Filmjölk & Yoghurt, Matlagning, Smör/margarin & jäst, Havre-/Soja-/Risdryck, Kvarg & Cottage cheese
**Nivå 3 (Specifika kategorier, exempel):**
- Bröd & Kakor > Bröd > Matbröd, Rostbröd
- Frukt & Grönt > Grönsaker > Sallad & Kål, Auberginer & Zucchini, Paprika, Övriga grönsaker, Tomater
### Uppdatering av kategorier
Nya kategorier läggs till direkt i `db/seeds/seed_all.sql`. Efter ändringar:
```bash
# Kör seed_all.sql igen för att uppdatera kategoriträdet
docker exec -i recipe-db mariadb -uroot -p"$DB_PASS" recipe_app < db/seeds/seed_all.sql
```
Produkterna behålls och omkategoriseras enligt de nya UPDATE-satserna.
---
## Framtida 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.