86 KiB
Teknisk beskrivning av Recipe App
Se README.md för användarinformation och kom-igång-guide.
Se NEXT_STEPS.md för förslag på nästa steg i projektet.
Se AI-FUNKTIONER.md för planerade AI-funktioner och modellval.
Översikt
Recipe App är en fullstack-applikation för hantering av hemmavaror, recept och matplanering. Systemet är byggt med Next.js (frontend), NestJS (backend), Prisma ORM och MariaDB. Applikationen är containeriserad med Docker och använder Caddy som reverse proxy.
Viktigt att komma ihåg vid implementering av nya funktioner och kodning är att inte använda windows sökvägar. Att inte använda c:/dev/recpie-app.... Detta eftersom bygg- och testmiljön är på en remote ubuntu-server. Utveckling sker lokalt och test samt drift sker på remote server. Säkerställ att inga absoluta Windows-sökvägar används i koden, för att stödja bygg och drift på Linux/Ubuntu
Nyheter och förbättringar (2026-04-30)
- Microservice-importer integrerad —
importer-apikörs nu som intern Docker-tjänst irecipe-app/compose.yml. All URL-skrapning, OCR, PDF-parsning och AI-kvittoparsning delegeras dit.recipe-apibehåller Levenshtein-matchning, produktdatabas och AI-kategorisering. Se migrering-MSI.md för fullständig lista över ändrade filer. - Bygg-instruktion — Katalogen
../microservice-importermåste finnas sida vid sida medrecipe-apppå servern../deploy.shbygger båda automatiskt.
Nyheter och förbättringar (2026-04-22)
- User-scope för pantry och matplan — Prisma-schema, backend och API är migrerade så att alla baslager- och matplansdata är per användare. JWT används för filtrering i alla endpoints.
- Robust bildimport — Bild-URL normaliseras, laddas ner och optimeras i backend. Fallback till extern URL om nedladdning misslyckas. Bilden kopplas till receptet och raderas vid delete. Diagnostikloggning på alla steg.
- Importflöde — Quick-import och receipt-import har förbättrats med robust multipart-hantering, timeout, och felhantering. Markdown och bild-url skickas hela vägen till UI.
- Flutter-parity — Matplan, inventarie, baslager och receptflöden är nu fullt migrerade till Flutter med user-scope och robust felhantering.
- Felsökningslogg — Se
IMPORT_IMAGE_DEBUG_2026-04-22.mdför detaljerad felsökningshistorik kring bildimport och importflöde.
Kända begränsningar
- Kvittoimport (Fas 6b) är påbörjad men granskningssteg och bulk-spara återstår.
- Bildimport kräver att containrar är uppdaterade med senaste kod — kontrollera att diagnostikloggar syns vid felsökning.
- Vissa adminfunktioner och avancerad AI-integration är planerade men ej migrerade.
- Privat git-server för denna applikation
- Ändringar pushas lokalt till denna server:
git push origin main
Bygge och körning på servern
Efter push till Gitea:
-
SSH in på servern
ssh user@server cd /opt/containers/recipe-app -
Hämta senaste ändringar
git pull origin main -
Bygg och starta applikationen
docker compose build docker compose up -d
Alla tjänster (frontend, backend, databas) startas via Docker Compose enligt compose.yml.
Rekommenderat kommandomonster
For att undvika forvirring mellan huvudappen och Flutter-sparat bor dessa kommandon anvandas konsekvent:
Huvudappen (Next.js + API + DB):
docker compose build recipe-frontend recipe-api
docker compose up -d recipe-db recipe-api recipe-frontend
Enbart backend:
docker compose build recipe-api
docker compose up -d recipe-db recipe-api
Flutter-sparat (separat klient):
docker compose -f compose.yml -f compose.flutter.yml build recipe-flutter
docker compose -f compose.yml -f compose.flutter.yml up -d --no-deps recipe-flutter
Tumregel:
compose.ymlstyr huvudappen parecept.gynther.se.compose.yml+compose.flutter.ymlstyr Flutter-klienten patest.gynther.se.- Att bygga en image startar inte containern;
docker compose up -d ...kravs alltid efter build.
Container- och deployupplägg
compose.ymlbygger lokala images för frontend och backendpull_policy: neveranvä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_onmed hälsovillkor används för stabilare startordning i Docker och Portainer- Fasta containernamn — alla tjänster har
container_namesatt icompose.yml, vilket ger förutsebara namn oavsett projektkatalog:
| Tjänst | Container-namn |
|---|---|
| Frontend (Next.js) | recipe-frontend |
| Backend (NestJS) | recipe-api |
| Databas (MariaDB) | recipe-db |
Använd dessa namn vid docker exec, t.ex.:
docker exec recipe-api npx prisma migrate dev --name migration_name
docker exec recipe-db mariadb -uroot -p"LÖSENORD" recipe_app -e "SHOW TABLES;"
Orphan-containers vid blandade compose-filer
Vid arbete med bade huvudappen och Flutter-sparet kan Docker Compose visa varningen om orphan containers, ofta for recipe-flutter.
Detta betyder normalt bara att:
- en container startades med en annan compose-filskombination tidigare,
- och att den inte finns med i kommandot du kor just nu.
Exempel:
docker compose up -d recipe-frontendkanner inte tillrecipe-fluttereftersom den bara finns icompose.flutter.yml.docker compose -f compose.yml -f compose.flutter.yml up -d recipe-flutterkanner till Flutter-sparet.
Varningen ar i sig inte ett fel och paverkar inte Prisma-migrationer eller databasens schema.
Stada endast bort orphan-containers om du verkligen vill stoppa dem:
docker compose down --remove-orphans
Obs: detta kan stoppa recipe-flutter, som da maste startas igen med override-filen.
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.
(auth) {
basicauth {
admin $2a$14$DahHUWD2cKyXJ96sH5VQwuQv1bqmIn0gsdoSaw4mofzfdNY2Y0VsO
}
}
(common) {
encode gzip zstd
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
X-XSS-Protection "1; mode=block"
Referrer-Policy "strict-origin-when-cross-origin"
Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=()"
Cross-Origin-Opener-Policy "same-origin"
Cross-Origin-Resource-Policy "same-origin"
Cross-Origin-Embedder-Policy "require-corp"
}
}
# ============================================
# Import Service (Document Converter) - Standalone UI
# ============================================
import.gynther.se {
import common
reverse_proxy recipe-import-service:3000
}
# ============================================
# RECIPE APP + IMPORT SERVICE
# ============================================
recept.gynther.se {
import common
# === IMPORT SERVICE (Document Converter) ===
# Dessa endpoints måste komma FÖRST innan backend reglerna!
handle /api/recipes/import* {
reverse_proxy recipe-import-service:3000
}
# === RECIPE FRONTEND PROXY ENDPOINTS ===
# Next.js API routes
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
}
# === RECIPE BACKEND API ENDPOINTS ===
# Backend körs på port 8080 (från docker-compose)
handle /api/products* {
reverse_proxy recipe-api:8080
}
handle /api/inventory* {
reverse_proxy recipe-api:8080
}
handle /api/recipes* {
reverse_proxy recipe-api:8080
}
# === HEALTH CHECKS ===
handle /health {
reverse_proxy recipe-api:8080
}
# === CATCH ALL ===
# Övriga /api/* går till frontend
handle /api/* {
reverse_proxy recipe-frontend:3000
}
# Frontend - catch all remaining routes (port 3000)
reverse_proxy /* recipe-frontend:3000
}
Kommentarer:
- Blocket
(common)innehåller alla grundläggande säkerhetshuvuden och komprimering. - Blocket
(auth)används för basic auth på utvalda endpoints. - Importera
(common)i varje site-block för att återanvända headers och inställningar. - Proxyreglerna styr trafik till rätt backend/frontend-tjänst beroende på path.
- Kommentarer i filen förklarar ordningen på reglerna och varför vissa hanteras först.
Säkerhetshuvuden
Säkerhetshuvuden är implementerade i tre lager för djupförsvar:
Lager 1: Caddy (globalt för alla tjänster)
Sätts i (common)-blocket och gäller alla domäner som importerar det.
| Header | Värde | Syfte |
|---|---|---|
Strict-Transport-Security |
max-age=31536000; includeSubDomains; preload |
Tvingar HTTPS, skyddar mot protocol downgrade |
X-Content-Type-Options |
nosniff |
Förhindrar MIME-sniffing |
X-Frame-Options |
DENY |
Skyddar mot clickjacking |
X-XSS-Protection |
1; mode=block |
Legacy XSS-skydd (äldre webbläsare) |
Referrer-Policy |
strict-origin-when-cross-origin |
Begränsar referrer-läckage |
Permissions-Policy |
geolocation=(), microphone=(), camera=(), payment=() |
Inaktiverar känsliga webbläsar-API:er |
Cross-Origin-Opener-Policy |
same-origin |
Isolerar browsing context |
Cross-Origin-Resource-Policy |
same-origin |
Förhindrar cross-origin läsning av resurser |
Cross-Origin-Embedder-Policy |
require-corp |
Kräver explicit cross-origin permission |
Lager 2: NestJS Helmet (backup)
Helmet konfigurerat i backend/src/main.ts som säkerhetsbackup ifall Caddy kringgås eller misslyckas. CSP är inaktiverat i Helmet (contentSecurityPolicy: false) eftersom det hanteras av Next.js.
Aktiveras vid: docker compose up --build backend
Lager 3: Next.js Content Security Policy
CSP sätts i frontend/next.config.js via headers()-funktionen och gäller alla routes (/:path*).
| Direktiv | Tillåtna källor | Motivering |
|---|---|---|
default-src |
'self' |
Restriktiv default |
script-src |
'self' 'unsafe-eval' 'unsafe-inline' |
Krävs av Next.js runtime |
style-src |
'self' 'unsafe-inline' fonts.googleapis.com |
Inline-stilar + Google Fonts |
img-src |
'self' data: https: |
Tillåter externa bilder via HTTPS |
font-src |
'self' fonts.gstatic.com |
Google Fonts-filer |
connect-src |
'self' api.mistral.ai |
API-anrop inkl. Mistral AI |
frame-src |
'none' |
Inga inbäddade frames tillåtna |
object-src |
'none' |
Inga plugins (Flash, etc.) |
base-uri |
'self' |
Skyddar mot base-tag-injektion |
form-action |
'self' |
Formulär får bara posta till samma origin |
Notering:
'unsafe-eval'och'unsafe-inline'iscript-srcär nödvändiga för Next.js 16 med App Router. Undvik att ta bort dessa utan noggrann testning.
Felsökning av CSP-brott
Om en funktion slutar fungera efter CSP-aktivering:
- Öppna webbläsarens devtools → Console för att se CSP-felmeddelanden
- Kontrollera vilken domän/resurs som blockeras
- Lägg till domänen i rätt direktiv i
frontend/next.config.js - Vanliga undantag: WebSockets kräver
wss:iconnect-src, Service Workers kräverworker-src 'self'
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 tillrecipe-frontend:3000) - Klientkomponenten hanterar UI-state lokalt efter svar (uppdatera/ta bort ur lokal state)
Exempel (se app/api/admin/product/[id]/route.ts):
// API route (server-side, har session)
export async function PATCH(req, { params }) {
const authHeaders = await getAuthHeaders(); // använder auth()
const res = await fetch(`${API_BASE}/api/products/${id}`, { method: 'PATCH', headers: authHeaders, ... });
return Response.json(await res.json());
}
// Klientkomponent
const res = await fetch(`/api/admin/product/${id}`, { method: 'PATCH', body: JSON.stringify(data) });
const updated = await res.json();
setProducts(prev => prev.map(p => p.id === updated.id ? updated : p)); // lokal state-uppdatering
När är Server Actions OK?
Server Actions kan fortfarande användas för operationer som inte anropas från klientkomponenter med startTransition, t.ex.:
- Form submissions i rena Server Components (inget
useTransition) - Admin-operationer som ändå triggar en helsidsladdning efteråt
Befintliga undantag att känna till
Dessa Server Actions finns kvar men bör migreras om de orsakar problem:
-
bulkSetCategory— anropas frånAdminProductList(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 anroparauth(). Den får aldrig importeras av client components — rendera den alltid ipage.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; spararaccessToken,userId,usernameochrolei JWT-token och sessionproxy.ts— Next.js 16 kräver att auth-middleware heterproxy.ts(intemiddleware.ts). Skyddar alla routes utom/login,/registeroch/api/*: ej inloggade redirectas till/login, och inloggade icke-admins blockeras från/admin/*(redirect till/). Täcker alltså både autentisering och rollbaserad accesskontroll i ett enda fil.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/loginvid 401lib/with-auth.ts—withAuth(handler)-wrapper för Next.js Route Handlers som behöver autentisering viarequest.auth. Löser kompatibilitetsproblemet medauth()standalone i Next.js 16 + NextAuth beta.lib/use-auth-fetch.ts—useAuthFetch()-hook för klientkomponenter. Returnerar enfetch-funktion som automatiskt lägger tillAuthorization: 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 medSessionProvider(krävs för attuseSession()ska fungera)
Kritisk regel: Caddy-routing och klientkomponenter
Caddy routar /api/recipes*, /api/products*, /api/inventory* och /api/pantry* direkt till NestJS — Next.js route handlers körs aldrig för dessa sökvägar. Det innebär att klientkomponenter ('use client') som gör fetch() till dessa endpoints inte kan lita på att withAuth eller getAuthHeaders lägger till token automatiskt.
Lösning: använd alltid useAuthFetch() i klientkomponenter:
import { useAuthFetch } from '../../../lib/use-auth-fetch';
// I komponentfunktionen:
const authFetch = useAuthFetch();
// Anrop — Content-Type och Authorization sätts automatiskt:
const res = await authFetch('/api/recipes/1', { method: 'PATCH', body: JSON.stringify(data) });
const res = await authFetch('/api/recipes', { method: 'POST', body: JSON.stringify(body) });
const res = await authFetch(`/api/recipes/${id}`, { method: 'DELETE' });
Hooken hämtar token via useSession() (kräver att SessionProvider finns i layout) och är memoizerad med useCallback.
Endpoints som kräver useAuthFetch() i klientkod (Caddy-direktrouting):
| Prefix | Destination | Kräver useAuthFetch i klient? |
|---|---|---|
/api/recipes* |
recipe-api:8080 direkt |
✅ Ja |
/api/products* |
recipe-api:8080 direkt |
✅ Ja |
/api/inventory* |
recipe-api:8080 direkt |
✅ Ja |
/api/pantry* |
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 |
âš ï¸ Ã…terkommande misstag (2026-04-21): Vid implementationen av
InventoryViewochPantryView(klientkomponenter i Databas-tabben) användes plainfetch('/api/inventory')ochfetch('/api/pantry')utan token. NestJS returnerade 401 och komponenterna visade "Kunde inte hämta inventarie". Felet uppstod trots att en Next.js API-route för/api/inventoryredan existerade — Caddy routar dessa paths förbi Next.js helt. Regel: varje ny klientkomponent som hämtar data från/api/inventory*,/api/pantry*,/api/recipes*eller/api/products*måste användauseAuthFetch().
- 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),
JwtAuthGuardskyddar alla routes globalt,RolesGuardkontrollerar rollkrav,@Public()dekorator för öppna endpoints - Rollbaserad behörighet:
@Roles('admin')dekoratorn viaSetMetadata;RolesGuardkastar 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: **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(userId, dto)`** — Skapar eller uppdaterar en `MealPlanEntry` för inloggad användare och ett givet datum (unik per `userId + date`). Sparar `recipeId` och valfritt `servings`.
- **`findByRange(userId, from, to)`** — Hämtar alla planerade dagar för inloggad användare i ett datumintervall, inkl. receptinfo.
- **`shoppingList(userId, from, to)`** — Aggregerar ingrediensmängder för inloggad användares 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:
- **`inventoryCompare(userId, from, to)`** — Kör samma aggregering som `shoppingList` men jämför sedan varje ingrediens mot:
1. **Pantry (baslager):** Om produkten finns i `PantryItem` för `userId` returneras `status: 'pantry'`, `missing: 0` — varan räknas alltid som tillgänglig oavsett inventariet.
- Sorteringsordning: `missing` → `enough` → `pantry`
- Frontend visar 📦-ikon och â€(baslager)†för pantry-varor; de visas aldrig som â€saknas†i inköpslistan.
**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 inloggad användares baslager (inkl. produktinfo) POST /api/pantry Lägg till produkt i baslagret (kräver JWT) DELETE /api/pantry/:id Ta bort produkt från baslagret (kräver JWT, ägarskap)
> Alla anrop kräver JWT. Varje användare ser bara sitt eget baslager.
### ðŸ—“ï¸ Matplan/matsedel-endpoints
GET /api/meal-plan?from=YYYY-MM-DD&to=YYYY-MM-DD Lista planerade recept för datumintervall (per inloggad användare) POST /api/meal-plan Skapa eller uppdatera post (upsert per datum, per användare) Body: { date, recipeId, servings? } DELETE /api/meal-plan/:date Ta bort recept för ett specifikt datum (per inloggad användare)
GET /api/meal-plan/shopping-list?from=...&to=... Generera inköpslista för veckan (per inloggad användare) Skalad proportionellt efter portionsjustering GET /api/meal-plan/inventory-compare?from=...&to=... Jämför inköpslista mot inventarie (användarens baslager) Returnerar status per ingrediens: räcker | saknas | enhetskonflikt
> Alla anrop kräver JWT. Varje användare ser och hanterar bara sin egen matplan.
### 🧾 Kvitto-endpoints
POST /api/receipt-import Tolka kvittobild (JPEG, PNG, WebP, HEIC, PDF) Multipart-form med "file"; max 15 MB Returnerar lista av { name, quantity, unit, productId?, confidence }
GET /api/receipt-alias Lista alla kvitto-alias POST /api/receipt-alias Skapa nytt alias (receiptName → productId) DELETE /api/receipt-alias/:id Ta bort alias
---
## Datamodell (Prisma ORM)
### User
```prisma
model User {
id Int @id @default(autoincrement())
username String @unique
email String @unique
firstName String?
lastName String?
passwordHash String
role String @default("user") # "user" eller "admin"
isPremium Boolean @default(false) # Styr tillgång till AI-premium-funktioner
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
Bootstrap-användare: När backend startar kör AdminBootstrapService.onApplicationBootstrap() och skapar eller uppdaterar fyra användare baserade på miljövariabler:
| Användarnamn | Roll | isPremium | E-post | Miljövariabel |
|---|---|---|---|---|
| Nadmin | admin | false | nadmin@localhost | ADMIN_NADMIN_PASSWORD |
| Padmin | admin | false | padmin@localhost | ADMIN_PADMIN_PASSWORD |
| user1 | user | false | user1@localhost | SEED_USER1_PASSWORD |
| user2 | user | false | user2@localhost | SEED_USER2_PASSWORD |
Om en användare redan finns men har fel roll rättas rollen automatiskt. Om miljövariabeln saknas hoppas den användaren över med en varning i loggen.
Category
model Category {
id Int @id @default(autoincrement())
name String
parentId Int?
parent Category? @relation("CategoryTree", ...) # Förälder (null = toppnivå)
children Category[] @relation("CategoryTree") # Underkategorier
products Product[]
@@unique([name, parentId])
}
Hierarkin har 3 nivåer: Huvudkategori → Underkategori → Typ
Exempelträd: Mejeri, ost & ägg → Mjölk → Standard 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:
- TRUNCATE
Category+ nollställerProduct.categoryId— rensar alla ev. gamla/duplicerade kategorier - Bygger upp hela kategoriträdet (nivå 1–3) med rena
INSERT— exakt en rad per kategori, inga dubbletter möjliga INSERT IGNORE~190 produkter — hoppar över produkter som redan finnsUPDATE Product SET categoryId— kopplar produkterna till rätt kategori via JOIN-subqueries
Körs manuellt på servern:
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.sqlinnehåller fortfarande gamla kategori-INSERTs från ursprungsimplementationen. Dessa kör en gång vidprisma migrate deploymen 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:DELETE FROM InventoryItem WHERE productId NOT IN (SELECT id FROM Product);
Product
model Product {
id Int @id @default(autoincrement())
name String # Visningsnamn
normalizedName String @unique # Normaliserat namn (lowercase, utan skiljetecken)
canonicalName String? # Canonical namn för receptmatchning
category String? # Fritext-kategori (äldre fält, ersätts av categoryRef)
subcategory String? # Fritext-underkategori (äldre fält)
brand String? # Varumärke
categoryId Int? # FK till Category (ny hierarki)
categoryRef Category? # Relation till Category
status String @default("active") # "active" | "pending" | "rejected"
isActive Boolean @default(true)
deletedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
inventoryItems InventoryItem[]
recipeIngredients RecipeIngredient[]
tags ProductTag[]
nutrition Nutrition?
}
Produktstatus:
active— normal produkt synlig i alla listor (default)pending— föreslagen produkt som väntar på admin-godkännande; visas på/admin/products/pendingrejected— avvisad produkt; visas inte i vanliga listor
InventoryItem
model InventoryItem {
id Int @id @default(autoincrement())
productId Int # Foreign key till Product
quantity Decimal @db.Decimal(10, 2) # Kvantitet (decimal för precision)
unit String # Enhet (g, kg, ml, dl, st, tsk, msk, etc)
brand String? # Varumärke
location String? # Lagerplats (Kyl, Frys, Skafferi, etc)
purchaseDate DateTime? # Köpdatum
opened Boolean? # Markering för öppnad produkt
suitableFor String? # Lämplighetsmärkning (t.ex. "vegetarian")
bestBeforeDate DateTime? # Bäst före-datum
comment String? # Fri kommentar
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
consumptions InventoryConsumption[]
@@index([productId])
}
InventoryConsumption
model InventoryConsumption {
id Int @id @default(autoincrement())
inventoryItemId Int # Foreign key till InventoryItem
amountUsed Decimal @db.Decimal(10, 2) # Konsumerad kvantitet
comment String? # Kommentar
createdAt DateTime @default(now())
inventoryItem InventoryItem @relation(fields: [inventoryItemId], references: [id])
}
Recipe
model Recipe {
id Int @id @default(autoincrement())
name String # Receptnamn
description String? # Receptbeskrivning
servings Int? # Antal portioner receptet är dimensionerat för
imageUrl String? # URL till receptbild (valfritt)
instructions String? @db.Text # Tillagningsinstruktioner (kan vara långt, stöder Markdown)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
ingredients RecipeIngredient[]
mealPlanEntries MealPlanEntry[]
}
servings är grundportionsantalet — matplanen använder det för att skala ingrediensmängder om användaren anger ett avvikande portionsantal per dag.
RecipeIngredient
model RecipeIngredient {
id Int @id @default(autoincrement())
recipeId Int # Foreign key till Recipe
productId Int # Foreign key till Product
quantity Decimal @db.Decimal(10, 2) # Receptkvantitet
unit String # Enhet enligt recept
note String? # Ingrediensnot (t.ex. variation)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
recipe Recipe @relation(fields: [recipeId], references: [id])
product Product @relation(fields: [productId], references: [id])
}
PantryItem
model PantryItem {
id Int @id @default(autoincrement())
userId Int # FK till User — baslager är per användare
productId Int # FK till Product
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
@@unique([userId, productId]) # En produkt kan bara finnas en gång per användare
@@index([userId])
}
Beslut 2026-04-22: Baslager är user-scopat. Varje inloggad användare har ett eget baslager. Den gamla globala modellen (
@unique([productId])) ersattes med den sammansatta unika nyckeln(userId, productId). Se migrationssektionen nedan.
MealPlanEntry
model MealPlanEntry {
id Int @id @default(autoincrement())
userId Int # FK till User — matplan är per användare
date DateTime # Datum för planerad måltid
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
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
recipe Recipe @relation(fields: [recipeId], references: [id])
@@unique([userId, date]) # Bara ett recept per dag och användare
@@index([userId])
}
Beslut 2026-04-22: Matplan är user-scopat. Den gamla globala modellen (
@@unique([date])) ersattes med@@unique([userId, date]). Se migrationssektionen nedan.
Portionsskalning: Om servings är satt och skiljer sig från recipe.servings beräknar shoppingList() och inventoryCompare() en skala: scale = entry.servings / recipe.servings. Alla ingrediensmängder multipliceras med denna faktor.
ReceiptAlias
model ReceiptAlias {
id Int @id @default(autoincrement())
receiptName String @unique # Namn som kvittosystemet returnerar (råtext)
productId Int # FK till matchad Product
createdAt DateTime @default(now())
product Product @relation(...)
}
Kvitto-alias lagrar mappningar från kvittots råtext till produkt-ID. När Mistral AI returnerar t.ex. "ICA Kvarg Jordg" slås det upp mot alias-tabellen. Om träff hoppas manuell matchning över.
Arkitektur: Privata produkter och kvittoimport produktval (2026-05-01)
Bakgrund
Kvittoimportflödet (Fas 6b) behövde ett sätt för användare att koppla kvittorader till produkter i systemet. Tre problem identifierades:
- Produktsökning gav nollträffar när AI-kategorins namn (t.ex. "Frukt & Grönt") matades in som söksträng i produktfältet — produkter heter inte samma sak som kategorier.
- AI-föreslagna kategorier var ofta L1/L2 (t.ex. "Mat") men produkter är kopplade till L3-noder. Att filtrera på bara det exakta kategori-ID:t gav 0 produkter.
- Saknad produkt — om varan på kvittot inte finns i systemet alls (t.ex. "Röda äpplen") hade användaren ingen väg framåt.
Lösning 1: Tvåstegs-picker (Kategori → Produkt)
En ny widget CategoryThenProductPicker (i lib/core/ui/) orkestrerar ett tvåstegsflöde:
Steg 1 — Kategoriträdet: Användaren bläddrar i L1 → L2 → L3 eller söker fritext. Sökresultaten visar hela sökvägen som brödsmula. Alla icke-lövnoder (L1, L2) har en "Välj"-knapp som fallback om man vill stoppa på mellannivå.
Steg 2 — Produktpicker: ProductPickerField.showSheet() öppnas filtrerad på den valda kategorins ID plus alla ättlingars ID:n (insamlade rekursivt via _collectIds()). Det innebär att valet av "Mat > Frukt & Grönt" ger alla produkter i samtliga L3-underkategorier.
L1: Mat
L2: Frukt & Grönt ← välj här → ger produkter från alla L3 nedan
L3: Äpplen ← välj här → ger bara äpple-produkter
L3: Bananer
L3: Citrusfrukter
Lösning 2: AI-direkthopp
Om preselectedCategoryId finns (AI-förslag) hoppar CategoryThenProductPicker.show() direkt till steg 2 utan att visa trädet. Användaren klickar på AI-chip → produktpickern öppnas direkt med rätt kategorifilter. Om kategorin inte hittas i trädet visas trädet som fallback.
Lösning 3: Privata produkter
Problemet: POST /products (admin) skapar globala produkter. Vanliga användare ska kunna skapa egna produkter utan att smutsa ned den globala produktkatalogen.
Modell: Product fick ett nytt fält isPrivate: Boolean @default(false). Privata produkter är kopplade till en specifik användare via ownerId och syns bara för den användaren.
normalizedName-prefix: För att undvika kollision i det unika indexet prefixas privata produkters normaliserade namn med private:{userId}:. Det innebär att "röda äpplen" för användare 5 lagras som private:5:roda applen och kolliderar varken med en global produkt med samma namn eller en annan användares privata produkt.
Nya endpoints:
| Endpoint | Åtkomst | Beskrivning |
|---|---|---|
GET /products |
Publik | Returnerar bara globala produkter (isPrivate: false) |
GET /products/mine |
Inloggad | Returnerar inloggad användares privata produkter |
POST /products/private |
Inloggad | Skapar en privat produkt kopplad till inloggad användare |
Migration: backend/prisma/migrations/20260501000000_add_product_is_private/migration.sql
Flutter-integration
_loadProducts()ireceipt_import_tab.dartladdarGET /productsochGET /products/mineparallellt viaFuture.waitoch slår ihop till en lista._EditDialogStatehåller en lokal_localProducts-kopia som utökas om en ny produkt skapas under dialogen — utan ytterligare nätverksanrop.- "Skapa ny"-dialogen i produktpickern är förifylld med den aktuella söksträngen för snabbt namngivande.
Arkitektur: User-scope för baslager och matplan (2026-04-22)
Designregel: User-scope vid ny funktionalitet
All data som tillhör en specifik användare måste ha ett
userId-fält med FK tillUseroch user-filtrering i service-lagret. Följ detta mönster vid all ny funktionsutveckling:
- Databasschema:
userId Intmed@relationtillUser, icke-null. Sammansatt@@unique([userId, ...])om raden ska vara unik per användare.- Migration: Backfill till första användaren för befintliga rader innan kolumnen sätts NOT NULL.
- Backend service: Alla
findAll/findOne/create/deletefiltrerar eller sätterwhere: { userId }. Returnera aldrig andra användares data.- Controller: Extrahera
userIdvia@CurrentUser()eller@Request() req— aldrig från body (säkerhetsrisk).- Globala vs. privata resurser: Produkter och kategorier är globala. Inventarie, baslager, matplan och användarspecifika produkter (
isPrivate: true) är user-scopade.- Privata produkter (
isPrivate: true): skapas viaPOST /products/private, visas viaGET /products/mine. Normaliserat namn prefixas medprivate:{userId}:för att undvika kollision med globala produkter.Kontrollera alltid: Kan två användare se varandras data? Om ja — lägg till
userId-scope.
Bakgrund och beslut
Baslager (PantryItem) och matplan (MealPlanEntry) var ursprungligen globala — delade av alla användare. Det skapade problem när flera användare loggade in eftersom de såg och påverkade varandras data.
Beslut 2026-04-22: Baslager och matplan är user-scopade. Varje inloggad användare har ett eget, isolerat baslager och en egen matplan.
Databasschema-ändringar
Båda modellerna fick ett userId-fält med FK till User och nya sammansatta unika nycklar:
| Modell | Gammal unik nyckel | Ny unik nyckel |
|---|---|---|
PantryItem |
@@unique([productId]) |
@@unique([userId, productId]) |
MealPlanEntry |
@@unique([date]) |
@@unique([userId, date]) |
User-modellen fick relationslistor: pantryItems PantryItem[] och mealPlanEntries MealPlanEntry[].
Migration
Migration: backend/prisma/migrations/20260422130000_user_scope_pantry_meal_plan/migration.sql
Viktiga steg i migrationens SQL:
- Lägg till
userId-kolonn (nullable) iPantryItemochMealPlanEntry. - Backfill
userIdtill(SELECT id FROM User LIMIT 1)för alla befintliga rader. - Sätt
userId NOT NULL. - Lägg till icke-unik index
PantryItem_productId_idxinnan det gamla unika indexet tas bort (MySQL/MariaDB kräver att ett FK-index finns kvar när ett annat tas bort). - Droppa gammalt unikt index
PantryItem_productId_key. - Lägg till nytt sammansatt unikt index
@@unique([userId, productId])och FK tillUser. - Droppa
MealPlanEntry_date_key, lägg till@@unique([userId, date])och FK tillUser.
Känd fallgrop: MariaDB ger fel 1553 om ett unikt index som används av en FK-constraint droppas utan att ett ersättande index finns. Migrationens steg 4 (lägga till
PantryItem_productId_idxinnan drop) är lösningen på detta.
Prisma-kommentarsyntax: Inline
#-kommentarer på@@unique-rader är ogiltig Prisma-syntax. Använd//-kommentarer på en separat rad ovanför attributet.
Controller/Service-mönster
Alla pantry- och matplan-endpoints extraherar userId via @CurrentUser()-dekoratorn:
// pantry.controller.ts
@Get()
findAll(@CurrentUser() user: { userId: number }) {
return this.pantryService.findAll(user.userId);
}
@Post()
create(@CurrentUser() user: { userId: number }, @Body() dto: CreatePantryItemDto) {
return this.pantryService.create(user.userId, dto);
}
@Delete(':id')
remove(@CurrentUser() user: { userId: number }, @Param('id') id: string) {
return this.pantryService.remove(user.userId, +id);
}
Service-metoderna skickar alltid userId i Prisma where-klausuler:
// pantry.service.ts
findAll(userId: number) {
return this.prisma.pantryItem.findMany({ where: { userId }, include: { product: true } });
}
create(userId: number, dto: CreatePantryItemDto) {
// Kontrollera duplikat per användare
const existing = await this.prisma.pantryItem.findUnique({
where: { userId_productId: { userId, productId: dto.productId } }
});
if (existing) return existing;
return this.prisma.pantryItem.create({ data: { userId, productId: dto.productId } });
}
remove(userId: number, id: number) {
const item = await this.prisma.pantryItem.findFirst({ where: { id, userId } });
if (!item) throw new NotFoundException();
return this.prisma.pantryItem.delete({ where: { id } });
}
Påverkan på Next.js-frontenden
Inga funktionella ändringar krävdes i Next.js-frontenden. Alla anrop till /api/pantry och /api/meal-plan går via auth-proxies (withAuth) som automatiskt vidarebefordrar användarens Bearer-token. Backend extraherar userId från token via JWT-strategin.
Dubblerad död kod i PantryView.tsx och PantryList.tsx städades bort i samband med arbetet.
Receptimport och receptskaping — Detaljerad arkitektur
Syfte och struktur
Recipe App erbjuder tre vägar för att lägga till recept:
- Snabbimport — Klistra in ICA-länk för automatisk skrapning (ny feature)
- Skriv in recept (
/recipes/write) — Markdown-baserad inmatning där användaren skriver receptet i enkelt format - 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:
- Frontend skickar till
/api/quick-import-proxy - Proxy proxiar till backend
POST /api/quick-import - Backend returnerar Markdown-text
- Frontend sparar i
sessionStorage('recipeMarkdown') - Omdirigera till
/recipes/writemed förifylld Markdown
- Frontend skickar till
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ägimportFromUpload(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
- Extraherar text med
- Bildlogik:
- OCR via
tesseract.js - Svensk och engelsk språkmodell (
swe+eng)
- OCR via
- Error-strategi:
400 Bad Request— Tomt input eller saknad fil400 Bad Request— Ostödd filtyp eller ingen läsbar text503 Service Unavailable— Misslyckad PDF- eller OCR-behandling400 Bad Request— HTML-parsing eller hämtning misslyckades
API-endpoint:
POST /api/quick-import
Input: { input: string }
Output: { markdown: string, source: 'ica' | 'pdf' | 'other' }
Proxy-route (Next.js):
/api/quick-import-proxy— Proxies till backend- Hanterar error-konvertering (BE HTTP → FE error message)
- Returnerar Markdown eller JSON-error
Markdown-format och parsningsregler
Markdown-format och parsningsregler
Format:
# Receptnamn
Valfri beskrivning av receptet.
## Ingredienser
- 500 g köttfärs
- 1 st lök
- 2.5 msk tomatpuré
- 1 dl grädde (vispgrädde)
- salt
## Tillvägagångssätt
Stek löken i lite smör. Tillsätt köttfärsen…
Parsningsregler i detalj:
| Element | Tolkning |
|---|---|
# Rubrik |
Receptnamn (första H1) |
Text mellan H1 och ## Ingredienser |
Receptbeskrivning (flera rader OK, valfritt) |
## Ingredienser |
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
2. Backend: POST /api/recipes/parse-markdown endpoint
@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:
{
"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-proxyför backend-anrop
Importera från fil: /recipes/import/ImportFilePage.tsx
- Tabs/toggle mellan två metoder:
- Fil-upload — Dra-och-släpp eller välja PDF/TXT/DOCX
- 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
servingssatt visas ett portionsinmatningsfält direkt i dagsvyn - Avviker inmatat portionsantal från receptets grundvärde visas en återställningsknapp (↩ N portioner)
handleServingsChange()POSTar direkt till backend och uppdaterar lokal state utan sidomladdning
Portionsskalning i backend
const scale = recipeServings && entryServings
? entryServings / recipeServings
: 1;
// Exempel: recept för 4, vill laga 6 → scale = 1.5
// 200 g pasta → 300 g pasta i inköpslistan
Enhetskonvertering (backendsida)
Stödda enhetstyper
| Typ | Enheter | Bassystem |
|---|---|---|
| Vikt | g, kg | gram (g) |
| Volym | ml, dl | milliliter (ml) |
| Portioner | tsk, msk | tesked (tsk), där 1 msk = 3 tsk |
| Stycken | st | kan inte konverteras |
Normalisering (inom RecipesService.normalizeUnit())
| Input | Output | Typ |
|---|---|---|
| "tesked", "test" | "tsk" | Portion |
| "matsled", "matsked" | "msk" | Portion |
| "gram" | "g" | Vikt |
| "kilogram", "kilo", "kg" | "kg" | Vikt |
| "milliliter" | "ml" | Volym |
| "deciliter" | "dl" | Volym |
| "stycke" | "st" | Styck |
| (other) | (as-is) | (as-is) |
Konverteringslogik (convertUnit() metod)
convertUnit(quantity: number, fromUnit: string, toUnit: string, productName: string): number {
// 1. Validering (quantity > 0, units inte tomma)
// 2. Normalisera båda enheter
// 3. Om identiska efter normalisering → return quantity
// 4. Bestäm enhetstyp för båda
// 5. Om olika typer → throw Error
// 6. Konvertera via basenhet:
// quantity * factor[fromUnit] / factor[toUnit]
}
Användning i getInventoryPreview()
Då receptjämförelse jämförs mot inventarie:
För varje ingrediens:
1. Hämta alla inventarieföremål för denna produkt
2. Gruppera efter enhet:
- Samma enhet som ingrediens → summera direkt
- Annan enhet → försök konvertera
3. Om konvertering misslyckas (t.ex. st ↔ g) → hoppa över denna post
4. Summera totalt available (samma + konverterad)
5. Returnera status: räcker | saknas | enhetskonflikt
Infrastruktur & DevOps
Docker Compose setup
Services:
recipe-frontend— Next.js container (port 3000)recipe-api— NestJS backend (port 8080)db— MariaDB database (port 3306)proxy— (valfritt) Caddy reverse proxy
Volumer:
- Database data persistence
- Build layers caching
Networks:
- External
proxynetwork (för Caddy integrering, valfritt) - Intern
recipe-networkmellan services
Backend-Dockerfile (3-stage build)
Stage 2: builder
FROM node:22-alpine AS builder
# Installera backend-deps
# Kopiera converter från stage 1
# Generera Prisma-klient
# Bygg NestJS-appen (nest build)
Stage 3: runner
FROM node:22-alpine AS runner
# Minimal production image
# Enbart dist/, node_modules/, prisma/
Build-kommando:
docker compose build recipe-api
docker compose up -d recipe-api
Frontend-Dockerfile
Standard Next.js build → standalone output
Miljövariabler
Konfigureras via .env eller docker compose up:
DATABASE_URL— MariaDB-anslutning (backend)PORT— Backend port (default 8080)- Ev. Caddy-konfiguration
UTF-8 och lokalisering
- Database: utf8mb4
- Backend: Normaliseringsfunktion hanterar åäö
- Frontend: Svenska felmeddelanden och UI-text
- Endpoints: Svenska benämningar och kategori-namn
Säkerhet & Utbyggbarhet
- Autentisering: JWT-baserad, 7 dagars token. Auth.js v5 (Credentials provider) i frontend. Alla backend-routes skyddas av
JwtAuthGuard— öppna endpoints markeras med@Public(). - Middleware:
middleware.tsskyddar alla Next.js-routes utom/login,/registeroch/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:
GlobalExceptionFilterfå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
memoryStorageoch 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:
db/init/001-init.sqlkörs vid första container-start (grundläggande tablestatus)- Prisma migrations körs automatiskt vid backend-start
db/seeds/seed_all.sqlkö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:
- Kategoristruktur — Hierarkiskt träd från nivå 1 (toppkategorier) till nivå 3 (underkategorier)
- Initiala produkter — 200+ standardprodukter (köttfärs, ost, frukt, etc.)
- 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:
# 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:
TRUNCATEraderar alla befintliga kategorier (men inte produkterna själva)INSERT IGNOREförhindrar dubbletter av produkter- Befintliga produkt-data (namn, märke, etc.) påverkas inte — endast
categoryIduppdateras
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, exempel):
- Bröd & Kakor
- Dryck
- Färdigmat
- Fryst
- Frukt & Grönt
- Glass, godis & snacks
- Kött, chark & fågel
- Mejeri, ost & ägg
- Skafferi
- Fisk & Skaldjur
- Vegetariskt
Nivå 2 (Underkategorier, exempel):
- Bröd & Kakor > Bröd, Matbröd, Rostbrö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
- Frukt & Grönt > Grönsaker
Nivå 3 (Specifika kategorier, exempel):
- Bröd & Kakor > Bröd > Matbröd, Rostbröd
- Mejeri, ost & ägg > Mjölk > Standard mjölk
- Frukt & Grönt > Grönsaker > Sallad & Kål
Uppdatering av kategorier
Nya kategorier läggs till direkt i db/seeds/seed_all.sql. Efter ändringar:
# 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 projekt - Companion-projekt
Microservice Importer
Recipe App har ett companion-projekt för receptimport: microservice-importer
Nuläge
Quick-import-funktionen är för närvarande integrerad i Recipe App med full funktionalitet.
Framtida möjlighet
I framtiden kan snabbimport-logiken extraheras till en standalone microservice för:
- Oberoende scaling
- Enklare API-integration med andra system
- Lägre komplexitet (ingen databaskonfiguration)
- Docker kontainer
Se microservice-importer README för komplett dokumentation och deployment-instruktioner när separation blir aktuell.
Arkitektur för Microservice Importer
Se ovan samt nedan: Gemensam arkitektur för companion-projekt
Microservice Shopping/Todo-lista
En möjlig framtida utveckling är ett separat companion-projekt till Recipe App i form av en standalone microservice för shopping/todo-lista. Denna tjänst ska:
- Vara helt oberoende av Recipe App, men kunna interagera med den
- Erbjuda sömlös integrering mellan sig själv och Recipe App
- Inte kräva någon avancerad databaskonfiguration, utan endast använda en enkel filbaserad databaslösning inom appen
- Docker kontainer
Syftet är att möjliggöra delad eller fristående användning av shopping/todo-listor, med enkel integration för användare som vill koppla ihop funktionaliteten med Recipe App.
Arkitektur för Microservice Shopping/Todo-lista
Se ovan samt nedan: Gemensam arkitektur för companion-projekt
Gemensam arkitektur för companion-projekten
Databasval: Companion-projekten ska använda SQLite som databas, eftersom det möjliggör återanvändning av datamodell och queries mellan backend och native app (iOS/Android). SQLite stöds direkt i Node.js, iOS och Android och gör det enkelt att dela databasstruktur och logik mellan olika plattformar. Gemensam teknisk grund och säkerhet: Både microservice-importer och companion-projektet för shopping/todo-lista ska dela teknisk grund med recipe-app, särskilt vad gäller säkerhetslösningar och API-hantering. Detta innebär:
- JWT-baserad autentisering och rollhantering, med tokens som används för att skydda API-endpoints
- API-design enligt REST-principer, med tydlig separation mellan autentiserade och publika endpoints
- Proxy-lösning (Caddy) för att styra trafik och säkerställa att rätt endpoints skyddas
- Återanvändning av auth-middleware och utility-funktioner för tokenhantering
- Felhantering och svar i JSON-format
I recipe-app används Auth.js v5 (NextAuth) för autentisering, JWT-sessioner, och globala guards i backend (NestJS) för att skydda alla API-routes. Frontend och backend kommunicerar via tydliga API-routes, och klientkomponenter använder utility-funktioner för att alltid skicka med rätt auth-token. Samma principer och kodmönster ska tillämpas i båda microservices för enhetlig säkerhet och API-hantering.
Databasval: Microservice-importer ska använda SQLite som databas, av samma skäl som companion-projektet för shopping/todo-lista: enkel filbaserad setup, portabilitet och möjlighet att återanvända datamodell och queries mellan backend och native app (iOS/Android).