commit ac5340195acf3cf9a93c6b778a37881a9bc5eb7b Author: Sven (AAMOS AI) Date: Wed Aug 5 19:21:11 2026 +0700 Initial commit (unpacked platform) diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..1014ba7 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 2 +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f1f678e --- /dev/null +++ b/.env.example @@ -0,0 +1,73 @@ +# ============================================================ +# Miljövariabler (varumärke: se brand.config.json) +# Kopiera till .env och fyll i. .env checkas ALDRIG in. +# Produktion: AWS SSM/Secrets Manager (spec §50, §56). +# ============================================================ + +# --- Core --- +NODE_ENV=development +API_PORT=4000 +API_BASE_URL=http://localhost:4000 +CORS_ORIGINS=http://localhost:5173 +LOG_LEVEL=info + +# --- Databas (separat databas + minsta möjliga privilegier, spec §52) --- +DATABASE_URL=postgres://app_user:app_dev_password@localhost:5432/app + +# --- Redis (BullMQ-köer + cache) --- +REDIS_URL=redis://localhost:6379 + +# --- Auth --- +JWT_ACCESS_SECRET=dev-only-change-me +JWT_REFRESH_SECRET=dev-only-change-me-too +JWT_ACCESS_TTL_SECONDS=900 +JWT_REFRESH_TTL_SECONDS=2592000 + +# --- Entitlements (signerad token med grace period, spec §44) --- +ENTITLEMENT_SIGNING_SECRET=dev-only-change-me-three +ENTITLEMENT_TTL_HOURS=24 +ENTITLEMENT_GRACE_DAYS=7 + +# --- S3 (spec §53). mock = lokal lagring (dev). aws = RIKTIG S3 (AWS eller +# --- S3-kompatibel som MinIO/R2 via S3_ENDPOINT). Tomma nycklar = IAM-roll. --- +S3_MODE=mock +S3_BUCKET= # default: -production +S3_REGION=eu-north-1 +S3_ENDPOINT= +S3_ACCESS_KEY_ID= +S3_SECRET_ACCESS_KEY= + +# --- AAMOS (befintlig AI-plattform; nås ENDAST från backend, spec §31, §61.13) --- +# AAMOS_MODE=http i alla riktiga miljöer. mock finns för lokal utveckling/test utan nätverk. +AAMOS_MODE=http +AAMOS_API_URL= +AAMOS_API_KEY= +AAMOS_TIMEOUT_MS=60000 + +# --- Prenumerationer (spec §45–47) --- +APPLE_BUNDLE_ID= # default ur brand.config.json +APPLE_ISSUER_ID= +APPLE_KEY_ID= +APPLE_PRIVATE_KEY_PATH= +GOOGLE_PACKAGE_NAME= # default ur brand.config.json +GOOGLE_SERVICE_ACCOUNT_JSON_PATH= + +# --- Push-notiser --- +EXPO_PUSH_ENABLED=false + +# --- Admin --- +ADMIN_PORT=5173 +VITE_API_BASE_URL=http://localhost:4000 + +# --- Larm (valfritt): 5xx-fel och fallerade jobb POST:as som {"text": "..."} --- +ERROR_WEBHOOK_URL= + +# --- E-post: log = mejl till loggen (dev). smtp = RIKTIGA mejl via valfri +# --- SMTP-leverantör (SES/Postmark/Resend/Brevo/egen server). --- +EMAIL_MODE=log +SMTP_HOST= +SMTP_PORT=587 +SMTP_SECURE=false +SMTP_USER= +SMTP_PASS= +MAIL_FROM= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6812206 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,91 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + verify: + name: Typecheck, test & build + runs-on: ubuntu-latest + timeout-minutes: 25 + + # Riktiga tjänster i CI: migrationer + seed körs mot Postgres 16 med + # sök-extensions, precis som i produktion (i18n M7). + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: app_user + POSTGRES_PASSWORD: app_ci_password + POSTGRES_DB: app + ports: ["5432:5432"] + options: >- + --health-cmd "pg_isready -U app_user -d app" + --health-interval 5s --health-timeout 5s --health-retries 10 + redis: + image: redis:7-alpine + ports: ["6379:6379"] + options: >- + --health-cmd "redis-cli ping" + --health-interval 5s --health-timeout 5s --health-retries 10 + + env: + DATABASE_URL: postgres://app_user:app_ci_password@localhost:5432/app + REDIS_URL: redis://localhost:6379 + AAMOS_MODE: mock + S3_MODE: mock + EMAIL_MODE: log + JWT_ACCESS_SECRET: ci-only-secret + JWT_REFRESH_SECRET: ci-only-secret-two + ENTITLEMENT_SIGNING_SECRET: ci-only-secret-three + + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: 10 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Varumärkesvakt (namnet lever endast i brand.config.json) + run: ./scripts/brand-guard.sh + + - name: Prettier + run: pnpm format:check + + - name: Typecheck + run: pnpm typecheck + + - name: Test + run: pnpm test + + - name: Build + run: pnpm build + + - name: Migrera + seeda mot riktig Postgres + run: pnpm db:migrate && pnpm db:seed + + - name: API-röktest (boot + healthz + readyz) + run: | + node apps/api/dist/index.js & + API_PID=$! + for i in $(seq 1 20); do + curl -fsS http://localhost:4000/healthz > /dev/null 2>&1 && break + sleep 1 + done + curl -fsS http://localhost:4000/healthz + curl -fsS http://localhost:4000/readyz + kill $API_PID diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2d2ad6e --- /dev/null +++ b/.gitignore @@ -0,0 +1,41 @@ +# Dependencies +node_modules/ +.pnpm-store/ + +# Builds +dist/ +build/ +.turbo/ +.expo/ +*.tsbuildinfo + +# Environment & secrets – checkas ALDRIG in (spec §56, §61.13) +.env +.env.* +!.env.example +*.pem +*.p8 +service-account*.json + +# Local data (mock-S3, uppladdningar, db-dumpar) +.data/ +*.dump + +# Logs +logs/ +*.log + +# OS / editor +.DS_Store +.idea/ +.vscode/ +*.swp + +# Test coverage +coverage/ + +# Expo / mobile +apps/mobile/ios/ +apps/mobile/android/ +apps/mobile/.expo/ +backups/ diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..2db74ff --- /dev/null +++ b/.npmrc @@ -0,0 +1,6 @@ +# React Native/Expo kräver hoisted node_modules i pnpm-monorepon. +# pnpm hardlänkar fortfarande från store, så diskkostnaden är begränsad. +node-linker=hoisted +shamefully-hoist=true +strict-peer-dependencies=false +auto-install-peers=true diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..4a1222f --- /dev/null +++ b/.prettierrc @@ -0,0 +1,7 @@ +{ + "semi": true, + "singleQuote": false, + "trailingComma": "all", + "printWidth": 100, + "tabWidth": 2 +} diff --git a/OPENCLAW-DEPLOY-PROMPT.md b/OPENCLAW-DEPLOY-PROMPT.md new file mode 100644 index 0000000..96f123a --- /dev/null +++ b/OPENCLAW-DEPLOY-PROMPT.md @@ -0,0 +1,186 @@ +# OPENCLAW-PROMPT: Bygg & deploya matplattformen + +> Kopiera ALLT nedanför linjen och klistra in som uppdrag till OpenClaw-agenten. +> Zip-arkivet med källkoden ska finnas på servern (eller ge agenten nedladdningsvägen). + +--- + +## UPPDRAG + +Du ska bygga och driftsätta en komplett applikationsplattform (API + worker + +adminpanel, PostgreSQL + Redis) från ett källkodsarkiv. Arbetet är förberett så +att ETT skript gör hela jobbet med inbyggda verifieringsgrindar. Din uppgift är +att köra det disciplinerat, verifiera varje grind och rapportera exakt utfall. + +## HÅRDA REGLER (bryt aldrig mot dessa) + +1. **Skriv aldrig ut hemligheter** (innehåll i `.env`, tokens, lösenord) i din + rapport, i loggar du citerar eller i kommandohistorik du visar. Referera dem + som ``. +2. **Ändra ingen kod.** Om något failar: samla felet, följ felsökningslistan + nedan, och rapportera. Du får ändra `.env`-VÄRDEN enligt instruktion, aldrig + källkod. Rör ALDRIG `brand.config.json`. +3. **Kör aldrig destruktiva kommandon** (`DROP DATABASE`, `rm -rf` utanför + temp, `git push`, `git reset`). Skriptet hanterar allt som behövs. +4. **Avbryt och rapportera** i stället för att improvisera om en grind är röd + efter felsökningslistan. En halvfärdig deploy lämnas ALDRIG igång: kör då + `pkill -f "apps/api/dist" ; pkill -f "apps/worker/dist"` och rapportera. +5. **Produktion**: kör ALDRIG produktionsläge utan att människan uttryckligen + bekräftat att `.env` innehåller riktiga produktionsvärden. + +## FÖRUTSÄTTNINGAR (kontrollera först, installera vid behov) + +```bash +node --version # >= 22 (installera: https://nodejs.org eller nvm) +corepack enable && corepack prepare pnpm@10 --activate +pnpm --version # >= 10 +docker --version # VALFRITT – behövs INTE om Postgres 16 + Redis 7 redan kör +``` + +**Docker är valfritt.** Skriptet använder Docker enbart som reservstart av +Postgres/Redis i staging. Kör tjänsterna redan nativt (t.ex. WSL med +`sudo service postgresql start` och `sudo service redis-server start`, systemd, +eller en molndatabas) passerar Grind 0 och 2 utan Docker – installera inget och +skapa inga shims. Grind 2 avgör på faktisk nåbarhet. + +## STEG 1 – Packa upp och gå in i repot + +```bash +unzip -q plattform-*.zip -d ~/app-platform && cd ~/app-platform +ls brand.config.json infrastructure/deployment/first-deploy.sh || echo "FEL: fel arkiv" +``` + +## VAD ZIPEN INNEHÅLLER (komplett – inget mer behövs utom dina nycklar) + +All källkod (API, worker, adminpanel, mobilapp, 13 paket), databas-migrationer +och seed (101 ingredienser, 22 recept, 12 språk), deploy-skriptet med grindar, +AI-utvärderingssviten, alla runbooks (`docs/21-lanseringsplan.md`, +`docs/25-testguide.md`, `docs/namnbyte.md`). Det ENDA som inte ligger i zipen +är hemligheter/nycklar – de fylls i nedan (avsiktligt: hemligheter checkas +aldrig in). + +## PÅ RIKTIGT FRÅN START (rekommenderat – inga mockar) + +Plattformen har tre externa integrationer. Ange människans värden som +miljövariabler vid FÖRSTA körningen så skrivs de in i `.env` och allt kör +äkta från start – ingen mockdata: + +| Integration | Variabler | Var värdena kommer ifrån | +| ----------- | --------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| AAMOS (AI) | `AAMOS_API_URL`, `AAMOS_API_KEY` | Er egen AAMOS-plattform | +| E-post | `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `MAIL_FROM` | Valfri SMTP-leverantör (SES/Postmark/Resend/Brevo/egen server) – transporten är leverantörsoberoende och verifierad med riktig rundtur | +| Fillagring | `S3_BUCKET`, `S3_REGION`, ev. `S3_ENDPOINT` + `S3_ACCESS_KEY_ID`/`S3_SECRET_ACCESS_KEY` | AWS S3 eller S3-kompatibel (MinIO/R2/Hetzner). Tomma nycklar = IAM-roll. Verifierad med äkta presign→PUT→GET-rundtur | + +Exempel (allt äkta, en rad): + +```bash +AAMOS_API_URL=https://aamos.er-doman.se AAMOS_API_KEY= \ +SMTP_HOST=smtp.er-leverantor.se SMTP_USER= SMTP_PASS= MAIL_FROM=noreply@er-doman.se \ +S3_BUCKET=er-staging-bucket S3_REGION=eu-north-1 \ +DEPLOY_MODE=staging REQUIRE_REAL=1 ./infrastructure/deployment/first-deploy.sh +``` + +`REQUIRE_REAL=1` gör äktheten till en GRIND: deployen UNDERKÄNNS med exakt +lista över vad som saknas om någon integration fortfarande kör mock. +Skriptets slututskrift visar alltid INTEGRATIONSSTATUS (RIKTIG/mock per rad) – +citera den i rapporten. Utelämnas värdena faller staging tillbaka till mock +(fungerar, men människan har bett om äkta drift – fråga hellre efter värdena). + +## STEG 2 – Kör första-deployen (STAGING är default och säkert) + +```bash +DEPLOY_MODE=staging ./infrastructure/deployment/first-deploy.sh +``` + +Skriptet är idempotent (säkert att köra om) och går igenom 8 grindar: + +| Grind | Vad | Förväntat i utskrift | +| ----- | ---------------------------------------------------------------------------- | ---------------------- | +| 0 | node/pnpm (docker valfritt – noteras bara) | `GRIND 0 OK` | +| 1 | .env (skapas med genererade hemligheter i staging) | `GRIND 1 OK` | +| 2 | Postgres + Redis nåbara (Docker endast som reservstart i staging) | `GRIND 2 OK` | +| 3 | Varumärkesvakt + typecheck + 100+ tester + build | `GRIND 3 OK` | +| 4 | Databasmigrationer (+ seed i staging: 101 ingredienser, 22 recept, 12 språk) | `GRIND 4 OK` | +| 5 | AI-utvärderingssvit: 6 fall, 30 kontroller | `GRIND 5 OK … GODKÄND` | +| 6 | API + worker startar; `/healthz` och `/readyz` svarar | `GRIND 6 OK` | +| 7 | Funktionsrök: registrera konto → logga in → läsa recept | `GRIND 7 OK` | + +Slutraden ska vara: `ALLA GRINDAR GRÖNA – staging är uppe.` + +## STEG 3 – Egen verifiering (lita aldrig blint på skriptet) + +```bash +curl -fsS http://127.0.0.1:4000/healthz # {"ok":true,...} +curl -fsS http://127.0.0.1:4000/readyz # {"ok":true,...} +tail -5 logs/api.log # inga rader med "level":50 +tail -5 logs/worker.log # innehåller "Worker igång" +``` + +Adminpanelen (valfritt att exponera): statiska filer i `apps/admin/dist/` – +servera t.ex. `cd apps/admin/dist && python3 -m http.server 5173`. + +## STEG 4 – Rapportera + +Rapportera EXAKT detta format: + +``` +DEPLOY-RAPPORT +Läge: staging|production +Alla grindar: GRÖNA / RÖD på grind N +healthz: +readyz: +Tester: +Eval-svit: GODKÄND/UNDERKÄND +Integrationer: AAMOS=RIKTIG/mock, E-post=RIKTIG/log, Fillagring=RIKTIG/mock +Avvikelser: +Kvarstående: +``` + +## FELSÖKNINGSLISTA (i ordning, max ett försök per punkt) + +1. **Grind 2 röd, ingen databas svarar**: starta tjänsterna – nativt + (`sudo service postgresql start; sudo service redis-server start` på WSL, + eller systemd-motsvarigheter) ELLER starta Docker-daemonen + (`sudo systemctl start docker`). Kör om skriptet. Bygg aldrig shims. +2. **Grind 2 röd, port 5432 upptagen**: en annan Postgres kör redan – sätt + `DATABASE_URL` i `.env` till den, kör om. +3. **Grind 3 röd**: kör `pnpm typecheck` och `pnpm test` separat, citera de + FÖRSTA 20 felraderna i rapporten. ÄNDRA INTE KOD – rapportera. (Testerna är + tidszonsoberoende – verifierade under Stockholm/Auckland/Los Angeles – så + sätt ALDRIG TZ som workaround.) +4. **Grind 6 röd**: citera `tail -30 logs/api.log`. Vanligast: fel + `DATABASE_URL`/`REDIS_URL` i `.env` – rätta värdet, kör om skriptet. +5. **Port 4000 upptagen**: `pkill -f "apps/api/dist"` och kör om skriptet. +6. Allt annat: avbryt enligt hård regel 4. + +## PRODUKTION (körs ENDAST efter explicit mänskligt godkännande) + +Skillnader mot staging – människan ska ha gjort detta FÖRE ditt körande: + +1. `.env` ifylld med riktiga värden (databas/RDS, riktiga hemligheter, + `AAMOS_MODE=http` + URL/nyckel, ev. `EMAIL_MODE=smtp`). Skriptet vägrar + dev-värden och vägrar starta databaser i produktion. +2. Databasen skapad via `infrastructure/deployment/create-database.sql` (som master). + +Sedan: + +```bash +DEPLOY_MODE=production ./infrastructure/deployment/first-deploy.sh +``` + +Skriptet tar automatiskt backup före migrationer och seedar ALDRIG i produktion. +Efteråt: peka reverse proxy med TLS mot `127.0.0.1:4000` och kör STEG 3 igen +utifrån (`curl https:///healthz`). + +**Rollback** om produktionen är trasig efter deploy: stoppa processerna +(`pkill -f "apps/api/dist"; pkill -f "apps/worker/dist"`), återställ databasen +från senaste dumpen i `backups/` med +`pg_restore --clean --dbname="" backups/.dump`, +starta föregående kända version. Rapportera att rollback skett. + +## VAD SOM MEDVETET INTE INGÅR (rapportera som "Kvarstående", försök inte lösa) + +- Reverse proxy/TLS-konfiguration (miljöspecifik) +- Mobilappens butiksbyggen (kräver EAS + butikskonton) +- E-postleverantör i produktion (EMAIL_MODE=smtp implementeras vid leverantörsval) +- Namnbyte (styrs av `brand.config.json` – rörs aldrig av dig) diff --git a/README.md b/README.md new file mode 100644 index 0000000..a59f85c --- /dev/null +++ b/README.md @@ -0,0 +1,97 @@ +# Plattformen (arbetsnamn: se `brand.config.json`) + +> AI-baserat operativsystem för hushållets mat, recept, nutrition, inköp och matsvinn. + +Monorepo för hela plattformen: mobilapp (iOS/Android), Food API, worker, adminpanel och +alla delade paket. Byggd enligt master-specifikationen i `docs/`. + +## Struktur + +```text +/ + apps/ + mobile/ Expo/React Native-app (iOS + Android) + api/ Food API (Fastify) – enda ingången för mobilappen + worker/ BullMQ-workers (bildanalys, kvitton, notiser, subscriptions …) + admin/ Adminpanel (Vite + React) + + packages/ + shared-types/ Domäntyper, enums och konstanter + validation/ Zod-scheman för API-kontrakt + database/ Drizzle-schema, migrationer, seed, repositories + inventory-engine/ Transaktionsbaserat matlager (Food Twin), FEFO, bäst före + recipe-engine/ Receptmatchning, filtrering, substitution, skalning + nutrition-engine/ Deterministisk näringsberäkning, BMR/TDEE, dagsmål + recommendation-engine/ "Vad ska vi äta?" – poängsättning + förklaringar + ai-contracts/ Typade AAMOS-kontrakt + HTTP-klient (mock för dev/test) + memory-client/ AAMOS Memory – lager, samtycken, "Vad appen vet om mig" + subscriptions/ Planer, entitlements, StoreKit/Play-verifiering + connectors/ Connector-interface + Livsmedelsverket/OFF/hälso-stubs + events/ Eventkatalog + outbox-publicering + feature-flags/ Feature flags (env + databas) + + infrastructure/ + docker/ Dockerfiles + docker-compose + deployment/ Deploy-skript för befintlig server (steg 1) → ECS (steg 2–3) + migrations/ Genererade SQL-migrationer (källa: packages/database) + monitoring/ Larm, dashboards, healthchecks + security/ IAM-policies, hardening-checklista + + docs/ Produkt- och teknikdokumentation (Del 1–20) +``` + +## Kom igång (lokal utveckling) + +Krav: Node ≥ 22, pnpm ≥ 10, Docker. + +```bash +# 1. Installera beroenden +pnpm install + +# 2. Starta Postgres + Redis +docker compose -f infrastructure/docker/docker-compose.dev.yml up -d + +# 3. Miljövariabler +cp .env.example .env + +# 4. Migrera + seeda databasen +pnpm db:migrate +pnpm db:seed + +# 5. Starta API + worker + admin +pnpm api:dev +pnpm worker:dev +pnpm admin:dev + +# 6. Mobilappen +pnpm mobile:start +``` + +API:t svarar på `http://localhost:4000` (Swagger UI på `/docs`). +Adminpanelen på `http://localhost:5173`. + +## Vanliga kommandon + +```bash +pnpm typecheck # TypeScript i hela repot +pnpm test # Alla tester (Vitest) +pnpm build # Bygg api/worker/admin +pnpm db:generate # Generera ny SQL-migration från Drizzle-schemat +``` + +## Kritiska regler (spec §61 – gäller all kod) + +1. AI får inte hitta på kalorier eller allergener – all nutrition och allergihantering är deterministisk kod. +2. Mobilappen pratar aldrig direkt med modelleverantörer – allt går `Mobil → Food API → AAMOS`. +3. Mobilappen innehåller aldrig AI-hemligheter; backend verifierar Premium. +4. AI ska visa osäkerhet, och korrigering ska vara enkel. +5. Extern data måste vara juridiskt tillåten. Recept och produkter versionshanteras. + +## Dokumentation + +Börja i `docs/README.md` – där ligger executive summary, beslutslogg, arkitektur, +datamodell, API-referens, säkerhets-/GDPR-checklista, utvecklingsfaser och launch-checklista. + +## Status + +Se `docs/STATUS.md` för vad som är byggt, vad som är stubbar och vad som står näst på tur. diff --git a/apps/admin/index.html b/apps/admin/index.html new file mode 100644 index 0000000..85ff3e4 --- /dev/null +++ b/apps/admin/index.html @@ -0,0 +1,13 @@ + + + + + + + Admin + + +
+ + + diff --git a/apps/admin/package.json b/apps/admin/package.json new file mode 100644 index 0000000..30b8bf9 --- /dev/null +++ b/apps/admin/package.json @@ -0,0 +1,25 @@ +{ + "name": "@app/admin", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Adminpanel (spec §57)", + "scripts": { + "dev": "vite --port 5173", + "build": "vite build", + "preview": "vite preview", + "typecheck": "tsc --noEmit", + "test": "vitest run --passWithNoTests" + }, + "dependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router-dom": "^7.1.0" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.3.0", + "vite": "^6.0.0" + } +} diff --git a/apps/admin/src/App.tsx b/apps/admin/src/App.tsx new file mode 100644 index 0000000..044ba32 --- /dev/null +++ b/apps/admin/src/App.tsx @@ -0,0 +1,53 @@ +import { useState } from "react"; +import { NavLink, Navigate, Route, Routes } from "react-router-dom"; +import { BRAND, hasToken, setToken } from "./api.js"; +import { LoginPage } from "./pages/Login.js"; +import { DashboardPage } from "./pages/Dashboard.js"; +import { UsersPage } from "./pages/Users.js"; +import { ModerationPage } from "./pages/Moderation.js"; +import { FlagsPage } from "./pages/Flags.js"; +import { SubscriptionsPage } from "./pages/Subscriptions.js"; +import { AuditLogsPage } from "./pages/AuditLogs.js"; + +export function App() { + const [authed, setAuthed] = useState(hasToken()); + + if (!authed) { + return setAuthed(true)} />; + } + + return ( +
+ +
+ + } /> + } /> + } /> + } /> + } /> + } /> + } /> + +
+
+ ); +} diff --git a/apps/admin/src/api.ts b/apps/admin/src/api.ts new file mode 100644 index 0000000..557a86b --- /dev/null +++ b/apps/admin/src/api.ts @@ -0,0 +1,92 @@ +import brand from "../../../brand.config.json"; +export const BRAND = brand as { name: string; slug: string }; +const ADMIN_TOKEN_KEY = `${BRAND.slug}_admin_token`; + +/** Enkel API-klient för adminpanelen. Token hålls i minne + sessionStorage. */ + +const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:4000"; + +let accessToken: string | null = sessionStorage.getItem(ADMIN_TOKEN_KEY); + +export function setToken(token: string | null): void { + accessToken = token; + if (token) sessionStorage.setItem(ADMIN_TOKEN_KEY, token); + else sessionStorage.removeItem(ADMIN_TOKEN_KEY); +} + +export function hasToken(): boolean { + return accessToken != null; +} + +export class ApiRequestError extends Error { + constructor( + public readonly status: number, + public readonly code: string, + message: string, + ) { + super(message); + } +} + +export async function api( + path: string, + options: { method?: string; body?: unknown } = {}, +): Promise { + const res = await fetch(`${API_BASE}${path}`, { + method: options.method ?? "GET", + headers: { + "content-type": "application/json", + ...(accessToken ? { authorization: `Bearer ${accessToken}` } : {}), + }, + body: options.body != null ? JSON.stringify(options.body) : undefined, + }); + const json = (await res.json().catch(() => ({}))) as { + error?: { code?: string; message?: string }; + } & T; + if (!res.ok) { + if (res.status === 401) setToken(null); + throw new ApiRequestError( + res.status, + json.error?.code ?? "UNKNOWN", + json.error?.message ?? `HTTP ${res.status}`, + ); + } + return json as T; +} + +export interface LoginResult { + totpRequired: boolean; + preAuthToken?: string; +} + +export async function login(email: string, password: string): Promise { + const result = await api<{ + accessToken?: string; + user?: { role: string }; + totpRequired?: boolean; + preAuthToken?: string; + }>("/v1/auth/login", { method: "POST", body: { email, password } }); + if (result.totpRequired && result.preAuthToken) { + return { totpRequired: true, preAuthToken: result.preAuthToken }; + } + if (!result.user || !result.accessToken) { + throw new ApiRequestError(500, "INTERNAL", "Oväntat svar från inloggningen."); + } + if (result.user.role !== "admin" && result.user.role !== "moderator") { + throw new ApiRequestError(403, "FORBIDDEN", "Kontot saknar admin-behörighet."); + } + setToken(result.accessToken); + return { totpRequired: false }; +} + +/** Steg 2 av inloggningen: engångskoden från autentiseringsappen. */ +export async function verifyTotp(preAuthToken: string, code: string): Promise { + const result = await api<{ accessToken: string; user: { role: string } }>( + "/v1/auth/totp-verify", + { method: "POST", body: { preAuthToken, code } }, + ); + if (result.user.role !== "admin" && result.user.role !== "moderator") { + throw new ApiRequestError(403, "FORBIDDEN", "Kontot saknar admin-behörighet."); + } + setToken(result.accessToken); +} diff --git a/apps/admin/src/main.tsx b/apps/admin/src/main.tsx new file mode 100644 index 0000000..3e3ebea --- /dev/null +++ b/apps/admin/src/main.tsx @@ -0,0 +1,16 @@ +import React from "react"; +import { createRoot } from "react-dom/client"; +import { BrowserRouter } from "react-router-dom"; +import { App } from "./App.js"; +import "./styles.css"; +import { BRAND } from "./api.js"; + +document.title = `${BRAND.name} Admin`; + +createRoot(document.getElementById("root")!).render( + + + + + , +); diff --git a/apps/admin/src/pages/AuditLogs.tsx b/apps/admin/src/pages/AuditLogs.tsx new file mode 100644 index 0000000..d0e36d7 --- /dev/null +++ b/apps/admin/src/pages/AuditLogs.tsx @@ -0,0 +1,71 @@ +import { useEffect, useState } from "react"; +import { api } from "../api.js"; + +interface AuditLog { + id: string; + actorUserId: string | null; + actorType: string; + action: string; + targetType: string | null; + targetId: string | null; + createdAt: string; +} + +export function AuditLogsPage() { + const [logs, setLogs] = useState([]); + const [filter, setFilter] = useState(""); + const [error, setError] = useState(null); + + const load = (action = "") => { + api<{ logs: AuditLog[] }>(`/admin/v1/audit-logs?action=${encodeURIComponent(action)}`) + .then((r) => setLogs(r.logs)) + .catch((e) => setError(String(e.message))); + }; + useEffect(() => load(), []); + + return ( +
+

Audit logs

+
+ setFilter(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && load(filter)} + /> + +
+ {error &&
{error}
} +
+ + + + + + + + + + + + {logs.map((log) => ( + + + + + + + + ))} + +
TidAktörTypActionMål
{new Date(log.createdAt).toLocaleString("sv-SE")} + {log.actorUserId?.slice(0, 8) ?? "system"} + {log.actorType} + {log.action} + {log.targetType ? `${log.targetType}:${log.targetId?.slice(0, 8)}` : "–"}
+
+
+ ); +} diff --git a/apps/admin/src/pages/Dashboard.tsx b/apps/admin/src/pages/Dashboard.tsx new file mode 100644 index 0000000..728047d --- /dev/null +++ b/apps/admin/src/pages/Dashboard.tsx @@ -0,0 +1,99 @@ +import { useEffect, useState } from "react"; +import { api } from "../api.js"; + +interface Health { + checks: Record; +} +interface JobsOverview { + scanJobs: Array<{ status: string; count: number }>; + queue: Record; + outboxPending: number; +} + +export function DashboardPage() { + const [health, setHealth] = useState(null); + const [jobs, setJobs] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + const load = () => { + api("/admin/v1/system/health") + .then(setHealth) + .catch((e) => setError(String(e.message))); + api("/admin/v1/jobs/overview") + .then(setJobs) + .catch(() => undefined); + }; + load(); + const timer = setInterval(load, 15_000); + return () => clearInterval(timer); + }, []); + + return ( +
+

Systemöversikt

+ {error &&
{error}
} + +
+ {health && + Object.entries(health.checks).map(([name, check]) => ( +
+
+ + {check.ok ? "OK" : "FEL"} + +
+
+ {name} {check.detail ? `– ${check.detail}` : ""} +
+
+ ))} + {jobs && ( +
+
{jobs.outboxPending}
+
Outbox väntar
+
+ )} +
+ + {jobs && ( +
+

Jobbköer

+ + + + + + + + + {Object.entries(jobs.queue).map(([status, count]) => ( + + + + + ))} + +
Kö-statusAntal
{status}{count}
+

Skanningsjobb

+ + + + + + + + + {jobs.scanJobs.map((row) => ( + + + + + ))} + +
StatusAntal
{row.status}{row.count}
+
+ )} +
+ ); +} diff --git a/apps/admin/src/pages/Flags.tsx b/apps/admin/src/pages/Flags.tsx new file mode 100644 index 0000000..b0eac94 --- /dev/null +++ b/apps/admin/src/pages/Flags.tsx @@ -0,0 +1,100 @@ +import { useEffect, useState } from "react"; +import { api } from "../api.js"; + +interface Flag { + key: string; + enabled: boolean; + rolloutPercent: number; + descriptionSv: string | null; +} + +export function FlagsPage() { + const [flags, setFlags] = useState([]); + const [error, setError] = useState(null); + + const load = () => { + api<{ flags: Flag[] }>("/admin/v1/flags") + .then((r) => setFlags(r.flags)) + .catch((e) => setError(String(e.message))); + }; + useEffect(load, []); + + const toggle = async (flag: Flag) => { + await api(`/admin/v1/flags/${flag.key}`, { + method: "PUT", + body: { + enabled: !flag.enabled, + rolloutPercent: flag.rolloutPercent, + descriptionSv: flag.descriptionSv ?? undefined, + }, + }); + load(); + }; + + const setRollout = async (flag: Flag, percent: number) => { + await api(`/admin/v1/flags/${flag.key}`, { + method: "PUT", + body: { + enabled: flag.enabled, + rolloutPercent: percent, + descriptionSv: flag.descriptionSv ?? undefined, + }, + }); + load(); + }; + + return ( +
+

Feature flags

+

+ Launch Advanced-funktioner rullas ut gradvis härifrån (spec §1, Del 3). +

+ {error &&
{error}
} +
+ + + + + + + + + + + + {flags.map((flag) => ( + + + + + + + + ))} + +
FlaggaBeskrivningRolloutStatus
+ {flag.key} + {flag.descriptionSv} + + + + {flag.enabled ? "PÅ" : "AV"} + + + +
+
+
+ ); +} diff --git a/apps/admin/src/pages/Login.tsx b/apps/admin/src/pages/Login.tsx new file mode 100644 index 0000000..15534b2 --- /dev/null +++ b/apps/admin/src/pages/Login.tsx @@ -0,0 +1,108 @@ +import { useState, type FormEvent } from "react"; +import { BRAND, login, verifyTotp } from "../api.js"; + +export function LoginPage({ onLogin }: { onLogin: () => void }) { + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + // 2FA-steget: sätts när kontot har TOTP aktiverat. + const [preAuthToken, setPreAuthToken] = useState(null); + const [code, setCode] = useState(""); + + const submit = async (e: FormEvent) => { + e.preventDefault(); + setBusy(true); + setError(null); + try { + const result = await login(email, password); + if (result.totpRequired && result.preAuthToken) { + setPreAuthToken(result.preAuthToken); + } else { + onLogin(); + } + } catch (err) { + setError(err instanceof Error ? err.message : "Inloggningen misslyckades."); + } finally { + setBusy(false); + } + }; + + const submitCode = async (e: FormEvent) => { + e.preventDefault(); + if (!preAuthToken) return; + setBusy(true); + setError(null); + try { + await verifyTotp(preAuthToken, code); + onLogin(); + } catch (err) { + setError(err instanceof Error ? err.message : "Fel engångskod."); + } finally { + setBusy(false); + } + }; + + if (preAuthToken) { + return ( +
+
+

Tvåfaktorsautentisering

+

Ange den sexsiffriga koden från din autentiseringsapp.

+ setCode(e.target.value.replace(/\D/g, ""))} + autoFocus + required + /> + {error &&
{error}
} + + +
+
+ ); + } + + return ( +
+
+

{BRAND.name} Admin

+ setEmail(e.target.value)} + required + /> + setPassword(e.target.value)} + required + /> + {error &&
{error}
} + +
Kräver konto med admin-roll.
+
+
+ ); +} diff --git a/apps/admin/src/pages/Moderation.tsx b/apps/admin/src/pages/Moderation.tsx new file mode 100644 index 0000000..4ec016f --- /dev/null +++ b/apps/admin/src/pages/Moderation.tsx @@ -0,0 +1,71 @@ +import { useEffect, useState } from "react"; +import { api } from "../api.js"; + +interface PendingRecipe { + id: string; + titleSv: string; + descriptionSv: string; + status: string; + creatorDisplayName: string | null; + moderationNote: string | null; + createdAt: string; +} + +export function ModerationPage() { + const [recipes, setRecipes] = useState([]); + const [error, setError] = useState(null); + + const load = () => { + api<{ recipes: PendingRecipe[] }>("/admin/v1/moderation/recipes") + .then((r) => setRecipes(r.recipes)) + .catch((e) => setError(String(e.message))); + }; + useEffect(load, []); + + const act = async (id: string, action: "approve" | "reject" | "request_changes") => { + const note = + action === "approve" ? undefined : (prompt("Motivering till skaparen:") ?? undefined); + await api(`/admin/v1/moderation/recipes/${id}`, { method: "POST", body: { action, note } }); + load(); + }; + + return ( +
+

Receptmoderering

+

+ Flöde enligt spec §35: submission → AI-kontroll → dubblettkontroll → moderering → + publicering. +

+ {error &&
{error}
} + {recipes.length === 0 &&
Inga recept väntar på granskning. 🎉
} + {recipes.map((recipe) => ( +
+
+
+ {recipe.titleSv} {recipe.status} +
+ av {recipe.creatorDisplayName ?? "okänd"} ·{" "} + {new Date(recipe.createdAt).toLocaleString("sv-SE")} +
+
+
+ + + +
+
+

{recipe.descriptionSv}

+ {recipe.moderationNote && ( +
AI-kontroll & dubbletter: {recipe.moderationNote}
+ )} +
+ ))} +
+ ); +} diff --git a/apps/admin/src/pages/Subscriptions.tsx b/apps/admin/src/pages/Subscriptions.tsx new file mode 100644 index 0000000..09eb123 --- /dev/null +++ b/apps/admin/src/pages/Subscriptions.tsx @@ -0,0 +1,68 @@ +import { useEffect, useState } from "react"; +import { api } from "../api.js"; + +interface Sub { + id: string; + userId: string; + provider: string; + plan: string; + status: string; + expiresAt: string | null; + lastVerifiedAt: string | null; +} + +export function SubscriptionsPage() { + const [subs, setSubs] = useState([]); + const [error, setError] = useState(null); + + useEffect(() => { + api<{ subscriptions: Sub[] }>("/admin/v1/subscriptions") + .then((r) => setSubs(r.subscriptions)) + .catch((e) => setError(String(e.message))); + }, []); + + const statusBadge = (status: string) => + status === "active" || status === "trial" ? "ok" : status === "in_grace" ? "warn" : "err"; + + return ( +
+

Prenumerationer

+

+ Backend är source of truth (spec §47). Promo-planer delas ut via API:t. +

+ {error &&
{error}
} +
+ + + + + + + + + + + + + {subs.map((s) => ( + + + + + + + + + ))} + +
AnvändarePlanProviderStatusGår utSenast verifierad
+ {s.userId.slice(0, 8)}… + {s.plan}{s.provider} + {s.status} + {s.expiresAt ? new Date(s.expiresAt).toLocaleDateString("sv-SE") : "–"} + {s.lastVerifiedAt ? new Date(s.lastVerifiedAt).toLocaleString("sv-SE") : "–"} +
+
+
+ ); +} diff --git a/apps/admin/src/pages/Users.tsx b/apps/admin/src/pages/Users.tsx new file mode 100644 index 0000000..f526fda --- /dev/null +++ b/apps/admin/src/pages/Users.tsx @@ -0,0 +1,77 @@ +import { useEffect, useState } from "react"; +import { api } from "../api.js"; + +interface AdminUser { + id: string; + email: string; + displayName: string; + role: string; + onboardingCompleted: boolean; + createdAt: string; + deletedAt: string | null; +} + +export function UsersPage() { + const [users, setUsers] = useState([]); + const [search, setSearch] = useState(""); + const [error, setError] = useState(null); + + const load = (q = "") => { + api<{ users: AdminUser[] }>(`/admin/v1/users?search=${encodeURIComponent(q)}`) + .then((r) => setUsers(r.users)) + .catch((e) => setError(String(e.message))); + }; + useEffect(() => load(), []); + + return ( +
+

Användare

+
+ setSearch(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && load(search)} + /> + +
+ {error &&
{error}
} +
+ + + + + + + + + + + + + {users.map((u) => ( + + + + + + + + + ))} + +
E-postNamnRollOnboardingSkapadStatus
{u.email}{u.displayName} + {u.role} + {u.onboardingCompleted ? "✓" : "–"}{new Date(u.createdAt).toLocaleDateString("sv-SE")} + {u.deletedAt ? ( + raderad + ) : ( + aktiv + )} +
+
+
+ ); +} diff --git a/apps/admin/src/styles.css b/apps/admin/src/styles.css new file mode 100644 index 0000000..5c043fa --- /dev/null +++ b/apps/admin/src/styles.css @@ -0,0 +1,126 @@ +:root { + --bg: #f7f8fa; + --panel: #ffffff; + --text: #1a202c; + --muted: #64748b; + --brand: #16803c; + --brand-dark: #11602d; + --border: #e2e8f0; + --danger: #b91c1c; + --warn: #b45309; +} + +* { box-sizing: border-box; } +body { + margin: 0; + font-family: system-ui, -apple-system, "Segoe UI", sans-serif; + background: var(--bg); + color: var(--text); +} + +.layout { display: flex; min-height: 100vh; } +.sidebar { + width: 220px; + background: #0f2417; + color: #d7e5db; + padding: 1.25rem 0.75rem; + flex-shrink: 0; +} +.sidebar h1 { font-size: 1.1rem; margin: 0 0 1.5rem 0.5rem; color: #fff; } +.sidebar a { + display: block; + padding: 0.55rem 0.75rem; + color: inherit; + text-decoration: none; + border-radius: 8px; + margin-bottom: 2px; + font-size: 0.92rem; +} +.sidebar a:hover { background: rgba(255, 255, 255, 0.08); } +.sidebar a.active { background: var(--brand); color: #fff; } +.sidebar button { + margin: 1.5rem 0.5rem 0; + background: none; + border: 1px solid rgba(255, 255, 255, 0.25); + color: inherit; + padding: 0.4rem 0.8rem; + border-radius: 6px; + cursor: pointer; +} + +.main { flex: 1; padding: 1.5rem 2rem; max-width: 1100px; } +.main h2 { margin-top: 0; } + +.panel { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 12px; + padding: 1.25rem; + margin-bottom: 1.25rem; +} + +table { border-collapse: collapse; width: 100%; font-size: 0.9rem; } +th { text-align: left; color: var(--muted); font-weight: 600; padding: 0.5rem 0.75rem; border-bottom: 2px solid var(--border); } +td { padding: 0.5rem 0.75rem; border-bottom: 1px solid var(--border); vertical-align: top; } + +.badge { + display: inline-block; + padding: 0.1rem 0.55rem; + border-radius: 999px; + font-size: 0.75rem; + font-weight: 600; + background: #e2e8f0; +} +.badge.ok { background: #dcfce7; color: var(--brand-dark); } +.badge.warn { background: #fef3c7; color: var(--warn); } +.badge.err { background: #fee2e2; color: var(--danger); } + +button.primary { + background: var(--brand); + color: #fff; + border: none; + padding: 0.5rem 1rem; + border-radius: 8px; + cursor: pointer; + font-size: 0.9rem; +} +button.primary:hover { background: var(--brand-dark); } +button.ghost { + background: none; + border: 1px solid var(--border); + padding: 0.35rem 0.8rem; + border-radius: 8px; + cursor: pointer; + font-size: 0.85rem; +} +button.danger { border-color: #fecaca; color: var(--danger); } + +input, select { + padding: 0.5rem 0.7rem; + border: 1px solid var(--border); + border-radius: 8px; + font-size: 0.9rem; +} + +.login-wrap { + min-height: 100vh; + display: grid; + place-items: center; +} +.login-box { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 16px; + padding: 2rem; + width: 340px; + display: flex; + flex-direction: column; + gap: 0.75rem; +} +.error-text { color: var(--danger); font-size: 0.85rem; } +.muted { color: var(--muted); font-size: 0.85rem; } +.stat-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 1rem; } +.stat { background: var(--panel); border: 1px solid var(--border); border-radius: 12px; padding: 1rem; } +.stat .value { font-size: 1.6rem; font-weight: 700; } +.stat .label { color: var(--muted); font-size: 0.8rem; } +.row { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; } diff --git a/apps/admin/tsconfig.json b/apps/admin/tsconfig.json new file mode 100644 index 0000000..f3b1c12 --- /dev/null +++ b/apps/admin/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src", "vite.config.ts"], + "compilerOptions": { + "jsx": "react-jsx", + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "types": ["vite/client"] + } +} diff --git a/apps/admin/vite.config.ts b/apps/admin/vite.config.ts new file mode 100644 index 0000000..0c92f1f --- /dev/null +++ b/apps/admin/vite.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + server: { + port: 5173, + }, + build: { + outDir: "dist", + sourcemap: true, + }, +}); diff --git a/apps/api/package.json b/apps/api/package.json new file mode 100644 index 0000000..0aa3a16 --- /dev/null +++ b/apps/api/package.json @@ -0,0 +1,65 @@ +{ + "name": "@app/api", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Food API – enda ingången för mobilappen (spec §31, §50)", + "scripts": { + "dev": "tsx watch src/index.ts", + "start": "node dist/index.js", + "build": "tsup src/index.ts --format esm --target node22 --sourcemap --clean", + "typecheck": "tsc --noEmit", + "test": "vitest run" + }, + "dependencies": { + "@app/ai-contracts": "workspace:*", + "@app/connectors": "workspace:*", + "@app/database": "workspace:*", + "@app/events": "workspace:*", + "@app/feature-flags": "workspace:*", + "@app/inventory-engine": "workspace:*", + "@app/memory-client": "workspace:*", + "@app/nutrition-engine": "workspace:*", + "@app/recipe-engine": "workspace:*", + "@app/recommendation-engine": "workspace:*", + "@app/shared-types": "workspace:*", + "@app/subscriptions": "workspace:*", + "@app/validation": "workspace:*", + "@aws-sdk/client-s3": "^3.1102.0", + "@aws-sdk/s3-request-presigner": "^3.1102.0", + "@fastify/cors": "^11.0.0", + "@fastify/jwt": "^10.0.0", + "@fastify/rate-limit": "^10.0.0", + "bullmq": "^6.0.0", + "dotenv": "^16.4.0", + "drizzle-orm": "^0.45.0", + "fastify": "^5.6.0", + "fastify-plugin": "^5.0.0", + "ioredis": "^6.0.0", + "nodemailer": "^9.0.3", + "pg": "^8.13.0", + "zod": "^4.4.0" + }, + "devDependencies": { + "@types/nodemailer": "^8.0.1", + "@types/pg": "^8.11.0", + "tsup": "^8.3.0" + }, + "tsup": { + "noExternal": [ + "@app/ai-contracts", + "@app/connectors", + "@app/database", + "@app/events", + "@app/feature-flags", + "@app/inventory-engine", + "@app/memory-client", + "@app/nutrition-engine", + "@app/recipe-engine", + "@app/recommendation-engine", + "@app/shared-types", + "@app/subscriptions", + "@app/validation" + ] + } +} diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts new file mode 100644 index 0000000..f044d37 --- /dev/null +++ b/apps/api/src/config.ts @@ -0,0 +1,94 @@ +import { config as loadDotenv } from "dotenv"; +import { existsSync } from "node:fs"; +import path from "node:path"; + +// Ladda .env från paketet ELLER monorepo-roten (pnpm --filter sätter cwd till paketet). +for (const candidate of [".env", "../.env", "../../.env"]) { + const p = path.resolve(process.cwd(), candidate); + if (existsSync(p)) { + loadDotenv({ path: p }); + break; + } +} +import { z } from "zod"; +import { BRAND } from "@app/shared-types"; + +/** All konfiguration valideras vid start – tydliga fel i stället för mystiska krascher. */ +const configSchema = z.object({ + NODE_ENV: z.enum(["development", "test", "production"]).default("development"), + API_PORT: z.coerce.number().int().default(4000), + API_BASE_URL: z.string().default("http://localhost:4000"), + CORS_ORIGINS: z.string().default("http://localhost:5173"), + LOG_LEVEL: z.enum(["fatal", "error", "warn", "info", "debug", "trace"]).default("info"), + + DATABASE_URL: z.string().default("postgres://app_user:app_dev_password@localhost:5432/app"), + REDIS_URL: z.string().default("redis://localhost:6379"), + + JWT_ACCESS_SECRET: z.string().min(10).default("dev-only-change-me"), + JWT_REFRESH_SECRET: z.string().min(10).default("dev-only-change-me-too"), + JWT_ACCESS_TTL_SECONDS: z.coerce.number().int().default(900), + JWT_REFRESH_TTL_SECONDS: z.coerce.number().int().default(2_592_000), + + ENTITLEMENT_SIGNING_SECRET: z.string().min(10).default("dev-only-change-me-three"), + ENTITLEMENT_TTL_HOURS: z.coerce.number().int().default(24), + ENTITLEMENT_GRACE_DAYS: z.coerce.number().int().default(7), + + S3_MODE: z.enum(["mock", "aws"]).default("mock"), + S3_BUCKET: z.string().default(""), + S3_REGION: z.string().default("eu-north-1"), + /** Tom = AWS S3; sätt för S3-kompatibel lagring (MinIO/R2/Hetzner). */ + S3_ENDPOINT: z.string().default(""), + /** Tomma = IAM-roll/instansprofil används (rekommenderat i AWS). */ + S3_ACCESS_KEY_ID: z.string().default(""), + S3_SECRET_ACCESS_KEY: z.string().default(""), + + AAMOS_MODE: z.enum(["http", "mock"]).default("http"), + AAMOS_API_URL: z.string().optional(), + AAMOS_API_KEY: z.string().optional(), + AAMOS_TIMEOUT_MS: z.coerce.number().int().default(60_000), + + APP_STORE_MODE: z.enum(["production", "sandbox"]).default("sandbox"), + EMAIL_MODE: z.enum(["log", "smtp"]).default("log"), + SMTP_HOST: z.string().default(""), + SMTP_PORT: z.coerce.number().int().default(587), + SMTP_SECURE: z + .enum(["true", "false"]) + .default("false") + .transform((v) => v === "true"), + SMTP_USER: z.string().default(""), + SMTP_PASS: z.string().default(""), + MAIL_FROM: z.string().default(""), + APPLE_BUNDLE_ID: z.string().default(""), + GOOGLE_PACKAGE_NAME: z.string().default(""), +}); + +export type AppConfig = z.infer; + +export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig { + const parsed = configSchema.safeParse(env); + if (!parsed.success) { + console.error("Ogiltig konfiguration:", parsed.error.flatten().fieldErrors); + process.exit(1); + } + const cfg = parsed.data; + // Varumärkesberoende defaults ur brand.config.json (i18n-spec §28) + if (!cfg.S3_BUCKET) cfg.S3_BUCKET = `${BRAND.slug}-production`; + if (!cfg.APPLE_BUNDLE_ID) cfg.APPLE_BUNDLE_ID = BRAND.iosBundleId; + if (!cfg.GOOGLE_PACKAGE_NAME) cfg.GOOGLE_PACKAGE_NAME = BRAND.androidPackage; + if (cfg.NODE_ENV === "production") { + const insecure = [ + cfg.JWT_ACCESS_SECRET, + cfg.JWT_REFRESH_SECRET, + cfg.ENTITLEMENT_SIGNING_SECRET, + ].some((s) => s.startsWith("dev-only")); + if (insecure) { + console.error("SÄKERHETSSTOPP: dev-hemligheter i produktion (spec §56)."); + process.exit(1); + } + if (cfg.AAMOS_MODE === "mock") { + console.error("SÄKERHETSSTOPP: AAMOS_MODE=mock är inte tillåtet i produktion."); + process.exit(1); + } + } + return cfg; +} diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts new file mode 100644 index 0000000..3bc4964 --- /dev/null +++ b/apps/api/src/index.ts @@ -0,0 +1,21 @@ +import { loadConfig } from "./config.js"; +import { buildServer } from "./server.js"; + +const config = loadConfig(); +const app = await buildServer(config); + +const shutdown = async (signal: string) => { + app.log.info(`${signal} mottagen – stänger ner API:t.`); + await app.close(); + process.exit(0); +}; +process.on("SIGTERM", () => void shutdown("SIGTERM")); +process.on("SIGINT", () => void shutdown("SIGINT")); + +try { + await app.listen({ port: config.API_PORT, host: "0.0.0.0" }); + app.log.info(`API igång på :${config.API_PORT} (${config.NODE_ENV}, AAMOS=${config.AAMOS_MODE})`); +} catch (err) { + app.log.error(err); + process.exit(1); +} diff --git a/apps/api/src/lib/contentLanguage.ts b/apps/api/src/lib/contentLanguage.ts new file mode 100644 index 0000000..59bff5f --- /dev/null +++ b/apps/api/src/lib/contentLanguage.ts @@ -0,0 +1,105 @@ +import { and, eq, inArray } from "drizzle-orm"; +import { schema, type Database } from "@app/database"; +import { loadLocalePreferences } from "./localeContext.js"; + +/** + * Innehållsspråk (i18n-spec §11–14): svensk källtext är sanningen, publicerade + * översättningar är vyer. Upplösning: exakt tagg ("en-US") → primärt språk + * ("en") → svensk källa. Endast status=published serveras till användare. + */ + +/** "en-US" -> ["en-US", "en"], "sv" -> ["sv"]. */ +export function languageCandidates(languageTag: string): string[] { + const primary = languageTag.split("-")[0] ?? languageTag; + return primary === languageTag ? [languageTag] : [languageTag, primary]; +} + +export async function userLanguageTag(db: Database, userId: string): Promise { + const prefs = await loadLocalePreferences(db, userId); + return prefs.languageTag; +} + +export interface ResolvedRecipeText { + language: string; + title: string; + description: string | null; + storageGuidance: string | null; + /** stepNumber -> { instruction, tip } */ + steps: Map; +} + +/** + * Hämtar publicerad receptöversättning för användarens språk, eller null om + * svensk källa ska användas. En DB-runda för texterna + en för stegen. + */ +export async function resolveRecipeTranslation( + db: Database, + recipeId: string, + languageTag: string, +): Promise { + const candidates = languageCandidates(languageTag); + if (candidates.includes("sv")) return null; + + const rows = await db + .select() + .from(schema.recipeTranslations) + .where( + and( + eq(schema.recipeTranslations.recipeId, recipeId), + inArray(schema.recipeTranslations.languageTag, candidates), + eq(schema.recipeTranslations.status, "published"), + ), + ); + if (rows.length === 0) return null; + const best = + candidates.map((c) => rows.find((r) => r.languageTag === c)).find(Boolean) ?? rows[0]!; + + const stepRows = await db + .select() + .from(schema.recipeStepTranslations) + .where( + and( + eq(schema.recipeStepTranslations.recipeId, recipeId), + eq(schema.recipeStepTranslations.languageTag, best.languageTag), + ), + ); + return { + language: best.languageTag, + title: best.title, + description: best.description, + storageGuidance: best.storageGuidance, + steps: new Map(stepRows.map((s) => [s.stepNumber, { instruction: s.instruction, tip: s.tip }])), + }; +} + +/** + * Ingrediensnamn för användarens språk: Map ingredientId -> namn. + * Saknade id:n behåller svenskt namn (fallback sker hos anroparen). + */ +export async function resolveIngredientNames( + db: Database, + ingredientIds: string[], + languageTag: string, +): Promise> { + const candidates = languageCandidates(languageTag); + if (candidates.includes("sv") || ingredientIds.length === 0) return new Map(); + const rows = await db + .select({ + ingredientId: schema.ingredientTranslations.ingredientId, + languageTag: schema.ingredientTranslations.languageTag, + name: schema.ingredientTranslations.name, + }) + .from(schema.ingredientTranslations) + .where( + and( + inArray(schema.ingredientTranslations.ingredientId, ingredientIds), + inArray(schema.ingredientTranslations.languageTag, candidates), + eq(schema.ingredientTranslations.status, "published"), + ), + ); + const out = new Map(); + for (const candidate of [...candidates].reverse()) { + for (const r of rows) if (r.languageTag === candidate) out.set(r.ingredientId, r.name); + } + return out; +} diff --git a/apps/api/src/lib/entitlements.ts b/apps/api/src/lib/entitlements.ts new file mode 100644 index 0000000..50731b7 --- /dev/null +++ b/apps/api/src/lib/entitlements.ts @@ -0,0 +1,101 @@ +import { and, desc, eq } from "drizzle-orm"; +import type { FastifyInstance } from "fastify"; +import { schema, type Database } from "@app/database"; +import type { Entitlements } from "@app/shared-types"; +import { + computeEntitlements, + signEntitlementToken, + type SubscriptionSnapshot, + type TrialSnapshot, +} from "@app/subscriptions"; +import { currentMonth } from "./helpers.js"; +import { errors } from "./errors.js"; + +/** Ladda och beräkna entitlements för en användare. Backend = source of truth (spec §61.14). */ +export async function loadEntitlements(db: Database, userId: string): Promise { + const [subRow] = await db + .select() + .from(schema.subscriptions) + .where(eq(schema.subscriptions.userId, userId)) + .orderBy(desc(schema.subscriptions.updatedAt)) + .limit(1); + + const [trialRow] = await db + .select() + .from(schema.trials) + .where(eq(schema.trials.userId, userId)) + .limit(1); + + const [usageRow] = await db + .select() + .from(schema.aiUsageCounters) + .where( + and( + eq(schema.aiUsageCounters.userId, userId), + eq(schema.aiUsageCounters.month, currentMonth()), + ), + ) + .limit(1); + + const subscription: SubscriptionSnapshot | null = subRow + ? { + plan: subRow.plan, + status: subRow.status, + expiresAt: subRow.expiresAt, + gracePeriodExpiresAt: subRow.gracePeriodExpiresAt, + } + : null; + const trial: TrialSnapshot | null = trialRow + ? { startedAt: trialRow.startedAt, endsAt: trialRow.endsAt } + : null; + + return computeEntitlements(subscription, trial, { + aiScansUsedThisMonth: usageRow?.aiScans ?? 0, + }); +} + +/** Entitlements + signerad offline-token (spec §44). */ +export async function loadEntitlementsWithToken( + app: FastifyInstance, + userId: string, +): Promise { + const ent = await loadEntitlements(app.db, userId); + ent.signedToken = signEntitlementToken(userId, ent, app.config.ENTITLEMENT_SIGNING_SECRET, { + ttlHours: app.config.ENTITLEMENT_TTL_HOURS, + graceDays: app.config.ENTITLEMENT_GRACE_DAYS, + }); + return ent; +} + +/** Kräv AI-kvot och räkna upp användningen atomiskt. */ +export async function consumeAiScan(db: Database, userId: string): Promise { + const ent = await loadEntitlements(db, userId); + if (ent.aiScansUsedThisMonth >= ent.aiScansPerMonth) { + throw errors.quotaExceeded( + `Månadens AI-skanningar är slut (${ent.aiScansPerMonth} st). Uppgradera för fler.`, + ); + } + const month = currentMonth(); + await db + .insert(schema.aiUsageCounters) + .values({ userId, month, aiScans: 1 }) + .onConflictDoUpdate({ + target: [schema.aiUsageCounters.userId, schema.aiUsageCounters.month], + set: { aiScans: (await import("drizzle-orm")).sql`${schema.aiUsageCounters.aiScans} + 1` }, + }); +} + +/** Kräv en premiumfunktion (t.ex. veckoplanering). */ +export async function requireFeature( + db: Database, + userId: string, + feature: "weekPlanning" | "advancedNutrition" | "communityPublish", + labelSv: string, +): Promise { + const ent = await loadEntitlements(db, userId); + if (!ent[feature]) { + throw errors.paymentRequired( + `${labelSv} ingår i Premium. Starta din provperiod eller uppgradera.`, + ); + } +} diff --git a/apps/api/src/lib/errors.ts b/apps/api/src/lib/errors.ts new file mode 100644 index 0000000..e66607b --- /dev/null +++ b/apps/api/src/lib/errors.ts @@ -0,0 +1,39 @@ +import type { ZodType } from "zod"; + +/** Applikationsfel med HTTP-status och maskinläsbar kod. */ +export class ApiError extends Error { + constructor( + public readonly statusCode: number, + public readonly code: string, + message: string, + public readonly details?: unknown, + ) { + super(message); + this.name = "ApiError"; + } +} + +export const errors = { + badRequest: (msg: string, details?: unknown) => new ApiError(400, "BAD_REQUEST", msg, details), + unauthorized: (msg = "Ogiltig eller saknad autentisering") => + new ApiError(401, "UNAUTHORIZED", msg), + forbidden: (msg = "Åtkomst nekad") => new ApiError(403, "FORBIDDEN", msg), + notFound: (msg = "Resursen finns inte") => new ApiError(404, "NOT_FOUND", msg), + conflict: (msg: string) => new ApiError(409, "CONFLICT", msg), + quotaExceeded: (msg: string) => new ApiError(429, "QUOTA_EXCEEDED", msg), + paymentRequired: (msg: string) => new ApiError(402, "PREMIUM_REQUIRED", msg), + internal: (msg = "Internt fel") => new ApiError(500, "INTERNAL", msg), +}; + +/** + * Validera indata mot ett Zod-schema. Kastar 400 med detaljer vid fel. + * Medvetet vald i stället för type-provider: färre rörliga delar i + * major-versionsgränser, samma säkerhet (se docs/beslutslogg.md D-011). + */ +export function parse(schema: ZodType, data: unknown, what = "indata"): T { + const result = schema.safeParse(data); + if (!result.success) { + throw new ApiError(400, "VALIDATION_ERROR", `Ogiltig ${what}`, result.error.issues); + } + return result.data; +} diff --git a/apps/api/src/lib/helpers.ts b/apps/api/src/lib/helpers.ts new file mode 100644 index 0000000..4d15a26 --- /dev/null +++ b/apps/api/src/lib/helpers.ts @@ -0,0 +1,112 @@ +import { createHash, randomBytes, randomUUID } from "node:crypto"; +import { and, eq } from "drizzle-orm"; +import type { Database } from "@app/database"; +import { schema } from "@app/database"; +import type { EventType } from "@app/shared-types"; +import type { NewDomainEvent } from "@app/events"; +import { errors } from "./errors.js"; + +/** Slumpad, läsbar inbjudningskod utan lättförväxlade tecken. */ +export function generateInviteCode(): string { + const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; + const bytes = randomBytes(8); + let code = ""; + for (const b of bytes) code += alphabet[b % alphabet.length]; + return code; +} + +export function sha256(input: string): string { + return createHash("sha256").update(input).digest("hex"); +} + +export function todayIso(): string { + return new Date().toISOString().slice(0, 10); +} + +export function currentMonth(): string { + return new Date().toISOString().slice(0, 7); +} + +/** Verifiera hushållsmedlemskap – enda vägen till hushållsdata (spec §56). */ +export async function requireMembership( + db: Database, + householdId: string, + userId: string, +): Promise<{ role: string; portionFactor: number }> { + const [member] = await db + .select() + .from(schema.householdMembers) + .where( + and( + eq(schema.householdMembers.householdId, householdId), + eq(schema.householdMembers.userId, userId), + ), + ) + .limit(1); + if (!member) throw errors.forbidden("Du är inte medlem i det här hushållet."); + return { role: member.role, portionFactor: member.portionFactor }; +} + +/** Hämta användarens aktiva hushåll (första medlemskapet) eller null. */ +export async function getActiveHouseholdId(db: Database, userId: string): Promise { + const [member] = await db + .select({ householdId: schema.householdMembers.householdId }) + .from(schema.householdMembers) + .where(eq(schema.householdMembers.userId, userId)) + .orderBy(schema.householdMembers.joinedAt) + .limit(1); + return member?.householdId ?? null; +} + +/** Som ovan men kastar 404 om användaren saknar hushåll. */ +export async function requireActiveHousehold(db: Database, userId: string): Promise { + const id = await getActiveHouseholdId(db, userId); + if (!id) { + throw errors.notFound("Du har inget hushåll ännu. Skapa ett under Hemma → Hushåll."); + } + return id; +} + +/** Skriv domänhändelse till outboxen (spec §55) – i samma transaktion när tx skickas in. */ +export async function emitEvent( + db: Database, + event: NewDomainEvent, +): Promise { + await db.insert(schema.domainEvents).values({ + type: event.type, + userId: event.userId ?? null, + householdId: event.householdId ?? null, + payload: event.payload as Record, + correlationId: event.correlationId ?? null, + }); +} + +/** Audit-logg (spec §56). */ +export async function audit( + db: Database, + entry: { + actorUserId?: string | undefined; + actorType?: "user" | "admin" | "system" | "worker"; + action: string; + targetType?: string; + targetId?: string; + metadata?: Record; + ip?: string | undefined; + correlationId?: string | undefined; + }, +): Promise { + await db.insert(schema.auditLogs).values({ + actorUserId: entry.actorUserId ?? null, + actorType: entry.actorType ?? "user", + action: entry.action, + targetType: entry.targetType ?? null, + targetId: entry.targetId ?? null, + metadata: entry.metadata ?? null, + ip: entry.ip ?? null, + correlationId: entry.correlationId ?? null, + }); +} + +export function newCorrelationId(): string { + return randomUUID(); +} diff --git a/apps/api/src/lib/localeContext.ts b/apps/api/src/lib/localeContext.ts new file mode 100644 index 0000000..ac01fcd --- /dev/null +++ b/apps/api/src/lib/localeContext.ts @@ -0,0 +1,36 @@ +import { eq } from "drizzle-orm"; +import { schema, type Database } from "@app/database"; +import { + DEFAULT_LOCALE_PREFERENCES, + toLocaleContext, + type LocaleContext, + type UserLocalePreferences, +} from "@app/shared-types"; + +/** Läs användarens locale-preferenser med SE-defaults som fallback (i18n-spec §6). */ +export async function loadLocalePreferences( + db: Database, + userId: string, +): Promise { + const [row] = await db + .select() + .from(schema.userLocalePreferences) + .where(eq(schema.userLocalePreferences.userId, userId)) + .limit(1); + if (!row) return { ...DEFAULT_LOCALE_PREFERENCES }; + return { + languageTag: row.languageTag, + regionCode: row.regionCode, + timeZone: row.timeZone, + measurementSystem: row.measurementSystem, + temperatureUnit: row.temperatureUnit, + currencyCode: row.currencyCode, + firstDayOfWeek: row.firstDayOfWeek, + use24HourTime: row.use24HourTime, + }; +} + +/** LocaleContext till varje AAMOS-anrop (i18n-spec §22). */ +export async function getLocaleContext(db: Database, userId: string): Promise { + return toLocaleContext(await loadLocalePreferences(db, userId)); +} diff --git a/apps/api/src/lib/mailer.ts b/apps/api/src/lib/mailer.ts new file mode 100644 index 0000000..212690a --- /dev/null +++ b/apps/api/src/lib/mailer.ts @@ -0,0 +1,310 @@ +import nodemailer, { type Transporter } from "nodemailer"; +import { BRAND } from "@app/shared-types"; + +/** + * E-postutskick med utbytbar transport. + * + * - EMAIL_MODE=log (dev/test): skriver mejlet till loggen i stället för att skicka. + * - EMAIL_MODE=smtp (produktion): kopplas till vald leverantör (SES/Postmark/Resend) + * i lanseringsfasen – transporten är en funktion, leverantörsbytet är en fil. + * + * Innehållet byggs som mallnyckel + variabler per språk (i18n-spec §26) – + * aldrig inbakad text i anropskoden. + */ + +export interface MailMessage { + to: string; + subject: string; + text: string; +} + +export interface Mailer { + send(message: MailMessage): Promise; +} + +class LogMailer implements Mailer { + async send(message: MailMessage): Promise { + console.log( + `[mailer:log] till=${message.to} ämne="${message.subject}"\n${message.text + .split("\n") + .map((l) => ` | ${l}`) + .join("\n")}`, + ); + } +} + +export interface SmtpSettings { + host: string; + port: number; + secure: boolean; + user: string; + pass: string; + from: string; +} + +/** + * Riktig SMTP via nodemailer. Leverantörsoberoende: fungerar med SES, + * Postmark, Resend, Brevo, egen mailserver – allt som talar standard-SMTP. + * Verifierad med riktig SMTP-rundtur (HELO → MAIL FROM → DATA) mot lokal server. + */ +class SmtpMailer implements Mailer { + private readonly transporter: Transporter; + private readonly from: string; + + constructor(settings: SmtpSettings) { + if (!settings.host) { + // Högljutt fel i stället för tyst tappade mejl. + throw new Error("EMAIL_MODE=smtp kräver SMTP_HOST (samt SMTP_PORT/USER/PASS/MAIL_FROM)."); + } + if (!settings.from) { + throw new Error("EMAIL_MODE=smtp kräver MAIL_FROM (avsändaradress)."); + } + this.from = settings.from; + this.transporter = nodemailer.createTransport({ + host: settings.host, + port: settings.port, + secure: settings.secure, // true = SMTPS (465); false = STARTTLS/klartext (587/25) + ...(settings.user ? { auth: { user: settings.user, pass: settings.pass } } : {}), + }); + } + + /** Verifiera anslutning + inloggning vid uppstart. */ + async verify(): Promise { + await this.transporter.verify(); + } + + async send(message: MailMessage): Promise { + await this.transporter.sendMail({ + from: this.from, + to: message.to, + subject: message.subject, + text: message.text, + }); + } +} + +export function createMailer(mode: string, smtp?: SmtpSettings): Mailer { + if (mode === "smtp") { + if (!smtp) throw new Error("EMAIL_MODE=smtp kräver SMTP-inställningar."); + return new SmtpMailer(smtp); + } + return new LogMailer(); +} + +// --------------------------------------------------------------------------- +// Mallar (nyckel + variabler, per språk – samma princip som notismallarna) +// --------------------------------------------------------------------------- + +type MailTemplate = (vars: Record) => { subject: string; text: string }; + +const TEMPLATES: Record> = { + sv: { + "auth.verify_email": (v) => ({ + subject: `Bekräfta din e-postadress – ${BRAND.name}`, + text: + `Välkommen till ${BRAND.name}!\n\n` + + `Bekräfta att ${v.email} är din adress genom att öppna länken i appen:\n${v.link}\n\n` + + `Länken gäller i 24 timmar. Appen fungerar även utan bekräftelse, men vissa ` + + `funktioner (t.ex. lösenordsåterställning) blir säkrare med verifierad adress.\n\n${BRAND.name}`, + }), + "auth.password_reset": (v) => ({ + subject: `Återställ ditt lösenord – ${BRAND.name}`, + text: + `Hej!\n\nDu (eller någon annan) har begärt att återställa lösenordet för ${v.email}.\n\n` + + `Öppna länken i appen inom 30 minuter:\n${v.link}\n\n` + + `Om du inte begärde detta kan du ignorera mejlet – lösenordet ändras inte.\n\n${BRAND.name}`, + }), + }, + en: { + "auth.verify_email": (v) => ({ + subject: `Confirm your email – ${BRAND.name}`, + text: + `Welcome to ${BRAND.name}!\n\n` + + `Confirm that ${v.email} is your address by opening this link in the app:\n${v.link}\n\n` + + `The link is valid for 24 hours. The app works without confirmation, but some ` + + `features (like password reset) are safer with a verified address.\n\n${BRAND.name}`, + }), + "auth.password_reset": (v) => ({ + subject: `Reset your password – ${BRAND.name}`, + text: + `Hi!\n\nA password reset was requested for ${v.email}.\n\n` + + `Open this link in the app within 30 minutes:\n${v.link}\n\n` + + `If you didn't request this, you can ignore this email – your password stays unchanged.\n\n${BRAND.name}`, + }), + }, + es: { + "auth.verify_email": (v) => ({ + subject: `Confirma tu correo – ${BRAND.name}`, + text: + `¡Bienvenido a ${BRAND.name}!\n\n` + + `Confirma que ${v.email} es tu dirección abriendo este enlace en la app:\n${v.link}\n\n` + + `El enlace es válido durante 24 horas. La app funciona sin confirmación, pero algunas ` + + `funciones (como restablecer la contraseña) son más seguras con la dirección verificada.\n\n${BRAND.name}`, + }), + "auth.password_reset": (v) => ({ + subject: `Restablece tu contraseña – ${BRAND.name}`, + text: + `¡Hola!\n\nSe ha solicitado restablecer la contraseña de ${v.email}.\n\n` + + `Abre este enlace en la app en un plazo de 30 minutos:\n${v.link}\n\n` + + `Si no lo solicitaste, ignora este correo: tu contraseña no cambiará.\n\n${BRAND.name}`, + }), + }, + it: { + "auth.verify_email": (v) => ({ + subject: `Conferma la tua email – ${BRAND.name}`, + text: + `Benvenuto su ${BRAND.name}!\n\n` + + `Conferma che ${v.email} è il tuo indirizzo aprendo questo link nell'app:\n${v.link}\n\n` + + `Il link è valido per 24 ore. L'app funziona anche senza conferma, ma alcune ` + + `funzioni (come il ripristino della password) sono più sicure con l'indirizzo verificato.\n\n${BRAND.name}`, + }), + "auth.password_reset": (v) => ({ + subject: `Reimposta la tua password – ${BRAND.name}`, + text: + `Ciao!\n\nÈ stato richiesto il ripristino della password per ${v.email}.\n\n` + + `Apri questo link nell'app entro 30 minuti:\n${v.link}\n\n` + + `Se non l'hai richiesto, ignora questa email: la password non cambierà.\n\n${BRAND.name}`, + }), + }, + de: { + "auth.verify_email": (v) => ({ + subject: `Bestätige deine E-Mail-Adresse – ${BRAND.name}`, + text: + `Willkommen bei ${BRAND.name}!\n\n` + + `Bestätige, dass ${v.email} deine Adresse ist, indem du diesen Link in der App öffnest:\n${v.link}\n\n` + + `Der Link ist 24 Stunden gültig. Die App funktioniert auch ohne Bestätigung, aber manche ` + + `Funktionen (z. B. Passwort-Zurücksetzen) sind mit verifizierter Adresse sicherer.\n\n${BRAND.name}`, + }), + "auth.password_reset": (v) => ({ + subject: `Setze dein Passwort zurück – ${BRAND.name}`, + text: + `Hallo!\n\nFür ${v.email} wurde ein Passwort-Reset angefordert.\n\n` + + `Öffne diesen Link innerhalb von 30 Minuten in der App:\n${v.link}\n\n` + + `Falls du das nicht warst, ignoriere diese E-Mail – dein Passwort bleibt unverändert.\n\n${BRAND.name}`, + }), + }, + fr: { + "auth.verify_email": (v) => ({ + subject: `Confirmez votre e-mail – ${BRAND.name}`, + text: + `Bienvenue sur ${BRAND.name} !\n\n` + + `Confirmez que ${v.email} est bien votre adresse en ouvrant ce lien dans l'app :\n${v.link}\n\n` + + `Le lien est valable 24 heures. L'app fonctionne sans confirmation, mais certaines ` + + `fonctions (comme la réinitialisation du mot de passe) sont plus sûres avec une adresse vérifiée.\n\n${BRAND.name}`, + }), + "auth.password_reset": (v) => ({ + subject: `Réinitialisez votre mot de passe – ${BRAND.name}`, + text: + `Bonjour !\n\nUne réinitialisation du mot de passe a été demandée pour ${v.email}.\n\n` + + `Ouvrez ce lien dans l'app sous 30 minutes :\n${v.link}\n\n` + + `Si vous n'êtes pas à l'origine de cette demande, ignorez cet e-mail : le mot de passe ne changera pas.\n\n${BRAND.name}`, + }), + }, + da: { + "auth.verify_email": (v) => ({ + subject: `Bekræft din e-mailadresse – ${BRAND.name}`, + text: + `Velkommen til ${BRAND.name}!\n\n` + + `Bekræft, at ${v.email} er din adresse ved at åbne linket i appen:\n${v.link}\n\n` + + `Linket gælder i 24 timer. Appen fungerer også uden bekræftelse, men nogle funktioner (fx nulstilling af adgangskode) er sikrere med bekræftet adresse.\n\n${BRAND.name}`, + }), + "auth.password_reset": (v) => ({ + subject: `Nulstil din adgangskode – ${BRAND.name}`, + text: + `Hej!\n\nDer er anmodet om nulstilling af adgangskoden for ${v.email}.\n\n` + + `Åbn linket i appen inden for 30 minutter:\n${v.link}\n\n` + + `Hvis du ikke har anmodet om dette, kan du ignorere denne mail – adgangskoden ændres ikke.\n\n${BRAND.name}`, + }), + }, + nb: { + "auth.verify_email": (v) => ({ + subject: `Bekreft e-postadressen din – ${BRAND.name}`, + text: + `Velkommen til ${BRAND.name}!\n\n` + + `Bekreft at ${v.email} er adressen din ved å åpne lenken i appen:\n${v.link}\n\n` + + `Lenken gjelder i 24 timer. Appen fungerer også uten bekreftelse, men noen funksjoner (f.eks. tilbakestilling av passord) er tryggere med bekreftet adresse.\n\n${BRAND.name}`, + }), + "auth.password_reset": (v) => ({ + subject: `Tilbakestill passordet ditt – ${BRAND.name}`, + text: + `Hei!\n\nDet er bedt om tilbakestilling av passordet for ${v.email}.\n\n` + + `Åpne lenken i appen innen 30 minutter:\n${v.link}\n\n` + + `Hvis du ikke ba om dette, kan du ignorere denne e-posten – passordet endres ikke.\n\n${BRAND.name}`, + }), + }, + fi: { + "auth.verify_email": (v) => ({ + subject: `Vahvista sähköpostiosoitteesi – ${BRAND.name}`, + text: + `Tervetuloa palveluun ${BRAND.name}!\n\n` + + `Vahvista, että ${v.email} on osoitteesi avaamalla linkki sovelluksessa:\n${v.link}\n\n` + + `Linkki on voimassa 24 tuntia. Sovellus toimii ilman vahvistustakin, mutta jotkin toiminnot (kuten salasanan nollaus) ovat turvallisempia vahvistetulla osoitteella.\n\n${BRAND.name}`, + }), + "auth.password_reset": (v) => ({ + subject: `Nollaa salasanasi – ${BRAND.name}`, + text: + `Hei!\n\nOsoitteelle ${v.email} on pyydetty salasanan nollausta.\n\n` + + `Avaa linkki sovelluksessa 30 minuutin kuluessa:\n${v.link}\n\n` + + `Jos et pyytänyt tätä, voit ohittaa viestin – salasana ei muutu.\n\n${BRAND.name}`, + }), + }, + nl: { + "auth.verify_email": (v) => ({ + subject: `Bevestig je e-mailadres – ${BRAND.name}`, + text: + `Welkom bij ${BRAND.name}!\n\n` + + `Bevestig dat ${v.email} jouw adres is door deze link in de app te openen:\n${v.link}\n\n` + + `De link is 24 uur geldig. De app werkt ook zonder bevestiging, maar sommige functies (zoals wachtwoordherstel) zijn veiliger met een bevestigd adres.\n\n${BRAND.name}`, + }), + "auth.password_reset": (v) => ({ + subject: `Stel je wachtwoord opnieuw in – ${BRAND.name}`, + text: + `Hoi!\n\nEr is een wachtwoordherstel aangevraagd voor ${v.email}.\n\n` + + `Open deze link binnen 30 minuten in de app:\n${v.link}\n\n` + + `Als jij dit niet was, kun je deze e-mail negeren – het wachtwoord verandert niet.\n\n${BRAND.name}`, + }), + }, + pl: { + "auth.verify_email": (v) => ({ + subject: `Potwierdź swój adres e-mail – ${BRAND.name}`, + text: + `Witamy w ${BRAND.name}!\n\n` + + `Potwierdź, że ${v.email} to Twój adres, otwierając link w aplikacji:\n${v.link}\n\n` + + `Link jest ważny 24 godziny. Aplikacja działa bez potwierdzenia, ale niektóre funkcje (np. resetowanie hasła) są bezpieczniejsze ze zweryfikowanym adresem.\n\n${BRAND.name}`, + }), + "auth.password_reset": (v) => ({ + subject: `Zresetuj swoje hasło – ${BRAND.name}`, + text: + `Cześć!\n\nPoproszono o zresetowanie hasła dla ${v.email}.\n\n` + + `Otwórz link w aplikacji w ciągu 30 minut:\n${v.link}\n\n` + + `Jeśli to nie Ty, zignoruj tę wiadomość – hasło się nie zmieni.\n\n${BRAND.name}`, + }), + }, + pt: { + "auth.verify_email": (v) => ({ + subject: `Confirme o seu e-mail – ${BRAND.name}`, + text: + `Bem-vindo ao ${BRAND.name}!\n\n` + + `Confirme que ${v.email} é o seu endereço abrindo este link na app:\n${v.link}\n\n` + + `O link é válido por 24 horas. A app funciona sem confirmação, mas algumas funções (como repor a palavra-passe) são mais seguras com o endereço verificado.\n\n${BRAND.name}`, + }), + "auth.password_reset": (v) => ({ + subject: `Reponha a sua palavra-passe – ${BRAND.name}`, + text: + `Olá!\n\nFoi pedida a reposição da palavra-passe de ${v.email}.\n\n` + + `Abra este link na app no prazo de 30 minutos:\n${v.link}\n\n` + + `Se não fez este pedido, ignore este e-mail – a palavra-passe não será alterada.\n\n${BRAND.name}`, + }), + }, +}; + +export function renderMail( + templateKey: string, + languageTag: string, + vars: Record, +): { subject: string; text: string } { + const lang = languageTag.split("-")[0] ?? "sv"; + const template = TEMPLATES[lang]?.[templateKey] ?? TEMPLATES.sv?.[templateKey]; + if (!template) throw new Error(`Okänd mejlmall: ${templateKey}`); + return template(vars); +} diff --git a/apps/api/src/lib/passwords.ts b/apps/api/src/lib/passwords.ts new file mode 100644 index 0000000..b277827 --- /dev/null +++ b/apps/api/src/lib/passwords.ts @@ -0,0 +1,50 @@ +import { randomBytes, scrypt, timingSafeEqual } from "node:crypto"; + +/** + * Lösenordshantering med scrypt ur node:crypto – ingen native-dependency, + * OWASP-rekommenderade parametrar (N=2^15, r=8, p=1, 32 byte). + * Format: scrypt$N$r$p$salt_b64$hash_b64 (självbeskrivande → parametrar kan höjas). + */ + +const N = 32_768; +const R = 8; +const P = 1; +const KEYLEN = 32; + +function scryptAsync( + password: string, + salt: Buffer, + n: number, + r: number, + p: number, +): Promise { + return new Promise((resolve, reject) => { + scrypt(password, salt, KEYLEN, { N: n, r, p, maxmem: 128 * n * r * 2 }, (err, key) => { + if (err) reject(err); + else resolve(key); + }); + }); +} + +export async function hashPassword(password: string): Promise { + const salt = randomBytes(16); + const hash = await scryptAsync(password, salt, N, R, P); + return `scrypt$${N}$${R}$${P}$${salt.toString("base64")}$${hash.toString("base64")}`; +} + +export async function verifyPassword(password: string, stored: string): Promise { + const parts = stored.split("$"); + if (parts.length !== 6 || parts[0] !== "scrypt") return false; + const n = Number(parts[1]); + const r = Number(parts[2]); + const p = Number(parts[3]); + const salt = Buffer.from(parts[4]!, "base64"); + const expected = Buffer.from(parts[5]!, "base64"); + if (!Number.isFinite(n) || !Number.isFinite(r) || !Number.isFinite(p)) return false; + try { + const actual = await scryptAsync(password, salt, n, r, p); + return actual.length === expected.length && timingSafeEqual(actual, expected); + } catch { + return false; + } +} diff --git a/apps/api/src/lib/totp.ts b/apps/api/src/lib/totp.ts new file mode 100644 index 0000000..6761bcb --- /dev/null +++ b/apps/api/src/lib/totp.ts @@ -0,0 +1,92 @@ +import { createHmac, randomBytes, timingSafeEqual } from "node:crypto"; + +/** + * TOTP (RFC 6238) utan externa beroenden – HMAC-SHA1, 6 siffror, 30 s steg. + * Används för admin-2FA. Inga tredjepartspaket i autentiseringskedjan + * (samma princip som scrypt-valet för lösenord). + */ + +const BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + +export function base32Encode(buf: Buffer): string { + let bits = 0; + let value = 0; + let out = ""; + for (const byte of buf) { + value = (value << 8) | byte; + bits += 8; + while (bits >= 5) { + out += BASE32_ALPHABET[(value >>> (bits - 5)) & 31]; + bits -= 5; + } + } + if (bits > 0) out += BASE32_ALPHABET[(value << (5 - bits)) & 31]; + return out; +} + +export function base32Decode(s: string): Buffer { + const clean = s.toUpperCase().replace(/=+$/, "").replace(/\s/g, ""); + let bits = 0; + let value = 0; + const out: number[] = []; + for (const ch of clean) { + const idx = BASE32_ALPHABET.indexOf(ch); + if (idx === -1) throw new Error("Ogiltig base32"); + value = (value << 5) | idx; + bits += 5; + if (bits >= 8) { + out.push((value >>> (bits - 8)) & 0xff); + bits -= 8; + } + } + return Buffer.from(out); +} + +/** Nytt slumpat TOTP-secret (160 bitar enligt RFC 4226-rekommendation). */ +export function generateTotpSecret(): string { + return base32Encode(randomBytes(20)); +} + +function hotp(secretBase32: string, counter: number): string { + const key = base32Decode(secretBase32); + const msg = Buffer.alloc(8); + msg.writeBigUInt64BE(BigInt(counter)); + const digest = createHmac("sha1", key).update(msg).digest(); + const offset = digest[digest.length - 1]! & 0x0f; + const code = + ((digest[offset]! & 0x7f) << 24) | + (digest[offset + 1]! << 16) | + (digest[offset + 2]! << 8) | + digest[offset + 3]!; + return String(code % 1_000_000).padStart(6, "0"); +} + +export function totpCode(secretBase32: string, atMs = Date.now(), stepSeconds = 30): string { + return hotp(secretBase32, Math.floor(atMs / 1000 / stepSeconds)); +} + +/** + * Verifiera med ±1 stegs fönster (klockdrift). Konstanttidsjämförelse. + * Returnerar det matchade steget (för engångsskydd) eller null. + */ +export function verifyTotp( + secretBase32: string, + code: string, + atMs = Date.now(), + stepSeconds = 30, +): number | null { + const normalized = code.replace(/\s/g, ""); + if (!/^\d{6}$/.test(normalized)) return null; + const step = Math.floor(atMs / 1000 / stepSeconds); + for (const candidate of [step, step - 1, step + 1]) { + const expected = hotp(secretBase32, candidate); + if (timingSafeEqual(Buffer.from(expected), Buffer.from(normalized))) return candidate; + } + return null; +} + +/** otpauth-URL för autentiseringsappar (Google Authenticator, 1Password …). */ +export function otpauthUrl(secretBase32: string, accountName: string, issuer: string): string { + const label = encodeURIComponent(`${issuer}:${accountName}`); + return `otpauth://totp/${label}?secret=${secretBase32}&issuer=${encodeURIComponent(issuer)}&algorithm=SHA1&digits=6&period=30`; +} diff --git a/apps/api/src/plugins/auth.ts b/apps/api/src/plugins/auth.ts new file mode 100644 index 0000000..26ac6c0 --- /dev/null +++ b/apps/api/src/plugins/auth.ts @@ -0,0 +1,77 @@ +import fp from "fastify-plugin"; +import fastifyJwt from "@fastify/jwt"; +import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import { eq } from "drizzle-orm"; +import { schema } from "@app/database"; +import type { UserRole } from "@app/shared-types"; +import { errors } from "../lib/errors.js"; + +export interface AccessTokenPayload { + sub: string; + role: UserRole; + type: "access"; + /** true endast när inloggningen gick via TOTP-steget (admin-2FA). */ + mfa?: boolean; +} + +declare module "fastify" { + interface FastifyInstance { + authenticate: (req: FastifyRequest, reply: FastifyReply) => Promise; + requireAdmin: (req: FastifyRequest, reply: FastifyReply) => Promise; + } + interface FastifyRequest { + userId: string; + userRole: UserRole; + tokenMfa: boolean; + } +} + +export const authPlugin = fp(async (app: FastifyInstance) => { + await app.register(fastifyJwt, { + secret: app.config.JWT_ACCESS_SECRET, + sign: { expiresIn: app.config.JWT_ACCESS_TTL_SECONDS }, + }); + + app.decorateRequest("userId", ""); + app.decorateRequest("userRole", "user" as UserRole); + app.decorateRequest("tokenMfa", false); + + app.decorate("authenticate", async (req: FastifyRequest) => { + let payload: AccessTokenPayload; + try { + payload = await req.jwtVerify(); + } catch { + throw errors.unauthorized(); + } + if (payload.type !== "access" || !payload.sub) throw errors.unauthorized(); + req.userId = payload.sub; + req.userRole = payload.role; + req.tokenMfa = payload.mfa === true; + }); + + app.decorate("requireAdmin", async (req: FastifyRequest) => { + await app.authenticate(req, undefined as unknown as FastifyReply); + if (req.userRole !== "admin") { + // Dubbelkolla mot databasen – rollen i en gammal token räcker inte. + const [user] = await app.db + .select({ role: schema.users.role }) + .from(schema.users) + .where(eq(schema.users.id, req.userId)) + .limit(1); + if (user?.role !== "admin") throw errors.forbidden("Kräver admin."); + req.userRole = "admin"; + } + // 2FA-tvång: har kontot TOTP aktiverat måste token vara MFA-utfärdad + // (via /v1/auth/totp-verify). En lösenords-token räcker inte för admin-API. + if (!req.tokenMfa) { + const [totp] = await app.db + .select({ enabledAt: schema.adminTotp.enabledAt }) + .from(schema.adminTotp) + .where(eq(schema.adminTotp.userId, req.userId)) + .limit(1); + if (totp?.enabledAt) { + throw errors.forbidden("Tvåfaktorsautentisering krävs. Logga in igen med engångskod."); + } + } + }); +}); diff --git a/apps/api/src/plugins/core.ts b/apps/api/src/plugins/core.ts new file mode 100644 index 0000000..bde02b8 --- /dev/null +++ b/apps/api/src/plugins/core.ts @@ -0,0 +1,201 @@ +import fp from "fastify-plugin"; +import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import { randomUUID } from "node:crypto"; +import { Queue } from "bullmq"; +import IORedis from "ioredis"; +import { createDatabase, type Database } from "@app/database"; +import { schema } from "@app/database"; +import { createAamosClient, type AamosClient } from "@app/ai-contracts"; +import { FeatureFlagService } from "@app/feature-flags"; +import { StoreVerifier } from "@app/subscriptions"; +import { createMailer, type Mailer } from "../lib/mailer.js"; +import { createDefaultRegistry, type ConnectorRegistry } from "@app/connectors"; +import type { AppConfig } from "../config.js"; +import { ApiError } from "../lib/errors.js"; + +declare module "fastify" { + interface FastifyInstance { + config: AppConfig; + db: Database; + dbPool: { end(): Promise }; + jobQueue: Queue; + redis: IORedis; + aamos: AamosClient; + flags: FeatureFlagService; + storeVerifier: StoreVerifier; + mailer: Mailer; + connectors: ConnectorRegistry; + } + interface FastifyRequest { + correlationId: string; + } +} + +import { JOB_QUEUE_NAME } from "@app/shared-types"; +export { JOB_QUEUE_NAME }; + +/** + * Kärnplugin: db, redis/kö, AAMOS-klient, flaggor, correlation-id, + * säkerhetsheaders och enhetlig felhantering. + */ +export const corePlugin = fp(async (app: FastifyInstance, opts: { config: AppConfig }) => { + const { config } = opts; + app.decorate("config", config); + + // --- Databas --- + const { db, pool } = createDatabase(config.DATABASE_URL); + app.decorate("db", db); + app.decorate("dbPool", pool); + app.addHook("onClose", async () => { + await pool.end(); + }); + + // --- Redis + jobbkö --- + const redis = new IORedis(config.REDIS_URL, { maxRetriesPerRequest: null, lazyConnect: true }); + redis.on("error", (err) => app.log.warn({ err }, "Redis-fel")); + app.decorate("redis", redis); + const jobQueue = new Queue(JOB_QUEUE_NAME, { + connection: redis, + defaultJobOptions: { + attempts: 3, + backoff: { type: "exponential", delay: 2_000 }, + removeOnComplete: 1_000, + removeOnFail: 5_000, + }, + }); + app.decorate("jobQueue", jobQueue); + app.addHook("onClose", async () => { + await jobQueue.close(); + redis.disconnect(); + }); + + // --- AAMOS (befintlig plattform; mock endast i dev/test) --- + app.decorate( + "aamos", + createAamosClient({ + AAMOS_MODE: config.AAMOS_MODE, + AAMOS_API_URL: config.AAMOS_API_URL, + AAMOS_API_KEY: config.AAMOS_API_KEY, + AAMOS_TIMEOUT_MS: String(config.AAMOS_TIMEOUT_MS), + }), + ); + + // --- Feature flags --- + app.decorate( + "flags", + new FeatureFlagService({ + loadAll: async () => { + const rows = await db.select().from(schema.featureFlags); + return rows.map((r) => ({ + key: r.key, + enabled: r.enabled, + rolloutPercent: r.rolloutPercent, + })); + }, + }), + ); + + // --- Butiksverifiering --- + app.decorate( + "storeVerifier", + new StoreVerifier({ + mode: config.APP_STORE_MODE, + appleBundleId: config.APPLE_BUNDLE_ID, + googlePackageName: config.GOOGLE_PACKAGE_NAME, + }), + ); + + // --- E-post (log-läge i dev; leverantör kopplas i fas 7) --- + app.decorate( + "mailer", + createMailer(config.EMAIL_MODE, { + host: config.SMTP_HOST, + port: config.SMTP_PORT, + secure: config.SMTP_SECURE, + user: config.SMTP_USER, + pass: config.SMTP_PASS, + from: config.MAIL_FROM, + }), + ); + + // --- Connectors --- + app.decorate("connectors", createDefaultRegistry()); + + // --- Correlation ID (spec §58) --- + app.decorateRequest("correlationId", ""); + app.addHook("onRequest", async (req: FastifyRequest, reply: FastifyReply) => { + const incoming = req.headers["x-correlation-id"]; + req.correlationId = + typeof incoming === "string" && incoming.length <= 100 ? incoming : randomUUID(); + reply.header("x-correlation-id", req.correlationId); + }); + + // --- Säkerhetsheaders --- + app.addHook("onSend", async (_req, reply) => { + reply.header("x-content-type-options", "nosniff"); + reply.header("x-frame-options", "DENY"); + reply.header("referrer-policy", "no-referrer"); + reply.header("cache-control", "no-store"); + }); + + // --- Enhetlig felhantering --- + const alertWebhook = async (text: string): Promise => { + const url = process.env.ERROR_WEBHOOK_URL; + if (!url) return; + try { + await fetch(url, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ text }), + signal: AbortSignal.timeout(5000), + }); + } catch { + // Larmet får aldrig påverka svaret till klienten. + } + }; + + app.setErrorHandler((err, req, reply) => { + if (err instanceof ApiError) { + reply.status(err.statusCode).send({ + error: { + code: err.code, + message: err.message, + details: err.details, + correlationId: req.correlationId, + }, + }); + return; + } + // Fastify-inbyggda (t.ex. rate limit, felaktig JSON) + const known = err as { statusCode?: unknown; message?: unknown }; + const status = typeof known.statusCode === "number" ? known.statusCode : 500; + if (status >= 500) { + req.log.error({ err, correlationId: req.correlationId }, "Ohanterat fel"); + void alertWebhook( + `API 5xx: ${req.method} ${req.url} – ${String(known.message ?? "okänt fel")} (correlationId: ${req.correlationId})`, + ); + } + reply.status(status).send({ + error: { + code: status === 429 ? "RATE_LIMITED" : status >= 500 ? "INTERNAL" : "REQUEST_ERROR", + message: + status >= 500 + ? "Internt fel – försök igen." + : typeof known.message === "string" + ? known.message + : "Ogiltig förfrågan.", + correlationId: req.correlationId, + }, + }); + }); + + app.setNotFoundHandler((req, reply) => { + reply.status(404).send({ + error: { + code: "NOT_FOUND", + message: "Endpointen finns inte.", + correlationId: req.correlationId, + }, + }); + }); +}); diff --git a/apps/api/src/plugins/storage.ts b/apps/api/src/plugins/storage.ts new file mode 100644 index 0000000..67cd48a --- /dev/null +++ b/apps/api/src/plugins/storage.ts @@ -0,0 +1,246 @@ +import fp from "fastify-plugin"; +import type { FastifyInstance } from "fastify"; +import { createHmac, randomUUID } from "node:crypto"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; + +/** + * Lagringsabstraktion (spec §53). Två lägen: + * + * - mock (dev/test): "presignade" URL:er pekar på API:ts egna PUT/GET-endpoints + * och filer lagras under .data/s3/. Hela flödet (app → signed upload → + * worker läser) fungerar utan AWS. + * - aws (staging/produktion): RIKTIGA S3 presigned URLs via @aws-sdk/client-s3 + * + @aws-sdk/s3-request-presigner. Fungerar mot AWS S3 och alla + * S3-kompatibla lagringar (MinIO, Cloudflare R2, Hetzner …) via S3_ENDPOINT. + * Verifierad med riktig rundtur (presign → PUT → presign → GET) mot MinIO. + * + * Nyckelstruktur enligt spec §53: users/, fridge-scans/, pantry-scans/, + * meal-scans/, receipts/, product-images/, recipe-images/, temporary/. + */ +import { + GetObjectCommand, + HeadBucketCommand, + PutObjectCommand, + S3Client, +} from "@aws-sdk/client-s3"; +import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; + +export interface PresignedUpload { + key: string; + uploadUrl: string; + method: "PUT"; + headers: Record; + expiresAt: string; +} + +export interface StorageService { + presignUpload(prefix: string, contentType: string): Promise; + /** URL som workern/AAMOS kan läsa bilden från. */ + getReadUrl(key: string): Promise; + putObject(key: string, data: Buffer, contentType: string): Promise; + getObject(key: string): Promise; +} + +declare module "fastify" { + interface FastifyInstance { + storage: StorageService; + } +} + +const MOCK_ROOT = path.resolve(process.cwd(), ".data/s3"); + +class MockStorage implements StorageService { + constructor( + private readonly baseUrl: string, + private readonly signingSecret: string, + ) {} + + private sign(key: string): string { + return createHmac("sha256", this.signingSecret).update(key).digest("hex").slice(0, 32); + } + + async presignUpload(prefix: string, contentType: string): Promise { + const ext = contentType.split("/")[1] ?? "bin"; + const key = `${prefix}/${randomUUID()}.${ext}`; + const sig = this.sign(key); + return { + key, + uploadUrl: `${this.baseUrl}/v1/mock-s3/${encodeURIComponent(key)}?sig=${sig}`, + method: "PUT", + headers: { "content-type": contentType }, + expiresAt: new Date(Date.now() + 15 * 60_000).toISOString(), + }; + } + + async getReadUrl(key: string): Promise { + return `${this.baseUrl}/v1/mock-s3/${encodeURIComponent(key)}?sig=${this.sign(key)}`; + } + + async putObject(key: string, data: Buffer, _contentType?: string): Promise { + const filePath = path.join(MOCK_ROOT, key); + await mkdir(path.dirname(filePath), { recursive: true }); + await writeFile(filePath, data); + } + + async getObject(key: string): Promise { + try { + return await readFile(path.join(MOCK_ROOT, key)); + } catch { + return null; + } + } + + verifySignature(key: string, sig: string): boolean { + return this.sign(key) === sig; + } +} + +/** Riktig S3 (AWS eller S3-kompatibel via S3_ENDPOINT). */ +export class AwsStorage implements StorageService { + private readonly client: S3Client; + + constructor( + private readonly bucket: string, + region: string, + endpoint: string | undefined, + accessKeyId: string | undefined, + secretAccessKey: string | undefined, + ) { + this.client = new S3Client({ + region, + ...(endpoint ? { endpoint, forcePathStyle: true } : {}), + ...(accessKeyId && secretAccessKey ? { credentials: { accessKeyId, secretAccessKey } } : {}), // annars IAM-roll/instansprofil (rekommenderat i AWS) + }); + } + + /** Snabb verifiering vid uppstart – hellre högljutt fel än trasiga uppladdningar. */ + async healthCheck(): Promise { + await this.client.send(new HeadBucketCommand({ Bucket: this.bucket })); + } + + async presignUpload(prefix: string, contentType: string): Promise { + const ext = contentType.split("/")[1] ?? "bin"; + const key = `${prefix}/${randomUUID()}.${ext}`; + const uploadUrl = await getSignedUrl( + this.client, + new PutObjectCommand({ Bucket: this.bucket, Key: key, ContentType: contentType }), + { expiresIn: 15 * 60 }, + ); + return { + key, + uploadUrl, + method: "PUT", + headers: { "content-type": contentType }, + expiresAt: new Date(Date.now() + 15 * 60_000).toISOString(), + }; + } + + async getReadUrl(key: string): Promise { + return getSignedUrl(this.client, new GetObjectCommand({ Bucket: this.bucket, Key: key }), { + expiresIn: 60 * 60, + }); + } + + async putObject(key: string, data: Buffer, contentType: string): Promise { + await this.client.send( + new PutObjectCommand({ Bucket: this.bucket, Key: key, Body: data, ContentType: contentType }), + ); + } + + async getObject(key: string): Promise { + try { + const res = await this.client.send(new GetObjectCommand({ Bucket: this.bucket, Key: key })); + const bytes = await res.Body?.transformToByteArray(); + return bytes ? Buffer.from(bytes) : null; + } catch { + return null; + } + } +} + +export const storagePlugin = fp(async (app: FastifyInstance) => { + let storage: StorageService; + if (app.config.S3_MODE === "aws") { + const aws = new AwsStorage( + app.config.S3_BUCKET, + app.config.S3_REGION, + app.config.S3_ENDPOINT || undefined, + app.config.S3_ACCESS_KEY_ID || undefined, + app.config.S3_SECRET_ACCESS_KEY || undefined, + ); + // Verifiera bucket-åtkomst vid uppstart – produktion ska aldrig starta halvtrasig. + try { + await aws.healthCheck(); + app.log.info(`S3: riktig lagring aktiv (bucket: ${app.config.S3_BUCKET})`); + } catch (err) { + if (app.config.NODE_ENV === "production") { + throw new Error( + `S3-bucketen "${app.config.S3_BUCKET}" är inte nåbar: ${(err as Error).message}`, + ); + } + app.log.error( + `S3-bucketen "${app.config.S3_BUCKET}" svarar inte (${(err as Error).message}) – kontrollera S3_*-värdena.`, + ); + } + storage = aws; + app.decorate("storage", storage); + return; // riktiga presignade URL:er – inga lokala S3-endpoints behövs + } + + const mockStorage = new MockStorage( + app.config.API_BASE_URL, + app.config.ENTITLEMENT_SIGNING_SECRET, + ); + storage = mockStorage; + app.decorate("storage", storage); + + // Mock-S3-endpoints (endast i mock-läge) + app.addContentTypeParser( + ["image/jpeg", "image/png", "image/webp", "image/heic", "application/octet-stream"], + { parseAs: "buffer", bodyLimit: 15 * 1024 * 1024 }, + (_req, body, done) => done(null, body), + ); + + app.put<{ Params: { key: string }; Querystring: { sig?: string } }>( + "/v1/mock-s3/:key", + { config: { rateLimit: false } }, + async (req, reply) => { + const key = decodeURIComponent(req.params.key); + if (!mockStorage.verifySignature(key, req.query.sig ?? "")) { + return reply + .status(403) + .send({ error: { code: "BAD_SIGNATURE", message: "Ogiltig signatur" } }); + } + const body = req.body as Buffer | undefined; + if (!body || body.length === 0) { + return reply.status(400).send({ error: { code: "EMPTY_BODY", message: "Tom fil" } }); + } + await mockStorage.putObject( + key, + body, + req.headers["content-type"] ?? "application/octet-stream", + ); + return reply.status(200).send({ ok: true, key }); + }, + ); + + app.get<{ Params: { key: string }; Querystring: { sig?: string } }>( + "/v1/mock-s3/:key", + { config: { rateLimit: false } }, + async (req, reply) => { + const key = decodeURIComponent(req.params.key); + if (!mockStorage.verifySignature(key, req.query.sig ?? "")) { + return reply + .status(403) + .send({ error: { code: "BAD_SIGNATURE", message: "Ogiltig signatur" } }); + } + const data = await mockStorage.getObject(key); + if (!data) + return reply + .status(404) + .send({ error: { code: "NOT_FOUND", message: "Filen finns inte" } }); + return reply.header("content-type", "application/octet-stream").send(data); + }, + ); +}); diff --git a/apps/api/src/routes/admin.ts b/apps/api/src/routes/admin.ts new file mode 100644 index 0000000..c78db88 --- /dev/null +++ b/apps/api/src/routes/admin.ts @@ -0,0 +1,427 @@ +import type { FastifyInstance } from "fastify"; +import { and, desc, eq, ilike, sql } from "drizzle-orm"; +import { schema } from "@app/database"; +import { z } from "zod"; +import { adminGrantInputSchema, totpCodeInputSchema } from "@app/validation"; +import { errors, parse } from "../lib/errors.js"; +import { audit } from "../lib/helpers.js"; +import { generateTotpSecret, otpauthUrl, verifyTotp } from "../lib/totp.js"; +import { BRAND } from "@app/shared-types"; + +/** Adminpanelens API (spec §57). Alla anrop kräver admin-roll och auditloggas. */ +export async function adminRoutes(app: FastifyInstance) { + const admin = { preHandler: [app.requireAdmin] }; + + // --- Users --- + app.get("/admin/v1/users", admin, async (req) => { + const q = z + .object({ + search: z.string().max(100).optional(), + limit: z.coerce.number().int().min(1).max(100).default(50), + offset: z.coerce.number().int().min(0).default(0), + }) + .parse(req.query); + const conditions = q.search ? ilike(schema.users.email, `%${q.search}%`) : undefined; + const users = await app.db + .select({ + id: schema.users.id, + email: schema.users.email, + displayName: schema.users.displayName, + role: schema.users.role, + onboardingCompleted: schema.users.onboardingCompleted, + createdAt: schema.users.createdAt, + deletedAt: schema.users.deletedAt, + }) + .from(schema.users) + .where(conditions) + .orderBy(desc(schema.users.createdAt)) + .limit(q.limit) + .offset(q.offset); + return { users }; + }); + + // --- Receptmoderering (spec §35 steg 4) --- + app.get("/admin/v1/moderation/recipes", admin, async () => { + const pending = await app.db + .select() + .from(schema.recipes) + .where(sql`${schema.recipes.status} IN ('submitted', 'ai_checked', 'in_moderation')`) + .orderBy(schema.recipes.createdAt) + .limit(50); + return { recipes: pending }; + }); + + app.post("/admin/v1/moderation/recipes/:id", admin, async (req) => { + const params = z.object({ id: z.uuid() }).parse(req.params); + const body = z + .object({ + action: z.enum(["approve", "reject", "request_changes"]), + note: z.string().max(1000).optional(), + }) + .parse(req.body); + + const status = + body.action === "approve" ? "published" : body.action === "reject" ? "rejected" : "draft"; + const [recipe] = await app.db + .update(schema.recipes) + .set({ status, moderationNote: body.note ?? null, updatedAt: new Date() }) + .where(eq(schema.recipes.id, params.id)) + .returning(); + if (!recipe) throw errors.notFound(); + + if (body.action === "approve" && recipe.creatorUserId) { + await app.db + .insert(schema.creatorStats) + .values({ userId: recipe.creatorUserId, publishedRecipes: 1 }) + .onConflictDoUpdate({ + target: schema.creatorStats.userId, + set: { + publishedRecipes: sql`${schema.creatorStats.publishedRecipes} + 1`, + updatedAt: new Date(), + }, + }); + } + + await audit(app.db, { + actorUserId: req.userId, + actorType: "admin", + action: `moderation.recipe_${body.action}`, + targetType: "recipe", + targetId: params.id, + metadata: { note: body.note }, + }); + return recipe; + }); + + // --- Feature flags (spec §57) --- + app.get("/admin/v1/flags", admin, async () => { + return { + flags: await app.db.select().from(schema.featureFlags).orderBy(schema.featureFlags.key), + }; + }); + + app.put("/admin/v1/flags/:key", admin, async (req) => { + const params = z.object({ key: z.string().max(60) }).parse(req.params); + const body = z + .object({ + enabled: z.boolean(), + rolloutPercent: z.number().int().min(0).max(100).default(100), + descriptionSv: z.string().max(300).optional(), + }) + .parse(req.body); + const [flag] = await app.db + .insert(schema.featureFlags) + .values({ key: params.key, ...body }) + .onConflictDoUpdate({ + target: schema.featureFlags.key, + set: { ...body, updatedAt: new Date() }, + }) + .returning(); + app.flags.invalidate(); + await audit(app.db, { + actorUserId: req.userId, + actorType: "admin", + action: "flags.updated", + targetType: "feature_flag", + targetId: params.key, + metadata: body, + }); + return flag; + }); + + // --- Subscriptions --- + app.get("/admin/v1/subscriptions", admin, async () => { + const subs = await app.db + .select() + .from(schema.subscriptions) + .orderBy(desc(schema.subscriptions.updatedAt)) + .limit(100); + return { subscriptions: subs }; + }); + + app.post("/admin/v1/subscriptions/grant", admin, async (req) => { + const input = parse(adminGrantInputSchema, req.body); + if (input.plan === "free") + throw errors.badRequest("Använd delete i stället för att sätta free."); + const [sub] = await app.db + .insert(schema.subscriptions) + .values({ + userId: input.userId, + provider: "promo", + productId: `promo_${input.plan}`, + plan: input.plan, + status: "active", + purchasedAt: new Date(), + expiresAt: new Date(Date.now() + input.days * 86_400_000), + lastVerifiedAt: new Date(), + }) + .returning(); + await audit(app.db, { + actorUserId: req.userId, + actorType: "admin", + action: "subscription.granted", + targetType: "user", + targetId: input.userId, + metadata: { plan: input.plan, days: input.days, reason: input.reason }, + }); + return sub; + }); + + // --- Jobb & systemhälsa (spec §57–58) --- + app.get("/admin/v1/jobs/overview", admin, async () => { + const scanStats = await app.db + .select({ status: schema.scanJobs.status, count: sql`count(*)` }) + .from(schema.scanJobs) + .groupBy(schema.scanJobs.status); + const queueCounts = await app.jobQueue.getJobCounts(); + const unpublishedEvents = await app.db + .select({ count: sql`count(*)` }) + .from(schema.domainEvents) + .where(sql`${schema.domainEvents.publishedAt} IS NULL`); + return { + scanJobs: scanStats, + queue: queueCounts, + outboxPending: Number(unpublishedEvents[0]?.count ?? 0), + }; + }); + + app.get("/admin/v1/system/health", admin, async () => { + const checks: Record = {}; + try { + await app.db.execute(sql`SELECT 1`); + checks.database = { ok: true }; + } catch (err) { + checks.database = { ok: false, detail: String(err) }; + } + try { + const pong = await app.redis.ping(); + checks.redis = { ok: pong === "PONG" }; + } catch (err) { + checks.redis = { ok: false, detail: String(err) }; + } + checks.aamos = await app.aamos.healthCheck(); + checks.connectors = { ok: true, detail: `${app.connectors.list().length} registrerade` }; + return { checks, timestamp: new Date().toISOString() }; + }); + + // --- Audit logs --- + app.get("/admin/v1/audit-logs", admin, async (req) => { + const q = z + .object({ + action: z.string().max(60).optional(), + limit: z.coerce.number().int().min(1).max(200).default(100), + }) + .parse(req.query); + const conditions = q.action ? ilike(schema.auditLogs.action, `%${q.action}%`) : undefined; + const logs = await app.db + .select() + .from(schema.auditLogs) + .where(conditions) + .orderBy(desc(schema.auditLogs.createdAt)) + .limit(q.limit); + return { logs }; + }); + + // --- AI-korrigeringar & evals (spec §33–34) --- + app.get("/admin/v1/ai/corrections", admin, async () => { + const corrections = await app.db + .select() + .from(schema.aiCorrections) + .orderBy(desc(schema.aiCorrections.createdAt)) + .limit(100); + return { corrections }; + }); + + app.get("/admin/v1/ai/eval-runs", admin, async () => { + const runs = await app.db + .select() + .from(schema.aiEvalRuns) + .orderBy(desc(schema.aiEvalRuns.createdAt)) + .limit(50); + return { runs }; + }); + + // --- Admin-2FA (TOTP, RFC 6238) --- + + /** + * Starta 2FA-setup: nytt secret + otpauth-URL för autentiseringsappen. + * OBS: kräver befintlig admin-session; kan köras om tills enable bekräftats. + */ + app.post("/admin/v1/2fa/setup", admin, async (req) => { + const [existing] = await app.db + .select({ enabledAt: schema.adminTotp.enabledAt }) + .from(schema.adminTotp) + .where(eq(schema.adminTotp.userId, req.userId)) + .limit(1); + if (existing?.enabledAt) { + throw errors.badRequest("2FA är redan aktiverat. Inaktivera först för att byta secret."); + } + const secret = generateTotpSecret(); + const [user] = await app.db + .select({ email: schema.users.email }) + .from(schema.users) + .where(eq(schema.users.id, req.userId)) + .limit(1); + await app.db + .insert(schema.adminTotp) + .values({ userId: req.userId, secretBase32: secret }) + .onConflictDoUpdate({ + target: schema.adminTotp.userId, + set: { secretBase32: secret, enabledAt: null, lastUsedStep: null }, + }); + return { + secret, + otpauthUrl: otpauthUrl(secret, user?.email ?? "admin", `${BRAND.name} Admin`), + }; + }); + + /** Bekräfta setup med första koden – först nu börjar tvånget gälla. */ + app.post("/admin/v1/2fa/enable", admin, async (req) => { + const body = parse(totpCodeInputSchema, req.body); + const [row] = await app.db + .select() + .from(schema.adminTotp) + .where(eq(schema.adminTotp.userId, req.userId)) + .limit(1); + if (!row) throw errors.badRequest("Kör setup först."); + if (row.enabledAt) return { enabled: true }; + const step = verifyTotp(row.secretBase32, body.code); + if (step == null) + throw errors.unauthorized("Fel engångskod – kontrollera appen och försök igen."); + await app.db + .update(schema.adminTotp) + .set({ enabledAt: new Date(), lastUsedStep: step }) + .where(eq(schema.adminTotp.userId, req.userId)); + await audit(app.db, { + actorUserId: req.userId, + actorType: "admin", + action: "admin.2fa_enabled", + }); + return { enabled: true }; + }); + + /** Inaktivera 2FA (kräver giltig kod – aldrig bara en session). */ + app.post("/admin/v1/2fa/disable", admin, async (req) => { + const body = parse(totpCodeInputSchema, req.body); + const [row] = await app.db + .select() + .from(schema.adminTotp) + .where(eq(schema.adminTotp.userId, req.userId)) + .limit(1); + if (!row?.enabledAt) return { enabled: false }; + if (verifyTotp(row.secretBase32, body.code) == null) { + throw errors.unauthorized("Fel engångskod."); + } + await app.db.delete(schema.adminTotp).where(eq(schema.adminTotp.userId, req.userId)); + await audit(app.db, { + actorUserId: req.userId, + actorType: "admin", + action: "admin.2fa_disabled", + }); + return { enabled: false }; + }); + + /** Status för inloggad admin (visas i adminpanelen). */ + app.get("/admin/v1/2fa/status", admin, async (req) => { + const [row] = await app.db + .select({ enabledAt: schema.adminTotp.enabledAt }) + .from(schema.adminTotp) + .where(eq(schema.adminTotp.userId, req.userId)) + .limit(1); + return { enabled: Boolean(row?.enabledAt) }; + }); + + // --- Receptöversättningar (i18n-spec §13–14, M3) --- + + /** Beställ AI-utkast för ett recept och språk. Idempotent (upsert i worker). */ + app.post("/admin/v1/recipes/:id/translate", admin, async (req) => { + const params = z.object({ id: z.uuid() }).parse(req.params); + const body = z.object({ languageTag: z.string().min(2).max(35) }).parse(req.body); + const [recipe] = await app.db + .select({ id: schema.recipes.id }) + .from(schema.recipes) + .where(eq(schema.recipes.id, params.id)) + .limit(1); + if (!recipe) throw errors.notFound("Receptet finns inte."); + await app.jobQueue.add("TRANSLATE_RECIPE", { + jobType: "TRANSLATE_RECIPE", + recipeId: params.id, + targetLanguageTag: body.languageTag, + }); + await audit(app.db, { + actorUserId: req.userId, + actorType: "admin", + action: "recipe.translate.requested", + targetType: "recipe", + targetId: params.id, + metadata: { languageTag: body.languageTag }, + }); + return { queued: true, recipeId: params.id, languageTag: body.languageTag }; + }); + + /** Lista översättningar (med verifieringsresultat) för granskning. */ + app.get("/admin/v1/recipes/:id/translations", admin, async (req) => { + const params = z.object({ id: z.uuid() }).parse(req.params); + const translations = await app.db + .select() + .from(schema.recipeTranslations) + .where(eq(schema.recipeTranslations.recipeId, params.id)); + const steps = await app.db + .select() + .from(schema.recipeStepTranslations) + .where(eq(schema.recipeStepTranslations.recipeId, params.id)) + .orderBy(schema.recipeStepTranslations.stepNumber); + return { + translations: translations.map((t) => ({ + ...t, + steps: steps.filter((s) => s.languageTag === t.languageTag), + })), + }; + }); + + /** Publicera/underkänn en översättning efter mänsklig granskning. */ + app.post("/admin/v1/recipes/:id/translations/:languageTag", admin, async (req) => { + const params = z + .object({ id: z.uuid(), languageTag: z.string().min(2).max(35) }) + .parse(req.params); + const body = z + .object({ + action: z.enum(["publish", "back_to_draft", "in_review"]), + /** Redaktören kan rätta texten i samma steg. */ + title: z.string().min(1).optional(), + description: z.string().nullable().optional(), + }) + .parse(req.body); + const status = + body.action === "publish" + ? ("published" as const) + : body.action === "in_review" + ? ("in_review" as const) + : ("draft_ai" as const); + const [row] = await app.db + .update(schema.recipeTranslations) + .set({ + status, + ...(body.title ? { title: body.title, source: "human" as const } : {}), + ...(body.description !== undefined ? { description: body.description } : {}), + updatedAt: new Date(), + }) + .where( + and( + eq(schema.recipeTranslations.recipeId, params.id), + eq(schema.recipeTranslations.languageTag, params.languageTag), + ), + ) + .returning(); + if (!row) throw errors.notFound("Översättningen finns inte."); + await audit(app.db, { + actorUserId: req.userId, + actorType: "admin", + action: "recipe.translation.moderated", + targetType: "recipe", + targetId: params.id, + metadata: { languageTag: params.languageTag, action: body.action }, + }); + return row; + }); +} diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts new file mode 100644 index 0000000..2798d9c --- /dev/null +++ b/apps/api/src/routes/auth.ts @@ -0,0 +1,482 @@ +import type { FastifyInstance } from "fastify"; +import { randomBytes, randomUUID } from "node:crypto"; +import { and, eq, isNull } from "drizzle-orm"; +import { schema } from "@app/database"; +import { + loginInputSchema, + refreshInputSchema, + registerInputSchema, + changePasswordInputSchema, + forgotPasswordInputSchema, + resetPasswordInputSchema, + verifyEmailInputSchema, + totpVerifyInputSchema, +} from "@app/validation"; +import { trialEndsAt } from "@app/subscriptions"; +import { BRAND, DEFAULT_LOCALE_PREFERENCES, localeDefaultsForRegion } from "@app/shared-types"; +import { renderMail } from "../lib/mailer.js"; +import { loadLocalePreferences } from "../lib/localeContext.js"; +import { errors, parse } from "../lib/errors.js"; +import { hashPassword, verifyPassword } from "../lib/passwords.js"; +import { verifyTotp } from "../lib/totp.js"; +import { audit, sha256 } from "../lib/helpers.js"; + +/** + * Auth: registrering, inloggning, roterande refresh-tokens, utloggning. + * Refresh-tokens lagras hashade; återanvändning av roterad token + * ogiltigförklarar hela familjen (token theft detection). + */ +export async function authRoutes(app: FastifyInstance) { + const strictLimit = { config: { rateLimit: { max: 10, timeWindow: "1 minute" } } }; + + app.post("/v1/auth/register", strictLimit, async (req, reply) => { + const input = parse(registerInputSchema, req.body); + + const [existing] = await app.db + .select({ id: schema.users.id }) + .from(schema.users) + .where(eq(schema.users.email, input.email)) + .limit(1); + if (existing) throw errors.conflict("E-postadressen är redan registrerad."); + + const [user] = await app.db + .insert(schema.users) + .values({ email: input.email, displayName: input.displayName, locale: input.locale }) + .returning(); + if (!user) throw errors.internal(); + + await app.db + .insert(schema.userCredentials) + .values({ userId: user.id, passwordHash: await hashPassword(input.password) }); + await app.db.insert(schema.userPreferences).values({ userId: user.id }).onConflictDoNothing(); + await app.db + .insert(schema.userHealthProfiles) + .values({ userId: user.id }) + .onConflictDoNothing(); + + // 7 dagars trial utan kort startar direkt (spec §46) + const now = new Date(); + await app.db + .insert(schema.trials) + .values({ userId: user.id, startedAt: now, endsAt: trialEndsAt(now) }); + + await audit(app.db, { + actorUserId: user.id, + action: "auth.register", + ip: req.ip, + correlationId: req.correlationId, + }); + + // D-031: enhetens språk följer med registreringen → locale-preferenser skapas + // FÖRE välkomstmejlet, så att spanjorens första mejl kommer på spanska. + const regionFromLocale = input.locale.split("-")[1]?.toUpperCase(); + const localeDefaults = regionFromLocale + ? localeDefaultsForRegion(regionFromLocale) + : DEFAULT_LOCALE_PREFERENCES; + await app.db + .insert(schema.userLocalePreferences) + .values({ + userId: user.id, + languageTag: input.locale, + regionCode: localeDefaults.regionCode, + timeZone: localeDefaults.timeZone, + measurementSystem: localeDefaults.measurementSystem, + temperatureUnit: localeDefaults.temperatureUnit, + currencyCode: localeDefaults.currencyCode, + }) + .onConflictDoNothing(); + + // E-postverifiering (icke-blockerande): mejlet skickas, kontot fungerar direkt. + await sendVerificationMail(app, user.id, user.email); + + const tokens = await issueTokens(app, user.id, user.role, req.headers["user-agent"], req.ip); + return reply.status(201).send({ + user: { id: user.id, email: user.email, displayName: user.displayName, role: user.role }, + ...tokens, + }); + }); + + app.post("/v1/auth/login", strictLimit, async (req, reply) => { + const input = parse(loginInputSchema, req.body); + + const [user] = await app.db + .select() + .from(schema.users) + .where(and(eq(schema.users.email, input.email), isNull(schema.users.deletedAt))) + .limit(1); + const [creds] = user + ? await app.db + .select() + .from(schema.userCredentials) + .where(eq(schema.userCredentials.userId, user.id)) + .limit(1) + : []; + + const ok = user && creds ? await verifyPassword(input.password, creds.passwordHash) : false; + if (!ok || !user) { + // Konstant svar oavsett om kontot finns – ingen user enumeration. + throw errors.unauthorized("Fel e-post eller lösenord."); + } + + // Admin-2FA (step-up): rätt lösenord räcker inte om TOTP är aktiverat. + const [totp] = await app.db + .select() + .from(schema.adminTotp) + .where(eq(schema.adminTotp.userId, user.id)) + .limit(1); + if (totp?.enabledAt) { + const preAuthToken = app.jwt.sign( + { sub: user.id, type: "preauth" }, + { expiresIn: 300 }, // 5 minuter att ange koden + ); + await audit(app.db, { + actorUserId: user.id, + action: "auth.login_totp_required", + ip: req.ip, + correlationId: req.correlationId, + }); + return reply.send({ totpRequired: true, preAuthToken }); + } + + await audit(app.db, { + actorUserId: user.id, + action: "auth.login", + ip: req.ip, + correlationId: req.correlationId, + }); + const tokens = await issueTokens(app, user.id, user.role, req.headers["user-agent"], req.ip); + return reply.send({ + user: { id: user.id, email: user.email, displayName: user.displayName, role: user.role }, + ...tokens, + }); + }); + + /** Steg 2 av admin-inloggning: preauth-token + TOTP-kod → riktiga tokens. */ + app.post("/v1/auth/totp-verify", strictLimit, async (req, reply) => { + const input = parse(totpVerifyInputSchema, req.body); + let payload: { sub?: string; type?: string }; + try { + payload = app.jwt.verify(input.preAuthToken); + } catch { + throw errors.unauthorized("Ogiltig eller utgången inloggning. Börja om."); + } + if (payload.type !== "preauth" || !payload.sub) throw errors.unauthorized(); + + const [totp] = await app.db + .select() + .from(schema.adminTotp) + .where(eq(schema.adminTotp.userId, payload.sub)) + .limit(1); + if (!totp?.enabledAt) throw errors.unauthorized(); + + const step = verifyTotp(totp.secretBase32, input.code); + if (step == null || (totp.lastUsedStep != null && step <= totp.lastUsedStep)) { + await audit(app.db, { + actorUserId: payload.sub, + action: "auth.totp_failed", + ip: req.ip, + }); + throw errors.unauthorized("Fel engångskod."); + } + await app.db + .update(schema.adminTotp) + .set({ lastUsedStep: step }) + .where(eq(schema.adminTotp.userId, payload.sub)); + + const [user] = await app.db + .select() + .from(schema.users) + .where(and(eq(schema.users.id, payload.sub), isNull(schema.users.deletedAt))) + .limit(1); + if (!user) throw errors.unauthorized(); + + await audit(app.db, { + actorUserId: user.id, + action: "auth.login", + metadata: { mfa: true }, + ip: req.ip, + correlationId: req.correlationId, + }); + const tokens = await issueTokens(app, user.id, user.role, req.headers["user-agent"], req.ip, { + mfa: true, + }); + return reply.send({ + user: { id: user.id, email: user.email, displayName: user.displayName, role: user.role }, + ...tokens, + }); + }); + + /** Bekräfta e-postadress (länken i välkomstmejlet). Engångs, 24 h TTL. */ + app.post("/v1/auth/verify-email", strictLimit, async (req, reply) => { + const input = parse(verifyEmailInputSchema, req.body); + const [stored] = await app.db + .select() + .from(schema.emailVerificationTokens) + .where(eq(schema.emailVerificationTokens.tokenHash, sha256(input.token))) + .limit(1); + if (!stored || stored.usedAt || stored.expiresAt < new Date()) { + throw errors.unauthorized("Ogiltig eller utgången verifieringslänk. Begär en ny."); + } + await app.db + .update(schema.emailVerificationTokens) + .set({ usedAt: new Date() }) + .where(eq(schema.emailVerificationTokens.id, stored.id)); + await app.db + .update(schema.users) + .set({ emailVerifiedAt: new Date(), updatedAt: new Date() }) + .where(eq(schema.users.id, stored.userId)); + await audit(app.db, { actorUserId: stored.userId, action: "auth.email_verified", ip: req.ip }); + return reply.send({ ok: true }); + }); + + /** Skicka nytt verifieringsmejl (inloggad, ej redan verifierad). */ + app.post( + "/v1/auth/resend-verification", + { preHandler: [app.authenticate], ...strictLimit }, + async (req, reply) => { + const [user] = await app.db + .select({ email: schema.users.email, verifiedAt: schema.users.emailVerifiedAt }) + .from(schema.users) + .where(eq(schema.users.id, req.userId)) + .limit(1); + if (!user) throw errors.unauthorized(); + if (!user.verifiedAt) await sendVerificationMail(app, req.userId, user.email); + return reply.send({ ok: true }); + }, + ); + + app.post("/v1/auth/refresh", strictLimit, async (req, reply) => { + const input = parse(refreshInputSchema, req.body); + const tokenHash = sha256(input.refreshToken); + + const [stored] = await app.db + .select() + .from(schema.refreshTokens) + .where(eq(schema.refreshTokens.tokenHash, tokenHash)) + .limit(1); + + if (!stored) throw errors.unauthorized("Ogiltig refresh-token."); + + if (stored.revokedAt) { + // Token-återanvändning → hela familjen ogiltigförklaras. + await app.db + .update(schema.refreshTokens) + .set({ revokedAt: new Date() }) + .where(eq(schema.refreshTokens.familyId, stored.familyId)); + await audit(app.db, { + actorUserId: stored.userId, + action: "auth.refresh_reuse_detected", + metadata: { familyId: stored.familyId }, + ip: req.ip, + }); + throw errors.unauthorized("Sessionen har återkallats. Logga in igen."); + } + if (stored.expiresAt < new Date()) throw errors.unauthorized("Sessionen har gått ut."); + + const [user] = await app.db + .select() + .from(schema.users) + .where(and(eq(schema.users.id, stored.userId), isNull(schema.users.deletedAt))) + .limit(1); + if (!user) throw errors.unauthorized(); + + // Rotera: revokera gamla, utfärda ny i samma familj. + const next = await issueTokens( + app, + user.id, + user.role, + req.headers["user-agent"], + req.ip, + stored.familyId, + ); + await app.db + .update(schema.refreshTokens) + .set({ revokedAt: new Date(), replacedByTokenId: next.refreshTokenId }) + .where(eq(schema.refreshTokens.id, stored.id)); + + return reply.send({ + accessToken: next.accessToken, + refreshToken: next.refreshToken, + accessTokenExpiresIn: next.accessTokenExpiresIn, + }); + }); + + app.post("/v1/auth/logout", { preHandler: [app.authenticate] }, async (req, reply) => { + await app.db + .update(schema.refreshTokens) + .set({ revokedAt: new Date() }) + .where( + and(eq(schema.refreshTokens.userId, req.userId), isNull(schema.refreshTokens.revokedAt)), + ); + await audit(app.db, { actorUserId: req.userId, action: "auth.logout", ip: req.ip }); + return reply.send({ ok: true }); + }); + + /** + * Lösenordsåterställning steg 1 (spec §56: säker kontohantering). + * Svarar ALLTID { ok: true } – avslöjar aldrig om kontot finns + * (anti-enumeration). Token: 32 slumpbytes, lagras sha256-hashad, + * 30 min TTL, engångsbruk; tidigare oanvända tokens ogiltigförklaras. + */ + app.post("/v1/auth/forgot-password", strictLimit, async (req, reply) => { + const input = parse(forgotPasswordInputSchema, req.body); + const [user] = await app.db + .select({ id: schema.users.id, email: schema.users.email }) + .from(schema.users) + .where(eq(schema.users.email, input.email)) + .limit(1); + + if (user) { + // Ogiltigförklara tidigare oanvända tokens. + await app.db + .update(schema.passwordResetTokens) + .set({ usedAt: new Date() }) + .where( + and( + eq(schema.passwordResetTokens.userId, user.id), + isNull(schema.passwordResetTokens.usedAt), + ), + ); + const token = randomBytes(32).toString("hex"); + await app.db.insert(schema.passwordResetTokens).values({ + userId: user.id, + tokenHash: sha256(token), + expiresAt: new Date(Date.now() + 30 * 60_000), + }); + const languageTag = (await loadLocalePreferences(app.db, user.id)).languageTag; + const mail = renderMail("auth.password_reset", languageTag, { + email: user.email, + link: `${BRAND.urlScheme}://reset-password?token=${token}`, + }); + await app.mailer.send({ to: user.email, ...mail }); + await audit(app.db, { + actorUserId: user.id, + action: "auth.password_reset_requested", + ip: req.ip, + }); + } + // Samma svar oavsett – och ingen tidsskillnad stor nog att mäta via rate limit. + return reply.send({ ok: true }); + }); + + /** Lösenordsåterställning steg 2: token + nytt lösenord. Engångsbruk. */ + app.post("/v1/auth/reset-password", strictLimit, async (req, reply) => { + const input = parse(resetPasswordInputSchema, req.body); + const [stored] = await app.db + .select() + .from(schema.passwordResetTokens) + .where(eq(schema.passwordResetTokens.tokenHash, sha256(input.token))) + .limit(1); + if (!stored || stored.usedAt || stored.expiresAt < new Date()) { + throw errors.unauthorized("Ogiltig eller utgången återställningslänk. Begär en ny."); + } + await app.db + .update(schema.passwordResetTokens) + .set({ usedAt: new Date() }) + .where(eq(schema.passwordResetTokens.id, stored.id)); + await app.db + .update(schema.userCredentials) + .set({ passwordHash: await hashPassword(input.newPassword), passwordUpdatedAt: new Date() }) + .where(eq(schema.userCredentials.userId, stored.userId)); + // Logga ut ALLA sessioner – ett återställt konto börjar om från noll. + await app.db + .update(schema.refreshTokens) + .set({ revokedAt: new Date() }) + .where( + and(eq(schema.refreshTokens.userId, stored.userId), isNull(schema.refreshTokens.revokedAt)), + ); + await audit(app.db, { + actorUserId: stored.userId, + action: "auth.password_reset_completed", + ip: req.ip, + }); + return reply.send({ ok: true }); + }); + + app.post( + "/v1/auth/change-password", + { preHandler: [app.authenticate], ...strictLimit }, + async (req, reply) => { + const input = parse(changePasswordInputSchema, req.body); + const [creds] = await app.db + .select() + .from(schema.userCredentials) + .where(eq(schema.userCredentials.userId, req.userId)) + .limit(1); + if (!creds || !(await verifyPassword(input.currentPassword, creds.passwordHash))) { + throw errors.unauthorized("Fel nuvarande lösenord."); + } + await app.db + .update(schema.userCredentials) + .set({ passwordHash: await hashPassword(input.newPassword), passwordUpdatedAt: new Date() }) + .where(eq(schema.userCredentials.userId, req.userId)); + // Logga ut alla andra sessioner. + await app.db + .update(schema.refreshTokens) + .set({ revokedAt: new Date() }) + .where( + and(eq(schema.refreshTokens.userId, req.userId), isNull(schema.refreshTokens.revokedAt)), + ); + await audit(app.db, { actorUserId: req.userId, action: "auth.change_password", ip: req.ip }); + return reply.send({ ok: true }); + }, + ); +} + +async function issueTokens( + app: FastifyInstance, + userId: string, + role: string, + userAgent: string | undefined, + ip: string | undefined, + familyIdOrOpts?: string | { mfa?: boolean }, + opts?: { mfa?: boolean }, +) { + const familyId = typeof familyIdOrOpts === "string" ? familyIdOrOpts : undefined; + const mfa = (typeof familyIdOrOpts === "object" ? familyIdOrOpts.mfa : opts?.mfa) ?? false; + const accessToken = app.jwt.sign({ sub: userId, role, type: "access", ...(mfa ? { mfa } : {}) }); + const refreshToken = randomUUID() + "." + randomUUID(); + const family = familyId ?? randomUUID(); + const [row] = await app.db + .insert(schema.refreshTokens) + .values({ + userId, + tokenHash: sha256(refreshToken), + familyId: family, + expiresAt: new Date(Date.now() + app.config.JWT_REFRESH_TTL_SECONDS * 1000), + userAgent: userAgent?.slice(0, 300) ?? null, + ip: ip ?? null, + }) + .returning({ id: schema.refreshTokens.id }); + return { + accessToken, + refreshToken, + refreshTokenId: row!.id, + accessTokenExpiresIn: app.config.JWT_ACCESS_TTL_SECONDS, + }; +} + +/** Skapa verifieringstoken + skicka mejl på användarens språk (24 h TTL). */ +async function sendVerificationMail(app: FastifyInstance, userId: string, email: string) { + await app.db + .update(schema.emailVerificationTokens) + .set({ usedAt: new Date() }) + .where( + and( + eq(schema.emailVerificationTokens.userId, userId), + isNull(schema.emailVerificationTokens.usedAt), + ), + ); + const token = randomBytes(32).toString("hex"); + await app.db.insert(schema.emailVerificationTokens).values({ + userId, + tokenHash: sha256(token), + expiresAt: new Date(Date.now() + 24 * 3600_000), + }); + const languageTag = (await loadLocalePreferences(app.db, userId)).languageTag; + const mail = renderMail("auth.verify_email", languageTag, { + email, + link: `${BRAND.urlScheme}://verify-email?token=${token}`, + }); + await app.mailer.send({ to: email, ...mail }); +} diff --git a/apps/api/src/routes/budget.ts b/apps/api/src/routes/budget.ts new file mode 100644 index 0000000..6c441b2 --- /dev/null +++ b/apps/api/src/routes/budget.ts @@ -0,0 +1,130 @@ +import type { FastifyInstance } from "fastify"; +import { and, eq, gte, inArray, sql } from "drizzle-orm"; +import { schema } from "@app/database"; +import { requireActiveHousehold } from "../lib/helpers.js"; + +/** Budget & matsvinn (spec §26): vecka/månad, kostnad per måltid, svinnvärde. */ +export async function budgetRoutes(app: FastifyInstance) { + const auth = { preHandler: [app.authenticate] }; + + app.get("/v1/budget/summary", auth, async (req) => { + const householdId = await requireActiveHousehold(app.db, req.userId); + const now = new Date(); + // UTC-kalender (samma policy som motorerna): identiska summor oavsett serverns tidszon. + const mondayOffset = (now.getUTCDay() + 6) % 7; // 0 = måndag + const weekStart = new Date( + Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() - mondayOffset), + ); + const monthStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)); + + // Inköpskostnad = summa purchase-transaktioner med värde + const [weekPurchases] = await app.db + .select({ total: sql`coalesce(sum(${schema.inventoryTransactions.valueMinor}), 0)` }) + .from(schema.inventoryTransactions) + .where( + and( + eq(schema.inventoryTransactions.householdId, householdId), + eq(schema.inventoryTransactions.type, "purchase"), + gte(schema.inventoryTransactions.createdAt, weekStart), + ), + ); + const [monthPurchases] = await app.db + .select({ total: sql`coalesce(sum(${schema.inventoryTransactions.valueMinor}), 0)` }) + .from(schema.inventoryTransactions) + .where( + and( + eq(schema.inventoryTransactions.householdId, householdId), + eq(schema.inventoryTransactions.type, "purchase"), + gte(schema.inventoryTransactions.createdAt, monthStart), + ), + ); + + // Matsvinnsvärde = summa discard-transaktioner (spec §12, §26) + const [weekWaste] = await app.db + .select({ + total: sql`coalesce(sum(${schema.inventoryTransactions.valueMinor}), 0)`, + count: sql`count(*)`, + }) + .from(schema.inventoryTransactions) + .where( + and( + eq(schema.inventoryTransactions.householdId, householdId), + eq(schema.inventoryTransactions.type, "discard"), + gte(schema.inventoryTransactions.createdAt, weekStart), + ), + ); + const [monthWaste] = await app.db + .select({ + total: sql`coalesce(sum(${schema.inventoryTransactions.valueMinor}), 0)`, + count: sql`count(*)`, + }) + .from(schema.inventoryTransactions) + .where( + and( + eq(schema.inventoryTransactions.householdId, householdId), + eq(schema.inventoryTransactions.type, "discard"), + gte(schema.inventoryTransactions.createdAt, monthStart), + ), + ); + + // Kostnad per hemlagad måltid: lagade recept denna månad med kostnadsdata + const cooks = await app.db + .select({ + recipeId: schema.recipeCooks.recipeId, + portions: schema.recipeCooks.portionsCooked, + }) + .from(schema.recipeCooks) + .where( + and( + eq(schema.recipeCooks.householdId, householdId), + gte(schema.recipeCooks.cookedAt, monthStart), + ), + ); + let mealCostTotal = 0; + let mealPortions = 0; + if (cooks.length > 0) { + const recipes = await app.db + .select({ id: schema.recipes.id, cost: schema.recipes.estimatedCostMinorPerPortion }) + .from(schema.recipes) + .where(inArray(schema.recipes.id, [...new Set(cooks.map((c) => c.recipeId))])); + const costMap = new Map(recipes.map((r) => [r.id, r.cost])); + for (const cook of cooks) { + const cost = costMap.get(cook.recipeId); + if (cost != null) { + mealCostTotal += cost * cook.portions; + mealPortions += cook.portions; + } + } + } + + const [household] = await app.db + .select({ + budget: schema.households.weeklyBudgetMinor, + currencyCode: schema.households.currencyCode, + }) + .from(schema.households) + .where(eq(schema.households.id, householdId)) + .limit(1); + + // Alla belopp i minor units (heltal) i hushållets valuta (i18n-spec §20). + const currency = household?.currencyCode ?? "SEK"; + return { + currency, + week: { + purchasedMinor: Math.round(Number(weekPurchases?.total ?? 0)), + wasteMinor: Math.round(Number(weekWaste?.total ?? 0)), + wasteCount: Number(weekWaste?.count ?? 0), + budgetMinor: household?.budget ?? null, + }, + month: { + purchasedMinor: Math.round(Number(monthPurchases?.total ?? 0)), + wasteMinor: Math.round(Number(monthWaste?.total ?? 0)), + wasteCount: Number(monthWaste?.count ?? 0), + estimatedCostPerPortionMinor: + mealPortions > 0 ? Math.round(mealCostTotal / mealPortions) : null, + cookedPortions: mealPortions, + }, + note: "Kostnader bygger på kvitton, angivna priser och schablonpriser – uppskattningar, inte bokföring.", + }; + }); +} diff --git a/apps/api/src/routes/community.ts b/apps/api/src/routes/community.ts new file mode 100644 index 0000000..f7eb8de --- /dev/null +++ b/apps/api/src/routes/community.ts @@ -0,0 +1,165 @@ +import type { FastifyInstance } from "fastify"; +import { and, desc, eq, sql } from "drizzle-orm"; +import { schema } from "@app/database"; +import { idParamSchema } from "@app/validation"; +import { errors, parse } from "../lib/errors.js"; + +/** + * Community & creators (spec §35–38). + * Rankinglistor bakom feature flag; ALDRIG ranking på vikt/kalorier (spec §38). + */ +export async function communityRoutes(app: FastifyInstance) { + const auth = { preHandler: [app.authenticate] }; + + app.get("/v1/creators/:id", auth, async (req) => { + const { id } = parse(idParamSchema, req.params); + const [stats] = await app.db + .select() + .from(schema.creatorStats) + .where(eq(schema.creatorStats.userId, id)) + .limit(1); + const [user] = await app.db + .select({ displayName: schema.users.displayName }) + .from(schema.users) + .where(eq(schema.users.id, id)) + .limit(1); + if (!user) throw errors.notFound("Profilen finns inte."); + if (stats?.visibility === "private" && id !== req.userId) { + throw errors.forbidden("Profilen är privat."); + } + + const recipes = await app.db + .select({ + id: schema.recipes.id, + titleSv: schema.recipes.titleSv, + ratingAverage: schema.recipes.ratingAverage, + cookCount: schema.recipes.cookCount, + verificationStatus: schema.recipes.verificationStatus, + imageUrls: schema.recipes.imageUrls, + }) + .from(schema.recipes) + .where(and(eq(schema.recipes.creatorUserId, id), eq(schema.recipes.status, "published"))) + .orderBy(desc(schema.recipes.cookCount)) + .limit(30); + + return { + userId: id, + displayName: user.displayName, + level: stats?.level ?? "beginner", + followers: stats?.followers ?? 0, + publishedRecipes: stats?.publishedRecipes ?? recipes.length, + totalCooks: stats?.totalCooks ?? 0, + averageRating: stats?.averageRating ?? null, + badges: stats?.badges ?? [], + recipes, + }; + }); + + app.post("/v1/creators/:id/follow", auth, async (req) => { + const { id } = parse(idParamSchema, req.params); + if (id === req.userId) throw errors.badRequest("Du kan inte följa dig själv."); + await app.db + .insert(schema.creatorFollows) + .values({ followerUserId: req.userId, creatorUserId: id }) + .onConflictDoNothing(); + await app.db + .insert(schema.creatorStats) + .values({ userId: id, followers: 1 }) + .onConflictDoUpdate({ + target: schema.creatorStats.userId, + set: { followers: sql`${schema.creatorStats.followers} + 1`, updatedAt: new Date() }, + }); + return { ok: true }; + }); + + app.delete("/v1/creators/:id/follow", auth, async (req) => { + const { id } = parse(idParamSchema, req.params); + await app.db + .delete(schema.creatorFollows) + .where( + and( + eq(schema.creatorFollows.followerUserId, req.userId), + eq(schema.creatorFollows.creatorUserId, id), + ), + ); + await app.db + .update(schema.creatorStats) + .set({ followers: sql`GREATEST(${schema.creatorStats.followers} - 1, 0)` }) + .where(eq(schema.creatorStats.userId, id)); + return { ok: true }; + }); + + /** Topplistor (spec §38) – kräver flagga + minst 5 betyg för att synas. */ + app.get("/v1/rankings/:kind", auth, async (req) => { + if (!(await app.flags.isEnabled("creator_rankings", req.userId))) { + return { enabled: false, entries: [] }; + } + const kind = (req.params as { kind: string }).kind; + const MIN_RATINGS = 5; + + if (kind === "most-cooked") { + const rows = await app.db + .select({ + id: schema.recipes.id, + titleSv: schema.recipes.titleSv, + value: schema.recipes.cookCount, + }) + .from(schema.recipes) + .where(eq(schema.recipes.status, "published")) + .orderBy(desc(schema.recipes.cookCount)) + .limit(20); + return { enabled: true, kind, entries: rows }; + } + if (kind === "top-rated") { + const rows = await app.db + .select({ + id: schema.recipes.id, + titleSv: schema.recipes.titleSv, + value: schema.recipes.ratingAverage, + }) + .from(schema.recipes) + .where( + and( + eq(schema.recipes.status, "published"), + sql`${schema.recipes.ratingCount} >= ${MIN_RATINGS}`, + ), + ) + .orderBy(desc(schema.recipes.ratingAverage)) + .limit(20); + return { enabled: true, kind, entries: rows }; + } + if (kind === "budget") { + const rows = await app.db + .select({ + id: schema.recipes.id, + titleSv: schema.recipes.titleSv, + value: schema.recipes.estimatedCostMinorPerPortion, + }) + .from(schema.recipes) + .where( + and( + eq(schema.recipes.status, "published"), + sql`${schema.recipes.estimatedCostMinorPerPortion} IS NOT NULL`, + ), + ) + .orderBy(schema.recipes.estimatedCostMinorPerPortion) + .limit(20); + return { enabled: true, kind, entries: rows }; + } + if (kind === "protein") { + const rows = await app.db + .select({ + id: schema.recipes.id, + titleSv: schema.recipes.titleSv, + value: sql`(${schema.recipes.nutritionPerPortion}->>'proteinG')::float`, + }) + .from(schema.recipes) + .where(eq(schema.recipes.status, "published")) + .orderBy(desc(sql`(${schema.recipes.nutritionPerPortion}->>'proteinG')::float`)) + .limit(20); + return { enabled: true, kind, entries: rows }; + } + // Spec §38: ingen ranking på vikt, viktnedgång, kalorier eller BMI. + throw errors.badRequest("Okänd rankingtyp."); + }); +} diff --git a/apps/api/src/routes/health.ts b/apps/api/src/routes/health.ts new file mode 100644 index 0000000..29a7388 --- /dev/null +++ b/apps/api/src/routes/health.ts @@ -0,0 +1,40 @@ +import type { FastifyInstance } from "fastify"; +import { sql } from "drizzle-orm"; +import { BRAND } from "@app/shared-types"; + +/** Liveness/readiness för lastbalanserare och Docker healthchecks (spec §58). */ +export async function healthRoutes(app: FastifyInstance) { + app.get("/healthz", { config: { rateLimit: false } }, async () => ({ + ok: true, + service: `${BRAND.slug}-api`, + timestamp: new Date().toISOString(), + })); + + app.get("/readyz", { config: { rateLimit: false } }, async (_req, reply) => { + try { + await app.db.execute(sql`SELECT 1`); + return { ok: true }; + } catch { + return reply.status(503).send({ ok: false, reason: "database" }); + } + }); + + /** Enkel endpointöversikt i stället för tung OpenAPI-generering (se beslutslogg D-011). */ + app.get("/docs", { config: { rateLimit: false } }, async (_req, reply) => { + const routes = app.routeCatalog + .filter((r) => !r.url.startsWith("/v1/mock-s3")) + .sort((a, b) => a.url.localeCompare(b.url) || a.method.localeCompare(b.method)); + const rows = routes + .map((r) => `${r.method}${r.url}`) + .join("\n"); + reply.header("content-type", "text/html; charset=utf-8"); + return `API + +

${BRAND.name} API v1

+

${routes.length} endpoints. Auth: Authorization: Bearer <accessToken>. +Fullständig referens: docs/api-referens.md i repot.

+${rows}
`; + }); +} diff --git a/apps/api/src/routes/households.ts b/apps/api/src/routes/households.ts new file mode 100644 index 0000000..4188e34 --- /dev/null +++ b/apps/api/src/routes/households.ts @@ -0,0 +1,230 @@ +import type { FastifyInstance } from "fastify"; +import { and, eq } from "drizzle-orm"; +import { schema } from "@app/database"; +import { + createHouseholdInputSchema, + createStorageLocationInputSchema, + idParamSchema, + joinHouseholdInputSchema, + memberParamSchema, + updateHouseholdInputSchema, + updateMemberInputSchema, + updateStorageLocationInputSchema, +} from "@app/validation"; +import { loadEntitlements } from "../lib/entitlements.js"; +import { errors, parse } from "../lib/errors.js"; +import { audit, emitEvent, generateInviteCode, requireMembership } from "../lib/helpers.js"; + +/** Hushåll (spec §7): delat lager/plan/lista, individuella mål och roller. */ +export async function householdRoutes(app: FastifyInstance) { + const auth = { preHandler: [app.authenticate] }; + + app.get("/v1/households", auth, async (req) => { + const rows = await app.db + .select({ + household: schema.households, + role: schema.householdMembers.role, + portionFactor: schema.householdMembers.portionFactor, + }) + .from(schema.householdMembers) + .innerJoin(schema.households, eq(schema.householdMembers.householdId, schema.households.id)) + .where(eq(schema.householdMembers.userId, req.userId)); + return rows.map((r) => ({ ...r.household, myRole: r.role, myPortionFactor: r.portionFactor })); + }); + + app.post("/v1/households", auth, async (req, reply) => { + const input = parse(createHouseholdInputSchema, req.body); + const [household] = await app.db + .insert(schema.households) + .values({ + name: input.name, + inviteCode: generateInviteCode(), + weeklyBudgetMinor: input.weeklyBudgetMinor ?? null, + ...(input.currencyCode ? { currencyCode: input.currencyCode } : {}), + }) + .returning(); + await app.db + .insert(schema.householdMembers) + .values({ householdId: household!.id, userId: req.userId, role: "owner" }); + await app.db.insert(schema.storageLocations).values([ + { householdId: household!.id, type: "fridge", name: "Kylen", sortOrder: 0 }, + { householdId: household!.id, type: "freezer", name: "Frysen", sortOrder: 1 }, + { householdId: household!.id, type: "pantry", name: "Skafferiet", sortOrder: 2 }, + ]); + return reply.status(201).send(household); + }); + + app.get("/v1/households/:id", auth, async (req) => { + const { id } = parse(idParamSchema, req.params); + await requireMembership(app.db, id, req.userId); + const [household] = await app.db + .select() + .from(schema.households) + .where(eq(schema.households.id, id)) + .limit(1); + if (!household) throw errors.notFound(); + + const members = await app.db + .select({ + userId: schema.householdMembers.userId, + role: schema.householdMembers.role, + portionFactor: schema.householdMembers.portionFactor, + joinedAt: schema.householdMembers.joinedAt, + displayName: schema.users.displayName, + }) + .from(schema.householdMembers) + .innerJoin(schema.users, eq(schema.householdMembers.userId, schema.users.id)) + .where(eq(schema.householdMembers.householdId, id)); + + const locations = await app.db + .select() + .from(schema.storageLocations) + .where(eq(schema.storageLocations.householdId, id)) + .orderBy(schema.storageLocations.sortOrder); + + // OBS: individuella hälsomål/allergier exponeras INTE här (spec §7, §56). + return { ...household, members, storageLocations: locations }; + }); + + app.patch("/v1/households/:id", auth, async (req) => { + const { id } = parse(idParamSchema, req.params); + const membership = await requireMembership(app.db, id, req.userId); + if (membership.role !== "owner" && membership.role !== "adult") { + throw errors.forbidden("Endast vuxna medlemmar kan ändra hushållet."); + } + const input = parse(updateHouseholdInputSchema, req.body); + const [row] = await app.db + .update(schema.households) + .set({ ...input, updatedAt: new Date() }) + .where(eq(schema.households.id, id)) + .returning(); + return row; + }); + + app.post("/v1/households/join", auth, async (req) => { + const input = parse(joinHouseholdInputSchema, req.body); + const [household] = await app.db + .select() + .from(schema.households) + .where(eq(schema.households.inviteCode, input.inviteCode.toUpperCase())) + .limit(1); + if (!household) throw errors.notFound("Ingen hushållsinbjudan matchar koden."); + + // Kontrollera plangräns: max medlemmar styrs av ägarens plan (spec §45). + const [owner] = await app.db + .select({ userId: schema.householdMembers.userId }) + .from(schema.householdMembers) + .where( + and( + eq(schema.householdMembers.householdId, household.id), + eq(schema.householdMembers.role, "owner"), + ), + ) + .limit(1); + const members = await app.db + .select({ userId: schema.householdMembers.userId }) + .from(schema.householdMembers) + .where(eq(schema.householdMembers.householdId, household.id)); + if (owner) { + const ent = await loadEntitlements(app.db, owner.userId); + if (members.length >= ent.maxHouseholdMembers) { + throw errors.paymentRequired( + `Hushållet har nått maxantalet medlemmar (${ent.maxHouseholdMembers}) för sin plan.`, + ); + } + } + + await app.db + .insert(schema.householdMembers) + .values({ householdId: household.id, userId: req.userId, role: "adult" }) + .onConflictDoNothing(); + + await emitEvent(app.db, { + type: "HOUSEHOLD_MEMBER_ADDED", + payload: { householdId: household.id, newUserId: req.userId, role: "adult" }, + userId: req.userId, + householdId: household.id, + correlationId: req.correlationId, + }); + return { ok: true, household: { id: household.id, name: household.name } }; + }); + + app.patch("/v1/households/:id/members/:userId", auth, async (req) => { + const { id, userId } = parse(memberParamSchema, req.params); + const membership = await requireMembership(app.db, id, req.userId); + const isSelf = userId === req.userId; + if (!isSelf && membership.role !== "owner" && membership.role !== "adult") { + throw errors.forbidden("Endast vuxna kan ändra andra medlemmar."); + } + const input = parse(updateMemberInputSchema, req.body); + if (input.role && membership.role !== "owner") { + throw errors.forbidden("Endast ägaren kan ändra roller."); + } + const [row] = await app.db + .update(schema.householdMembers) + .set(input) + .where( + and( + eq(schema.householdMembers.householdId, id), + eq(schema.householdMembers.userId, userId), + ), + ) + .returning(); + if (!row) throw errors.notFound("Medlemmen finns inte."); + return row; + }); + + app.delete("/v1/households/:id/members/:userId", auth, async (req) => { + const { id, userId } = parse(memberParamSchema, req.params); + const membership = await requireMembership(app.db, id, req.userId); + const isSelf = userId === req.userId; + if (!isSelf && membership.role !== "owner") { + throw errors.forbidden("Endast ägaren kan ta bort andra medlemmar."); + } + await app.db + .delete(schema.householdMembers) + .where( + and( + eq(schema.householdMembers.householdId, id), + eq(schema.householdMembers.userId, userId), + ), + ); + await audit(app.db, { + actorUserId: req.userId, + action: "household.member_removed", + targetType: "user", + targetId: userId, + }); + return { ok: true }; + }); + + // --- Förvaringsplatser (spec §8) --- + app.post("/v1/households/:id/storage-locations", auth, async (req, reply) => { + const { id } = parse(idParamSchema, req.params); + await requireMembership(app.db, id, req.userId); + const input = parse(createStorageLocationInputSchema, req.body); + const [row] = await app.db + .insert(schema.storageLocations) + .values({ householdId: id, ...input }) + .returning(); + return reply.status(201).send(row); + }); + + app.patch("/v1/households/:id/storage-locations/:locationId", auth, async (req) => { + const params = req.params as { id: string; locationId: string }; + await requireMembership(app.db, params.id, req.userId); + const input = parse(updateStorageLocationInputSchema, req.body); + const [row] = await app.db + .update(schema.storageLocations) + .set(input) + .where( + and( + eq(schema.storageLocations.id, params.locationId), + eq(schema.storageLocations.householdId, params.id), + ), + ) + .returning(); + if (!row) throw errors.notFound(); + return row; + }); +} diff --git a/apps/api/src/routes/inventory.ts b/apps/api/src/routes/inventory.ts new file mode 100644 index 0000000..2a63604 --- /dev/null +++ b/apps/api/src/routes/inventory.ts @@ -0,0 +1,406 @@ +import type { FastifyInstance } from "fastify"; +import { and, desc, eq, gt, ilike, isNull, or } from "drizzle-orm"; +import { schema } from "@app/database"; +import { + createInventoryItemInputSchema, + idParamSchema, + inventoryQuerySchema, + inventoryTransactionInputSchema, + updateInventoryItemInputSchema, +} from "@app/validation"; +import { classifyExpiry, findDuplicateCandidates, normalizeDelta } from "@app/inventory-engine"; +import { errors, parse } from "../lib/errors.js"; +import { emitEvent, requireActiveHousehold, requireMembership } from "../lib/helpers.js"; + +/** + * Food Twin – lagret (spec §8). Transaktionsbaserat: varje förändring skrivs + * som inventory_transaction och saldot uppdateras atomiskt. + */ +export async function inventoryRoutes(app: FastifyInstance) { + const auth = { preHandler: [app.authenticate] }; + + app.get("/v1/inventory", auth, async (req) => { + const query = parse(inventoryQuerySchema, req.query); + const householdId = await requireActiveHousehold(app.db, req.userId); + + const conditions = [ + eq(schema.inventoryItems.householdId, householdId), + isNull(schema.inventoryItems.depletedAt), + gt(schema.inventoryItems.quantity, 0), + ]; + if (query.storageLocationId) { + conditions.push(eq(schema.inventoryItems.storageLocationId, query.storageLocationId)); + } + if (query.search) { + const pattern = `%${query.search}%`; + conditions.push( + or( + ilike(schema.inventoryItems.displayName, pattern), + ilike(schema.inventoryItems.brand, pattern), + )!, + ); + } + + const rows = await app.db + .select({ + item: schema.inventoryItems, + locationType: schema.storageLocations.type, + locationName: schema.storageLocations.name, + shelfLife: schema.canonicalIngredients.shelfLifeGuidance, + }) + .from(schema.inventoryItems) + .innerJoin( + schema.storageLocations, + eq(schema.inventoryItems.storageLocationId, schema.storageLocations.id), + ) + .leftJoin( + schema.canonicalIngredients, + eq(schema.inventoryItems.canonicalIngredientId, schema.canonicalIngredients.id), + ) + .where(and(...conditions)) + .orderBy(desc(schema.inventoryItems.updatedAt)) + .limit(query.limit) + .offset(query.offset); + + const items = rows.map((r) => { + const expiry = classifyExpiry({ + bestBeforeDate: r.item.bestBeforeDate, + useByDate: r.item.useByDate, + openedAt: r.item.openedAt, + frozenAt: r.item.frozenAt, + thawedAt: r.item.thawedAt, + purchasedAt: r.item.purchasedAt, + storageLocationType: r.locationType, + shelfLifeGuidance: r.shelfLife, + }); + return { + ...r.item, + locationName: r.locationName, + locationType: r.locationType, + expiry, + }; + }); + + const filtered = query.expiryStatus + ? items.filter((i) => i.expiry.status === query.expiryStatus) + : items; + return { items: filtered }; + }); + + /** Varor som bör användas snart – driver "använd först" (spec §4.4, §40). */ + app.get("/v1/inventory/expiring", auth, async (req) => { + const householdId = await requireActiveHousehold(app.db, req.userId); + const rows = await app.db + .select({ + item: schema.inventoryItems, + locationType: schema.storageLocations.type, + shelfLife: schema.canonicalIngredients.shelfLifeGuidance, + }) + .from(schema.inventoryItems) + .innerJoin( + schema.storageLocations, + eq(schema.inventoryItems.storageLocationId, schema.storageLocations.id), + ) + .leftJoin( + schema.canonicalIngredients, + eq(schema.inventoryItems.canonicalIngredientId, schema.canonicalIngredients.id), + ) + .where( + and( + eq(schema.inventoryItems.householdId, householdId), + isNull(schema.inventoryItems.depletedAt), + gt(schema.inventoryItems.quantity, 0), + ), + ); + + const withExpiry = rows + .map((r) => ({ + ...r.item, + expiry: classifyExpiry({ + bestBeforeDate: r.item.bestBeforeDate, + useByDate: r.item.useByDate, + openedAt: r.item.openedAt, + frozenAt: r.item.frozenAt, + thawedAt: r.item.thawedAt, + purchasedAt: r.item.purchasedAt, + storageLocationType: r.locationType, + shelfLifeGuidance: r.shelfLife, + }), + })) + .filter( + (i) => + i.expiry.status === "expiring" || + i.expiry.status === "use_soon" || + i.expiry.status === "expired", + ) + .sort((a, b) => (a.expiry.daysLeft ?? 99) - (b.expiry.daysLeft ?? 99)); + + return { items: withExpiry }; + }); + + app.post("/v1/inventory/items", auth, async (req, reply) => { + const input = parse(createInventoryItemInputSchema, req.body); + const householdId = await requireActiveHousehold(app.db, req.userId); + + const [location] = await app.db + .select() + .from(schema.storageLocations) + .where( + and( + eq(schema.storageLocations.id, input.storageLocationId), + eq(schema.storageLocations.householdId, householdId), + ), + ) + .limit(1); + if (!location) throw errors.badRequest("Förvaringsplatsen tillhör inte ditt hushåll."); + + // Dubblettkontroll (spec §9) – varna, blockera inte. + const existing = await app.db + .select() + .from(schema.inventoryItems) + .where( + and( + eq(schema.inventoryItems.householdId, householdId), + isNull(schema.inventoryItems.depletedAt), + gt(schema.inventoryItems.quantity, 0), + ), + ) + .limit(200); + const duplicates = findDuplicateCandidates( + { + canonicalIngredientId: input.canonicalIngredientId ?? null, + displayName: input.displayName, + brand: input.brand ?? null, + quantity: input.quantity, + source: input.source, + createdAt: new Date().toISOString(), + }, + existing.map((e) => ({ + id: e.id, + canonicalIngredientId: e.canonicalIngredientId, + displayName: e.displayName, + brand: e.brand, + quantity: e.quantity, + source: e.source, + createdAt: e.createdAt.toISOString(), + })), + ); + + const [item] = await app.db + .insert(schema.inventoryItems) + .values({ + householdId, + canonicalIngredientId: input.canonicalIngredientId ?? null, + productId: input.productId ?? null, + displayName: input.displayName, + brand: input.brand ?? null, + quantity: input.quantity, + unit: input.unit, + storageLocationId: input.storageLocationId, + sublocation: input.sublocation ?? null, + purchasedAt: input.purchasedAt ?? null, + openedAt: input.openedAt ?? null, + bestBeforeDate: input.bestBeforeDate ?? null, + useByDate: input.useByDate ?? null, + dateKind: input.dateKind ?? null, + frozenAt: input.frozenAt ?? null, + priceMinor: input.priceMinor ?? null, + source: input.source, + confidence: input.source === "manual_search" || input.source === "free_text" ? 1 : 0.9, + verifiedByUser: true, + lastVerifiedAt: new Date(), + }) + .returning(); + + await app.db.insert(schema.inventoryTransactions).values({ + householdId, + inventoryItemId: item!.id, + type: "purchase", + quantityDelta: input.quantity, + unit: input.unit, + refType: "manual", + actorUserId: req.userId, + valueMinor: input.priceMinor ?? null, + }); + + await emitEvent(app.db, { + type: "PRODUCT_ADDED", + payload: { + inventoryItemId: item!.id, + canonicalIngredientId: input.canonicalIngredientId ?? null, + quantity: input.quantity, + unit: input.unit, + source: input.source, + }, + userId: req.userId, + householdId, + correlationId: req.correlationId, + }); + + return reply.status(201).send({ item, duplicateCandidates: duplicates }); + }); + + app.patch("/v1/inventory/items/:id", auth, async (req) => { + const { id } = parse(idParamSchema, req.params); + const input = parse(updateInventoryItemInputSchema, req.body); + const item = await getOwnedItem(app, id, req.userId); + + const { verifiedByUser, quantity, ...fields } = input; + const updates: Record = { ...fields, updatedAt: new Date() }; + if (verifiedByUser) { + updates.verifiedByUser = true; + updates.lastVerifiedAt = new Date(); + } + + // Mängdändring går ALLTID via transaktion (spec §8). + if (quantity != null && quantity !== item.quantity) { + const delta = quantity - item.quantity; + await app.db.insert(schema.inventoryTransactions).values({ + householdId: item.householdId, + inventoryItemId: id, + type: "adjust", + quantityDelta: delta, + unit: item.unit, + refType: "manual", + actorUserId: req.userId, + note: "Manuell justering", + }); + updates.quantity = quantity; + } + + const [row] = await app.db + .update(schema.inventoryItems) + .set(updates) + .where(eq(schema.inventoryItems.id, id)) + .returning(); + + await emitEvent(app.db, { + type: "PRODUCT_UPDATED", + payload: { inventoryItemId: id, changes: updates }, + userId: req.userId, + householdId: item.householdId, + correlationId: req.correlationId, + }); + return row; + }); + + /** Konsumera/släng/justera – kärnan i transaktionsmodellen. */ + app.post("/v1/inventory/items/:id/transactions", auth, async (req) => { + const { id } = parse(idParamSchema, req.params); + const input = parse(inventoryTransactionInputSchema, req.body); + const item = await getOwnedItem(app, id, req.userId); + + const delta = normalizeDelta(input.type, input.quantityDelta); + const newQuantity = Math.max(0, Math.round((item.quantity + delta) * 1000) / 1000); + const actualDelta = newQuantity - item.quantity; + + const valueMinor = + input.type === "discard" && item.priceMinor != null && item.quantity > 0 + ? Math.round(Math.abs(actualDelta / item.quantity) * item.priceMinor * 100) / 100 + : null; + + await app.db.insert(schema.inventoryTransactions).values({ + householdId: item.householdId, + inventoryItemId: id, + type: input.type, + quantityDelta: actualDelta, + unit: item.unit, + refType: "manual", + actorUserId: req.userId, + note: input.note ?? null, + valueMinor, + }); + + const [updated] = await app.db + .update(schema.inventoryItems) + .set({ + quantity: newQuantity, + depletedAt: newQuantity <= 0 ? new Date() : null, + updatedAt: new Date(), + }) + .where(eq(schema.inventoryItems.id, id)) + .returning(); + + const eventType = + input.type === "discard" + ? "PRODUCT_DISCARDED" + : input.type === "consume" + ? "PRODUCT_CONSUMED" + : null; + if (eventType === "PRODUCT_DISCARDED") { + await emitEvent(app.db, { + type: "PRODUCT_DISCARDED", + payload: { + inventoryItemId: id, + quantity: Math.abs(actualDelta), + unit: item.unit, + valueMinor, + reason: input.note ?? null, + }, + userId: req.userId, + householdId: item.householdId, + correlationId: req.correlationId, + }); + } else if (eventType === "PRODUCT_CONSUMED") { + await emitEvent(app.db, { + type: "PRODUCT_CONSUMED", + payload: { + inventoryItemId: id, + quantity: Math.abs(actualDelta), + unit: item.unit, + refType: "manual", + }, + userId: req.userId, + householdId: item.householdId, + correlationId: req.correlationId, + }); + } + + return { item: updated }; + }); + + app.get("/v1/inventory/items/:id/transactions", auth, async (req) => { + const { id } = parse(idParamSchema, req.params); + await getOwnedItem(app, id, req.userId); + const txs = await app.db + .select() + .from(schema.inventoryTransactions) + .where(eq(schema.inventoryTransactions.inventoryItemId, id)) + .orderBy(desc(schema.inventoryTransactions.createdAt)) + .limit(100); + return { transactions: txs }; + }); + + app.delete("/v1/inventory/items/:id", auth, async (req) => { + const { id } = parse(idParamSchema, req.params); + const item = await getOwnedItem(app, id, req.userId); + // Radering = correction till 0 + arkivering (historiken bevaras, spec §8). + if (item.quantity > 0) { + await app.db.insert(schema.inventoryTransactions).values({ + householdId: item.householdId, + inventoryItemId: id, + type: "correction", + quantityDelta: -item.quantity, + unit: item.unit, + actorUserId: req.userId, + note: "Post borttagen av användare", + }); + } + await app.db + .update(schema.inventoryItems) + .set({ quantity: 0, depletedAt: new Date(), updatedAt: new Date() }) + .where(eq(schema.inventoryItems.id, id)); + return { ok: true }; + }); +} + +async function getOwnedItem(app: FastifyInstance, itemId: string, userId: string) { + const [item] = await app.db + .select() + .from(schema.inventoryItems) + .where(eq(schema.inventoryItems.id, itemId)) + .limit(1); + if (!item) throw errors.notFound("Lagerposten finns inte."); + await requireMembership(app.db, item.householdId, userId); + return item; +} diff --git a/apps/api/src/routes/me.ts b/apps/api/src/routes/me.ts new file mode 100644 index 0000000..685f05d --- /dev/null +++ b/apps/api/src/routes/me.ts @@ -0,0 +1,336 @@ +import type { FastifyInstance } from "fastify"; +import { eq } from "drizzle-orm"; +import { schema } from "@app/database"; +import { + consentInputSchema, + onboardingInputSchema, + updateHealthProfileInputSchema, + updateMeInputSchema, + updatePreferencesInputSchema, +} from "@app/validation"; +import { computeDailyTargets, DEFAULT_TARGETS } from "@app/nutrition-engine"; +import { errors, parse } from "../lib/errors.js"; +import { audit, generateInviteCode, getActiveHouseholdId } from "../lib/helpers.js"; +import { loadEntitlementsWithToken } from "../lib/entitlements.js"; + +/** Profil, preferenser, samtycken, dagsmål, GDPR-export/-radering (spec §6, §56). */ +export async function meRoutes(app: FastifyInstance) { + const auth = { preHandler: [app.authenticate] }; + + app.get("/v1/me", auth, async (req) => { + const [user] = await app.db + .select() + .from(schema.users) + .where(eq(schema.users.id, req.userId)) + .limit(1); + if (!user) throw errors.notFound(); + const householdId = await getActiveHouseholdId(app.db, req.userId); + return { + id: user.id, + email: user.email, + emailVerified: user.emailVerifiedAt != null, + displayName: user.displayName, + role: user.role, + locale: user.locale, + precisionMode: user.precisionMode, + onboardingCompleted: user.onboardingCompleted, + activeHouseholdId: householdId, + }; + }); + + app.patch("/v1/me", auth, async (req) => { + const input = parse(updateMeInputSchema, req.body); + const [user] = await app.db + .update(schema.users) + .set({ ...input, updatedAt: new Date() }) + .where(eq(schema.users.id, req.userId)) + .returning(); + return { + id: user!.id, + displayName: user!.displayName, + locale: user!.locale, + precisionMode: user!.precisionMode, + }; + }); + + // --- Hälsoprofil (separerad domän, spec §56) --- + app.get("/v1/me/health-profile", auth, async (req) => { + const [profile] = await app.db + .select() + .from(schema.userHealthProfiles) + .where(eq(schema.userHealthProfiles.userId, req.userId)) + .limit(1); + return profile ?? null; + }); + + app.patch("/v1/me/health-profile", auth, async (req) => { + const input = parse(updateHealthProfileInputSchema, req.body); + const [row] = await app.db + .insert(schema.userHealthProfiles) + .values({ userId: req.userId, ...input }) + .onConflictDoUpdate({ + target: schema.userHealthProfiles.userId, + set: { ...input, updatedAt: new Date() }, + }) + .returning(); + return row; + }); + + // --- Preferenser --- + app.get("/v1/me/preferences", auth, async (req) => { + const [prefs] = await app.db + .select() + .from(schema.userPreferences) + .where(eq(schema.userPreferences.userId, req.userId)) + .limit(1); + return prefs ?? null; + }); + + app.patch("/v1/me/preferences", auth, async (req) => { + const input = parse(updatePreferencesInputSchema, req.body); + const [row] = await app.db + .insert(schema.userPreferences) + .values({ userId: req.userId, ...input }) + .onConflictDoUpdate({ + target: schema.userPreferences.userId, + set: { ...input, updatedAt: new Date() }, + }) + .returning(); + return row; + }); + + // --- Onboarding i ett svep (spec §6) --- + app.post("/v1/me/onboarding", auth, async (req) => { + const input = parse(onboardingInputSchema, req.body); + + if (input.healthProfile) { + await app.db + .insert(schema.userHealthProfiles) + .values({ userId: req.userId, ...input.healthProfile }) + .onConflictDoUpdate({ + target: schema.userHealthProfiles.userId, + set: { ...input.healthProfile, updatedAt: new Date() }, + }); + } + if (input.preferences) { + await app.db + .insert(schema.userPreferences) + .values({ userId: req.userId, ...input.preferences }) + .onConflictDoUpdate({ + target: schema.userPreferences.userId, + set: { ...input.preferences, updatedAt: new Date() }, + }); + } + + let householdId: string | null = await getActiveHouseholdId(app.db, req.userId); + if (input.householdChoice.kind === "create" && !householdId) { + const [household] = await app.db + .insert(schema.households) + .values({ name: input.householdChoice.name, inviteCode: generateInviteCode() }) + .returning(); + await app.db.insert(schema.householdMembers).values({ + householdId: household!.id, + userId: req.userId, + role: "owner", + }); + // Standardplatser: kyl, frys, skafferi (spec §8) + await app.db.insert(schema.storageLocations).values([ + { householdId: household!.id, type: "fridge", name: "Kylen", sortOrder: 0 }, + { householdId: household!.id, type: "freezer", name: "Frysen", sortOrder: 1 }, + { householdId: household!.id, type: "pantry", name: "Skafferiet", sortOrder: 2 }, + ]); + householdId = household!.id; + } else if (input.householdChoice.kind === "join") { + const [household] = await app.db + .select() + .from(schema.households) + .where(eq(schema.households.inviteCode, input.householdChoice.inviteCode.toUpperCase())) + .limit(1); + if (!household) throw errors.notFound("Ingen hushållsinbjudan matchar koden."); + await app.db + .insert(schema.householdMembers) + .values({ householdId: household.id, userId: req.userId, role: "adult" }) + .onConflictDoNothing(); + householdId = household.id; + } + + await app.db + .update(schema.users) + .set({ precisionMode: input.precisionMode, onboardingCompleted: true, updatedAt: new Date() }) + .where(eq(schema.users.id, req.userId)); + + return { ok: true, householdId }; + }); + + // --- Samtycken (spec §33: separata) --- + app.get("/v1/me/consents", auth, async (req) => { + return app.db + .select() + .from(schema.userConsents) + .where(eq(schema.userConsents.userId, req.userId)); + }); + + app.put("/v1/me/consents", auth, async (req) => { + const input = parse(consentInputSchema, req.body); + const now = new Date(); + const [row] = await app.db + .insert(schema.userConsents) + .values({ + userId: req.userId, + kind: input.kind, + status: input.granted ? "granted" : "denied", + grantedAt: input.granted ? now : null, + revokedAt: input.granted ? null : now, + }) + .onConflictDoUpdate({ + target: [schema.userConsents.userId, schema.userConsents.kind], + set: { + status: input.granted ? "granted" : "revoked", + ...(input.granted ? { grantedAt: now, revokedAt: null } : { revokedAt: now }), + updatedAt: now, + }, + }) + .returning(); + await audit(app.db, { + actorUserId: req.userId, + action: `consent.${input.granted ? "granted" : "revoked"}`, + targetType: "consent", + targetId: input.kind, + }); + return row; + }); + + // --- Dagsmål: beräknas deterministiskt, med transparent grund (spec §21) --- + app.get("/v1/me/daily-targets", auth, async (req) => { + const [profile] = await app.db + .select() + .from(schema.userHealthProfiles) + .where(eq(schema.userHealthProfiles.userId, req.userId)) + .limit(1); + const [prefs] = await app.db + .select() + .from(schema.userPreferences) + .where(eq(schema.userPreferences.userId, req.userId)) + .limit(1); + + if (!profile?.weightKg || !profile.heightCm || !profile.birthYear) { + return { + targets: DEFAULT_TARGETS, + basis: null, + note: "Schablonmål – fyll i längd, vikt och födelseår för personliga mål.", + }; + } + const result = computeDailyTargets({ + sex: profile.sex ?? "unspecified", + age: new Date().getUTCFullYear() - profile.birthYear, + heightCm: profile.heightCm, + weightKg: profile.weightKg, + activityLevel: profile.activityLevel, + primaryGoal: prefs?.primaryGoal ?? undefined, + }); + return { + ...result, + note: "Uppskattning enligt Mifflin–St Jeor. Appen är inte medicinsk rådgivning.", + }; + }); + + // --- Entitlements (spec §47) --- + app.get("/v1/me/entitlements", auth, async (req) => { + return loadEntitlementsWithToken(app, req.userId); + }); + + // --- Locale-preferenser (i18n-spec §6): språk ≠ region ≠ enheter --- + app.get("/v1/me/locale-preferences", auth, async (req) => { + const { loadLocalePreferences } = await import("../lib/localeContext.js"); + return loadLocalePreferences(app.db, req.userId); + }); + + app.patch("/v1/me/locale-preferences", auth, async (req) => { + const { updateLocalePreferencesInputSchema } = await import("@app/validation"); + const input = parse(updateLocalePreferencesInputSchema, req.body); + const [row] = await app.db + .insert(schema.userLocalePreferences) + .values({ userId: req.userId, ...input }) + .onConflictDoUpdate({ + target: schema.userLocalePreferences.userId, + set: { ...input, updatedAt: new Date() }, + }) + .returning(); + return row; + }); + + // --- GDPR: export (spec §56) --- + app.get("/v1/me/export", auth, async (req) => { + const userId = req.userId; + const [user] = await app.db + .select() + .from(schema.users) + .where(eq(schema.users.id, userId)) + .limit(1); + const [health] = await app.db + .select() + .from(schema.userHealthProfiles) + .where(eq(schema.userHealthProfiles.userId, userId)) + .limit(1); + const [prefs] = await app.db + .select() + .from(schema.userPreferences) + .where(eq(schema.userPreferences.userId, userId)) + .limit(1); + const consents = await app.db + .select() + .from(schema.userConsents) + .where(eq(schema.userConsents.userId, userId)); + const meals = await app.db.select().from(schema.meals).where(eq(schema.meals.userId, userId)); + const memory = await app.db + .select() + .from(schema.memoryItems) + .where(eq(schema.memoryItems.userId, userId)); + const ratings = await app.db + .select() + .from(schema.recipeRatings) + .where(eq(schema.recipeRatings.userId, userId)); + + await audit(app.db, { actorUserId: userId, action: "gdpr.export", ip: req.ip }); + return { + exportedAt: new Date().toISOString(), + user, + healthProfile: health ?? null, + preferences: prefs ?? null, + consents, + meals, + memory, + ratings, + }; + }); + + // --- GDPR: radera konto (spec §56, §32) --- + app.delete("/v1/me", auth, async (req) => { + const userId = req.userId; + // Hård radering av persondata via FK-cascade; users-raden anonymiseras + // och soft-deletas för att bevara referensintegritet i aggregat. + await app.db.delete(schema.memoryItems).where(eq(schema.memoryItems.userId, userId)); + await app.db.delete(schema.tasteSignals).where(eq(schema.tasteSignals.userId, userId)); + await app.db.delete(schema.meals).where(eq(schema.meals.userId, userId)); + await app.db + .delete(schema.userHealthProfiles) + .where(eq(schema.userHealthProfiles.userId, userId)); + await app.db.delete(schema.userPreferences).where(eq(schema.userPreferences.userId, userId)); + await app.db.delete(schema.refreshTokens).where(eq(schema.refreshTokens.userId, userId)); + await app.db.delete(schema.userCredentials).where(eq(schema.userCredentials.userId, userId)); + await app.db + .update(schema.users) + .set({ + email: `deleted-${userId}@anonymized.invalid`, + displayName: "Raderad användare", + deletedAt: new Date(), + updatedAt: new Date(), + }) + .where(eq(schema.users.id, userId)); + await audit(app.db, { actorUserId: userId, action: "gdpr.delete_account", ip: req.ip }); + return { + ok: true, + message: "Kontot är raderat. Kvarvarande backupper roteras ut enligt retentionspolicyn.", + }; + }); +} diff --git a/apps/api/src/routes/meals.ts b/apps/api/src/routes/meals.ts new file mode 100644 index 0000000..3734e17 --- /dev/null +++ b/apps/api/src/routes/meals.ts @@ -0,0 +1,334 @@ +import type { FastifyInstance } from "fastify"; +import { and, desc, eq } from "drizzle-orm"; +import { schema } from "@app/database"; +import { + consumeMealBoxInputSchema, + createMealBoxInputSchema, + dayQuerySchema, + idParamSchema, + logMealInputSchema, +} from "@app/validation"; +import { + computeItemNutrition, + DEFAULT_TARGETS, + computeDailyTargets, + scaleNutrition, + summarizeDay, + sumNutrition, +} from "@app/nutrition-engine"; +import { EMPTY_NUTRITION, type NutritionValues } from "@app/shared-types"; +import { errors, parse } from "../lib/errors.js"; +import { emitEvent, requireActiveHousehold, requireMembership, todayIso } from "../lib/helpers.js"; + +/** + * Måltidsloggning + "Min dag" (spec §4.3, §23) och matlådor (spec §24). + * Näringsvärden härleds deterministiskt – aldrig av AI (spec §61.1). + */ +export async function mealRoutes(app: FastifyInstance) { + const auth = { preHandler: [app.authenticate] }; + + app.post("/v1/meals", auth, async (req, reply) => { + const input = parse(logMealInputSchema, req.body); + const householdId = await requireActiveHousehold(app.db, req.userId).catch(() => null); + + let nutrition: NutritionValues; + let isEstimate = false; + let estimateMin: number | null = null; + let estimateMax: number | null = null; + + if (input.nutritionOverride) { + // Användarens egen inmatning eller bekräftat foto-intervall. + nutrition = { ...EMPTY_NUTRITION, ...input.nutritionOverride }; + isEstimate = input.source === "plate_photo"; + } else if (input.recipeId) { + const [recipe] = await app.db + .select() + .from(schema.recipes) + .where(eq(schema.recipes.id, input.recipeId)) + .limit(1); + if (!recipe) throw errors.notFound("Receptet finns inte."); + nutrition = scaleNutrition(recipe.nutritionPerPortion, input.portionFraction); + } else if (input.items.length > 0) { + const parts: NutritionValues[] = []; + for (const item of input.items) { + if (item.nutrition) { + parts.push({ ...EMPTY_NUTRITION, ...item.nutrition }); + continue; + } + if (item.canonicalIngredientId && item.quantity != null && item.unit) { + const [ing] = await app.db + .select() + .from(schema.canonicalIngredients) + .where(eq(schema.canonicalIngredients.id, item.canonicalIngredientId)) + .limit(1); + if (ing) { + const computed = computeItemNutrition(item.quantity, item.unit, ing.nutritionPer100, { + densityGPerMl: ing.densityGPerMl, + gramsPerPiece: ing.gramsPerPiece, + }); + if (computed) { + parts.push(computed); + continue; + } + } + } + throw errors.badRequest( + `"${item.displayName}" saknar näringsdata. Ange mängd + känd ingrediens, eller egna värden.`, + ); + } + nutrition = sumNutrition(parts); + } else if (input.scanJobId) { + // Tallriksfoto: intervall från AAMOS som användaren bekräftar (spec §22). + const [job] = await app.db + .select() + .from(schema.scanJobs) + .where(and(eq(schema.scanJobs.id, input.scanJobId), eq(schema.scanJobs.userId, req.userId))) + .limit(1); + if (!job?.result) throw errors.badRequest("Skanningen har inget resultat."); + const result = job.result as { kcalRange?: { min: number; max: number; mostLikely: number } }; + if (!result.kcalRange) throw errors.badRequest("Skanningen saknar kaloriuppskattning."); + nutrition = { ...EMPTY_NUTRITION, kcal: result.kcalRange.mostLikely }; + isEstimate = true; + estimateMin = result.kcalRange.min; + estimateMax = result.kcalRange.max; + } else { + throw errors.badRequest("Ange recept, livsmedel, skanning eller egna näringsvärden."); + } + + const [meal] = await app.db + .insert(schema.meals) + .values({ + userId: req.userId, + householdId, + date: input.date, + mealType: input.mealType, + source: input.source, + recipeId: input.recipeId ?? null, + titleSv: input.titleSv, + portionFraction: input.portionFraction, + nutrition, + nutritionIsEstimate: isEstimate, + estimateMinKcal: estimateMin, + estimateMaxKcal: estimateMax, + items: input.items.length > 0 ? input.items : null, + scanJobId: input.scanJobId ?? null, + }) + .returning(); + + await emitEvent(app.db, { + type: "MEAL_LOGGED", + payload: { + mealId: meal!.id, + mealType: input.mealType, + kcal: nutrition.kcal, + source: input.source, + }, + userId: req.userId, + householdId: householdId ?? undefined, + correlationId: req.correlationId, + }); + + return reply.status(201).send(meal); + }); + + /** "Min dag" (spec §4.3): måltider + summering mot personliga mål. */ + app.get("/v1/meals/day", auth, async (req) => { + const { date } = parse(dayQuerySchema, req.query); + const meals = await app.db + .select() + .from(schema.meals) + .where(and(eq(schema.meals.userId, req.userId), eq(schema.meals.date, date))) + .orderBy(schema.meals.loggedAt); + + const [profile] = await app.db + .select() + .from(schema.userHealthProfiles) + .where(eq(schema.userHealthProfiles.userId, req.userId)) + .limit(1); + const [prefs] = await app.db + .select() + .from(schema.userPreferences) + .where(eq(schema.userPreferences.userId, req.userId)) + .limit(1); + + const targets = + profile?.weightKg && profile.heightCm && profile.birthYear + ? computeDailyTargets({ + sex: profile.sex ?? "unspecified", + age: new Date().getUTCFullYear() - profile.birthYear, + heightCm: profile.heightCm, + weightKg: profile.weightKg, + activityLevel: profile.activityLevel, + primaryGoal: prefs?.primaryGoal ?? undefined, + }).targets + : DEFAULT_TARGETS; + + const summary = summarizeDay( + meals.map((m) => m.nutrition), + targets, + ); + + const hasEstimates = meals.some((m) => m.nutritionIsEstimate); + return { + date, + meals, + summary, + note: hasEstimates + ? "Dagen innehåller uppskattade värden från foto – justera gärna vid behov." + : "Värdena är beräknade ur recept och livsmedelsdata och visas som uppskattningar.", + }; + }); + + app.delete("/v1/meals/:id", auth, async (req) => { + const { id } = parse(idParamSchema, req.params); + const [meal] = await app.db + .select() + .from(schema.meals) + .where(and(eq(schema.meals.id, id), eq(schema.meals.userId, req.userId))) + .limit(1); + if (!meal) throw errors.notFound(); + await app.db.delete(schema.meals).where(eq(schema.meals.id, id)); + return { ok: true }; + }); + + // --------------------------------------------------------------------- + // Matlådor (spec §24) + // --------------------------------------------------------------------- + + app.get("/v1/meal-boxes", auth, async (req) => { + const householdId = await requireActiveHousehold(app.db, req.userId); + const boxes = await app.db + .select() + .from(schema.mealBoxes) + .where( + and( + eq(schema.mealBoxes.householdId, householdId), + eq(schema.mealBoxes.status, "available"), + ), + ) + .orderBy(schema.mealBoxes.recommendedUseBy); + return { mealBoxes: boxes }; + }); + + app.post("/v1/meal-boxes", auth, async (req, reply) => { + const input = parse(createMealBoxInputSchema, req.body); + const householdId = await requireActiveHousehold(app.db, req.userId); + + let nutritionPerPortion = null; + if (input.recipeId) { + const [recipe] = await app.db + .select({ nutrition: schema.recipes.nutritionPerPortion }) + .from(schema.recipes) + .where(eq(schema.recipes.id, input.recipeId)) + .limit(1); + nutritionPerPortion = recipe?.nutrition ?? null; + } + + const cookedAt = input.cookedAt ?? todayIso(); + const useByDays = input.frozen ? 90 : 3; + const [box] = await app.db + .insert(schema.mealBoxes) + .values({ + householdId, + recipeId: input.recipeId ?? null, + titleSv: input.titleSv, + portions: input.portions, + portionsRemaining: input.portions, + nutritionPerPortion, + cookedAt, + storageLocationId: input.storageLocationId, + frozen: input.frozen, + recommendedUseBy: new Date(Date.parse(cookedAt) + useByDays * 86_400_000) + .toISOString() + .slice(0, 10), + reservedForUserId: input.reservedForUserId ?? null, + }) + .returning(); + + await emitEvent(app.db, { + type: "MEAL_BOX_CREATED", + payload: { mealBoxId: box!.id, portions: input.portions }, + userId: req.userId, + householdId, + correlationId: req.correlationId, + }); + return reply.status(201).send(box); + }); + + app.post("/v1/meal-boxes/:id/consume", auth, async (req) => { + const { id } = parse(idParamSchema, req.params); + const input = parse(consumeMealBoxInputSchema, req.body); + const [box] = await app.db + .select() + .from(schema.mealBoxes) + .where(eq(schema.mealBoxes.id, id)) + .limit(1); + if (!box) throw errors.notFound("Matlådan finns inte."); + await requireMembership(app.db, box.householdId, req.userId); + if (box.portionsRemaining < input.portions) { + throw errors.conflict(`Bara ${box.portionsRemaining} portioner kvar.`); + } + + const remaining = box.portionsRemaining - input.portions; + await app.db + .update(schema.mealBoxes) + .set({ portionsRemaining: remaining, status: remaining <= 0 ? "consumed" : box.status }) + .where(eq(schema.mealBoxes.id, id)); + + let mealId: string | null = null; + if (input.logAsMeal && box.nutritionPerPortion) { + const [meal] = await app.db + .insert(schema.meals) + .values({ + userId: req.userId, + householdId: box.householdId, + date: input.date ?? todayIso(), + mealType: input.mealType, + source: "meal_box", + recipeId: box.recipeId, + titleSv: box.titleSv, + portionFraction: input.portions, + nutrition: scaleNutrition(box.nutritionPerPortion, input.portions), + nutritionIsEstimate: false, + }) + .returning(); + mealId = meal!.id; + } + + await emitEvent(app.db, { + type: "MEAL_BOX_CONSUMED", + payload: { mealBoxId: id, portions: input.portions }, + userId: req.userId, + householdId: box.householdId, + correlationId: req.correlationId, + }); + return { ok: true, portionsRemaining: remaining, mealId }; + }); + + app.post("/v1/meal-boxes/:id/discard", auth, async (req) => { + const { id } = parse(idParamSchema, req.params); + const [box] = await app.db + .select() + .from(schema.mealBoxes) + .where(eq(schema.mealBoxes.id, id)) + .limit(1); + if (!box) throw errors.notFound(); + await requireMembership(app.db, box.householdId, req.userId); + await app.db + .update(schema.mealBoxes) + .set({ status: "discarded", portionsRemaining: 0 }) + .where(eq(schema.mealBoxes.id, id)); + return { ok: true }; + }); + + /** Måltidshistorik ("tidigare måltid" som loggkälla, spec §23). */ + app.get("/v1/meals/recent", auth, async (req) => { + const meals = await app.db + .select() + .from(schema.meals) + .where(eq(schema.meals.userId, req.userId)) + .orderBy(desc(schema.meals.loggedAt)) + .limit(20); + return { meals }; + }); +} diff --git a/apps/api/src/routes/memory.ts b/apps/api/src/routes/memory.ts new file mode 100644 index 0000000..b12e506 --- /dev/null +++ b/apps/api/src/routes/memory.ts @@ -0,0 +1,143 @@ +import type { FastifyInstance } from "fastify"; +import { and, desc, eq, or } from "drizzle-orm"; +import { schema } from "@app/database"; +import { buildMemoryOverview } from "@app/memory-client"; +import { userLanguageTag } from "../lib/contentLanguage.js"; +import { idParamSchema, memoryQuerySchema, updateMemoryItemInputSchema } from "@app/validation"; +import { errors, parse } from "../lib/errors.js"; +import { audit, emitEvent, getActiveHouseholdId } from "../lib/helpers.js"; + +/** + * "Vad plattformen vet om mig" (spec §32): full transparens. + * Användaren kan korrigera, pausa, radera varje post – och radera allt. + */ +export async function memoryRoutes(app: FastifyInstance) { + const auth = { preHandler: [app.authenticate] }; + + app.get("/v1/me/memory", auth, async (req) => { + const q = parse(memoryQuerySchema, req.query); + const householdId = await getActiveHouseholdId(app.db, req.userId); + + const scope = householdId + ? or( + eq(schema.memoryItems.userId, req.userId), + eq(schema.memoryItems.householdId, householdId), + )! + : eq(schema.memoryItems.userId, req.userId); + const conditions = [scope]; + if (q.kind) conditions.push(eq(schema.memoryItems.kind, q.kind)); + if (!q.includePaused) conditions.push(eq(schema.memoryItems.paused, false)); + + const items = await app.db + .select() + .from(schema.memoryItems) + .where(and(...conditions)) + .orderBy(desc(schema.memoryItems.updatedAt)) + .limit(q.limit) + .offset(q.offset); + + const overview = buildMemoryOverview( + items.map((i) => ({ + id: i.id, + userId: i.userId ?? undefined, + householdId: i.householdId ?? undefined, + kind: i.kind, + key: i.key, + summarySv: i.summarySv, + value: i.value, + origin: i.origin, + confidence: i.confidence, + verifiedByUser: i.verifiedByUser, + paused: i.paused, + createdAt: i.createdAt.toISOString(), + updatedAt: i.updatedAt.toISOString(), + lastUsedAt: i.lastUsedAt?.toISOString(), + expiresAt: i.expiresAt?.toISOString(), + })), + await userLanguageTag(app.db, req.userId), + ); + return overview; + }); + + app.patch("/v1/me/memory/:id", auth, async (req) => { + const { id } = parse(idParamSchema, req.params); + const input = parse(updateMemoryItemInputSchema, req.body); + const item = await getOwnedMemory(app, id, req.userId); + + const updates: Record = { updatedAt: new Date() }; + if (input.summarySv != null) updates.summarySv = input.summarySv; + if (input.value !== undefined) updates.value = input.value; + if (input.paused != null) updates.paused = input.paused; + if (input.verified != null || input.summarySv != null || input.value !== undefined) { + // Användarkorrigering gör posten verifierad och användarägd (spec §30). + updates.verifiedByUser = true; + updates.origin = "user_stated"; + updates.confidence = 1; + } + + const [row] = await app.db + .update(schema.memoryItems) + .set(updates) + .where(eq(schema.memoryItems.id, item.id)) + .returning(); + + await emitEvent(app.db, { + type: "MEMORY_UPDATED", + payload: { memoryItemId: id, kind: item.kind, origin: "user_stated" }, + userId: req.userId, + correlationId: req.correlationId, + }); + return row; + }); + + app.delete("/v1/me/memory/:id", auth, async (req) => { + const { id } = parse(idParamSchema, req.params); + const item = await getOwnedMemory(app, id, req.userId); + await app.db.delete(schema.memoryItems).where(eq(schema.memoryItems.id, item.id)); + await audit(app.db, { + actorUserId: req.userId, + action: "memory.deleted", + targetType: "memory_item", + targetId: id, + }); + return { ok: true }; + }); + + /** Radera ALLT personligt minne (spec §32: "radera"). */ + app.delete("/v1/me/memory", auth, async (req) => { + await app.db.delete(schema.memoryItems).where(eq(schema.memoryItems.userId, req.userId)); + await app.db.delete(schema.tasteSignals).where(eq(schema.tasteSignals.userId, req.userId)); + await audit(app.db, { actorUserId: req.userId, action: "memory.deleted_all" }); + return { ok: true, message: "Allt personligt minne är raderat." }; + }); + + /** Pausa allt minne (spec §32: "pausa"). */ + app.post("/v1/me/memory/pause-all", auth, async (req) => { + const body = (req.body ?? {}) as { paused?: boolean }; + const paused = body.paused ?? true; + await app.db + .update(schema.memoryItems) + .set({ paused, updatedAt: new Date() }) + .where(eq(schema.memoryItems.userId, req.userId)); + await audit(app.db, { + actorUserId: req.userId, + action: paused ? "memory.paused_all" : "memory.resumed_all", + }); + return { ok: true, paused }; + }); +} + +async function getOwnedMemory(app: FastifyInstance, id: string, userId: string) { + const [item] = await app.db + .select() + .from(schema.memoryItems) + .where(eq(schema.memoryItems.id, id)) + .limit(1); + if (!item) throw errors.notFound("Minnesposten finns inte."); + if (item.userId && item.userId !== userId) throw errors.forbidden(); + if (!item.userId && item.householdId) { + const { requireMembership } = await import("../lib/helpers.js"); + await requireMembership(app.db, item.householdId, userId); + } + return item; +} diff --git a/apps/api/src/routes/planning.ts b/apps/api/src/routes/planning.ts new file mode 100644 index 0000000..8330148 --- /dev/null +++ b/apps/api/src/routes/planning.ts @@ -0,0 +1,165 @@ +import type { FastifyInstance } from "fastify"; +import { and, desc, eq, gte, sql } from "drizzle-orm"; +import { schema } from "@app/database"; +import { + generateWeekPlanInputSchema, + idParamSchema, + updatePlanEntryInputSchema, + weekPlanQuerySchema, +} from "@app/validation"; +import { errors, parse } from "../lib/errors.js"; +import { emitEvent, requireActiveHousehold, requireMembership } from "../lib/helpers.js"; +import { requireFeature } from "../lib/entitlements.js"; + +/** + * Veckoplanering (spec §25). Planen genereras asynkront av workern + * (GENERATE_WEEK_PLAN) som väger lager, utgångsdatum, matlådor, budget, + * variation och mål – med deterministisk kärna och AAMOS som rådgivare. + */ +export async function planningRoutes(app: FastifyInstance) { + const auth = { preHandler: [app.authenticate] }; + + app.get("/v1/week-plans", auth, async (req) => { + const q = parse(weekPlanQuerySchema, req.query); + const householdId = await requireActiveHousehold(app.db, req.userId); + const conditions = [eq(schema.weekPlans.householdId, householdId)]; + if (q.weekStartDate) conditions.push(eq(schema.weekPlans.weekStartDate, q.weekStartDate)); + + const plans = await app.db + .select() + .from(schema.weekPlans) + .where(and(...conditions)) + .orderBy(desc(schema.weekPlans.weekStartDate)) + .limit(8); + + const result = []; + for (const plan of plans) { + const entries = await app.db + .select() + .from(schema.weekPlanEntries) + .where(eq(schema.weekPlanEntries.weekPlanId, plan.id)) + .orderBy(schema.weekPlanEntries.date, schema.weekPlanEntries.sortOrder); + result.push({ ...plan, entries }); + } + return { plans: result }; + }); + + app.post("/v1/week-plans/generate", auth, async (req, reply) => { + await requireFeature(app.db, req.userId, "weekPlanning", "Veckoplanering"); + const input = parse(generateWeekPlanInputSchema, req.body); + const householdId = await requireActiveHousehold(app.db, req.userId); + + const [plan] = await app.db + .insert(schema.weekPlans) + .values({ + householdId, + weekStartDate: input.weekStartDate, + status: "draft", + generatedBy: "engine", + notes: input.noteSv ?? null, + }) + .returning(); + + await app.jobQueue.add("GENERATE_WEEK_PLAN", { + jobType: "GENERATE_WEEK_PLAN", + weekPlanId: plan!.id, + householdId, + userId: req.userId, + input, + correlationId: req.correlationId, + }); + + return reply.status(202).send({ + plan, + message: "Planen genereras – hämta den om en stund via GET /v1/week-plans.", + }); + }); + + app.patch("/v1/week-plans/:id/entries/:entryId", auth, async (req) => { + const params = req.params as { id: string; entryId: string }; + const [plan] = await app.db + .select() + .from(schema.weekPlans) + .where(eq(schema.weekPlans.id, params.id)) + .limit(1); + if (!plan) throw errors.notFound("Planen finns inte."); + await requireMembership(app.db, plan.householdId, req.userId); + + const input = parse(updatePlanEntryInputSchema, req.body); + const updates: Record = { ...input }; + + // Dynamisk omplanering med förklaring (spec §25) + if (input.status === "skipped") { + const [entry] = await app.db + .select() + .from(schema.weekPlanEntries) + .where(eq(schema.weekPlanEntries.id, params.entryId)) + .limit(1); + if (entry?.recipeId) { + // Flytta rätten till nästa lediga dag om råvaror bör användas. + const later = await app.db + .select() + .from(schema.weekPlanEntries) + .where( + and( + eq(schema.weekPlanEntries.weekPlanId, params.id), + gte(schema.weekPlanEntries.date, entry.date), + eq(schema.weekPlanEntries.status, "planned"), + sql`${schema.weekPlanEntries.id} <> ${params.entryId}`, + ), + ) + .orderBy(schema.weekPlanEntries.date) + .limit(1); + if (later[0]) { + await app.db + .update(schema.weekPlanEntries) + .set({ + recipeId: entry.recipeId, + titleSv: entry.titleSv, + status: "moved", + rescheduleReasonSv: `${entry.titleSv} flyttades hit eftersom råvarorna bör användas först.`, + }) + .where(eq(schema.weekPlanEntries.id, later[0].id)); + } + } + } + + const [row] = await app.db + .update(schema.weekPlanEntries) + .set(updates) + .where( + and( + eq(schema.weekPlanEntries.id, params.entryId), + eq(schema.weekPlanEntries.weekPlanId, params.id), + ), + ) + .returning(); + if (!row) throw errors.notFound("Planposten finns inte."); + + await emitEvent(app.db, { + type: "WEEK_PLAN_UPDATED", + payload: { weekPlanId: params.id, reason: input.status ?? null }, + userId: req.userId, + householdId: plan.householdId, + correlationId: req.correlationId, + }); + return row; + }); + + app.post("/v1/week-plans/:id/activate", auth, async (req) => { + const { id } = parse(idParamSchema, req.params); + const [plan] = await app.db + .select() + .from(schema.weekPlans) + .where(eq(schema.weekPlans.id, id)) + .limit(1); + if (!plan) throw errors.notFound(); + await requireMembership(app.db, plan.householdId, req.userId); + const [row] = await app.db + .update(schema.weekPlans) + .set({ status: "active", updatedAt: new Date() }) + .where(eq(schema.weekPlans.id, id)) + .returning(); + return row; + }); +} diff --git a/apps/api/src/routes/recipes.ts b/apps/api/src/routes/recipes.ts new file mode 100644 index 0000000..d3393f9 --- /dev/null +++ b/apps/api/src/routes/recipes.ts @@ -0,0 +1,1031 @@ +import type { FastifyInstance } from "fastify"; +import { and, desc, eq, gt, ilike, inArray, isNull, lte, or, sql } from "drizzle-orm"; +import { schema } from "@app/database"; +import { + cookRecipeInputSchema, + createUserRecipeInputSchema, + idParamSchema, + rateRecipeInputSchema, + recipeQuerySchema, + substitutionQuerySchema, +} from "@app/validation"; +import { + checkRecipeSafety, + deriveRecipeAllergens, + scaleIngredients, + type IngredientSafetyInfo, +} from "@app/recipe-engine"; +import { allocateFefo, classifyExpiry } from "@app/inventory-engine"; +import { computeRecipeNutrition, scaleNutrition } from "@app/nutrition-engine"; +import { errors, parse } from "../lib/errors.js"; +import { loadLocalePreferences } from "../lib/localeContext.js"; +import { + languageCandidates, + resolveIngredientNames, + resolveRecipeTranslation, + userLanguageTag, +} from "../lib/contentLanguage.js"; +import { + emitEvent, + getActiveHouseholdId, + requireActiveHousehold, + todayIso, +} from "../lib/helpers.js"; +import { requireFeature } from "../lib/entitlements.js"; + +/** Recept: sök, detalj, betyg, favoriter, "jag har lagat", substitutioner, användarrecept. */ +export async function recipeRoutes(app: FastifyInstance) { + const auth = { preHandler: [app.authenticate] }; + + app.get("/v1/recipes", auth, async (req) => { + const q = parse(recipeQuerySchema, req.query); + + const conditions = [eq(schema.recipes.status, "published")]; + if (q.search) { + conditions.push( + or( + ilike(schema.recipes.titleSv, `%${q.search}%`), + ilike(schema.recipes.descriptionSv, `%${q.search}%`), + )!, + ); + } + if (q.cuisine) conditions.push(eq(schema.recipes.cuisine, q.cuisine)); + if (q.mealType) conditions.push(sql`${q.mealType} = ANY(${schema.recipes.mealTypes})`); + if (q.tags) + for (const tag of q.tags) conditions.push(sql`${tag} = ANY(${schema.recipes.tags})`); + if (q.method) conditions.push(sql`${q.method} = ANY(${schema.recipes.methods})`); + if (q.maxTotalMinutes) conditions.push(lte(schema.recipes.totalTimeMinutes, q.maxTotalMinutes)); + if (q.difficulty) conditions.push(eq(schema.recipes.difficulty, q.difficulty)); + if (q.creatorUserId) conditions.push(eq(schema.recipes.creatorUserId, q.creatorUserId)); + if (q.maxKcalPerPortion) { + conditions.push( + sql`(${schema.recipes.nutritionPerPortion}->>'kcal')::float <= ${q.maxKcalPerPortion}`, + ); + } + if (q.minProteinPerPortion) { + conditions.push( + sql`(${schema.recipes.nutritionPerPortion}->>'proteinG')::float >= ${q.minProteinPerPortion}`, + ); + } + if (q.maxCostMinorPerPortion) { + conditions.push(lte(schema.recipes.estimatedCostMinorPerPortion, q.maxCostMinorPerPortion)); + } + // Deterministisk allergifiltrering på databasnivå (spec §61.2) + if (q.excludeAllergens) { + for (const allergen of q.excludeAllergens) { + conditions.push(sql`NOT (${allergen} = ANY(${schema.recipes.allergens}))`); + } + } + + const orderBy = + q.sort === "rating" + ? desc(schema.recipes.ratingAverage) + : q.sort === "cooked" + ? desc(schema.recipes.cookCount) + : q.sort === "newest" + ? desc(schema.recipes.createdAt) + : q.sort === "time" + ? schema.recipes.totalTimeMinutes + : q.sort === "cost" + ? schema.recipes.estimatedCostMinorPerPortion + : desc(schema.recipes.cookCount); + + const rows = await app.db + .select({ + id: schema.recipes.id, + slug: schema.recipes.slug, + titleSv: schema.recipes.titleSv, + descriptionSv: schema.recipes.descriptionSv, + cuisine: schema.recipes.cuisine, + mealTypes: schema.recipes.mealTypes, + tags: schema.recipes.tags, + totalTimeMinutes: schema.recipes.totalTimeMinutes, + portions: schema.recipes.portions, + nutritionPerPortion: schema.recipes.nutritionPerPortion, + allergens: schema.recipes.allergens, + spiceLevel: schema.recipes.spiceLevel, + estimatedCostMinorPerPortion: schema.recipes.estimatedCostMinorPerPortion, + costCurrency: sql`'SEK'`, + difficulty: schema.recipes.difficulty, + imageUrls: schema.recipes.imageUrls, + ratingAverage: schema.recipes.ratingAverage, + ratingCount: schema.recipes.ratingCount, + cookCount: schema.recipes.cookCount, + verificationStatus: schema.recipes.verificationStatus, + creatorDisplayName: schema.recipes.creatorDisplayName, + variantType: schema.recipes.variantType, + }) + .from(schema.recipes) + .where(and(...conditions)) + .orderBy(orderBy) + .limit(q.limit) + .offset(q.offset); + + // Titlar på användarens språk där publicerad översättning finns (i18n M3). + const languageTag = await userLanguageTag(app.db, req.userId); + const candidates = languageCandidates(languageTag); + let titleMap = new Map(); + if (!candidates.includes("sv") && rows.length > 0) { + const translations = await app.db + .select({ + recipeId: schema.recipeTranslations.recipeId, + languageTag: schema.recipeTranslations.languageTag, + title: schema.recipeTranslations.title, + }) + .from(schema.recipeTranslations) + .where( + and( + inArray( + schema.recipeTranslations.recipeId, + rows.map((r) => r.id), + ), + inArray(schema.recipeTranslations.languageTag, candidates), + eq(schema.recipeTranslations.status, "published"), + ), + ); + for (const candidate of [...candidates].reverse()) { + for (const tr of translations) + if (tr.languageTag === candidate) titleMap.set(tr.recipeId, tr.title); + } + } + + return { + recipes: rows.map((r) => ({ ...r, title: titleMap.get(r.id) ?? r.titleSv })), + language: candidates.includes("sv") ? "sv" : languageTag, + }; + }); + + app.get("/v1/recipes/:id", auth, async (req) => { + const { id } = parse(idParamSchema, req.params); + const recipe = await loadFullRecipe(app, id); + + // Personlig säkerhetskontroll – deterministisk (spec §61.2). + const [prefs] = await app.db + .select() + .from(schema.userPreferences) + .where(eq(schema.userPreferences.userId, req.userId)) + .limit(1); + let safety: { safe: boolean; violations: unknown[] } = { safe: true, violations: [] }; + if (prefs) { + const info = await ingredientSafetyMap( + app, + recipe.ingredients.map((i) => i.canonicalIngredientId), + ); + const violations = checkRecipeSafety( + { + ingredients: recipe.ingredients.map((i) => ({ + canonicalIngredientId: i.canonicalIngredientId, + optional: i.optional, + })), + spiceLevel: recipe.spiceLevel, + }, + { + allergens: prefs.allergens, + dietPattern: prefs.dietPattern, + religiousRule: prefs.religiousRule, + avoidIngredientIds: prefs.avoidIngredientIds, + spiceLevelMax: prefs.spiceLevelMax, + }, + info, + ); + safety = { safe: !violations.some((v) => v.severity === "blocker"), violations }; + } + + // Varianter (spec §16) + const variants = await app.db + .select({ + id: schema.recipes.id, + titleSv: schema.recipes.titleSv, + variantType: schema.recipes.variantType, + }) + .from(schema.recipes) + .where( + and( + or( + eq(schema.recipes.variantOfRecipeId, id), + recipe.variantOfRecipeId ? eq(schema.recipes.id, recipe.variantOfRecipeId) : sql`false`, + ), + eq(schema.recipes.status, "published"), + ), + ); + + const [myRating] = await app.db + .select() + .from(schema.recipeRatings) + .where( + and(eq(schema.recipeRatings.recipeId, id), eq(schema.recipeRatings.userId, req.userId)), + ) + .limit(1); + const [favorite] = await app.db + .select() + .from(schema.recipeFavorites) + .where( + and(eq(schema.recipeFavorites.recipeId, id), eq(schema.recipeFavorites.userId, req.userId)), + ) + .limit(1); + + // Innehållsspråk (i18n-spec §13–14): publicerad översättning om användarens + // språk inte är svenska; annars svensk källa. Struktur ändras aldrig. + const languageTag = await userLanguageTag(app.db, req.userId); + const translation = await resolveRecipeTranslation(app.db, id, languageTag); + const ingredientNames = await resolveIngredientNames( + app.db, + recipe.ingredients.map((i) => i.canonicalIngredientId), + languageTag, + ); + + return { + ...recipe, + language: translation?.language ?? "sv", + title: translation?.title ?? recipe.titleSv, + description: translation ? translation.description : recipe.descriptionSv, + storageGuidance: translation ? translation.storageGuidance : recipe.storageGuidanceSv, + ingredients: recipe.ingredients.map((i) => ({ + ...i, + displayName: ingredientNames.get(i.canonicalIngredientId) ?? i.displayNameSv, + })), + steps: recipe.steps.map((s) => { + const ts = translation?.steps.get(s.stepNumber); + return { ...s, instruction: ts?.instruction ?? s.instructionSv, tip: ts?.tip ?? s.tip }; + }), + safety, + variants, + myRating: myRating ?? null, + isFavorite: Boolean(favorite), + }; + }); + + /** Skala recept (Cooking Mode: "skala till sex personer"). */ + app.get("/v1/recipes/:id/scaled", auth, async (req) => { + const { id } = parse(idParamSchema, req.params); + const portions = Number((req.query as { portions?: string }).portions ?? 4); + if (!Number.isInteger(portions) || portions < 1 || portions > 24) { + throw errors.badRequest("portions måste vara 1–24."); + } + const recipe = await loadFullRecipe(app, id); + const scaled = scaleIngredients( + recipe.ingredients.map((i) => ({ + canonicalIngredientId: i.canonicalIngredientId, + displayNameSv: i.displayNameSv, + quantity: i.quantity, + unit: i.unit, + optional: i.optional, + })), + recipe.portions, + portions, + ); + return { + recipeId: id, + portions, + ingredients: scaled, + nutritionPerPortion: recipe.nutritionPerPortion, + }; + }); + + app.post("/v1/recipes/:id/rate", auth, async (req) => { + const { id } = parse(idParamSchema, req.params); + const input = parse(rateRecipeInputSchema, req.body); + await loadFullRecipe(app, id); + + await app.db + .insert(schema.recipeRatings) + .values({ + recipeId: id, + userId: req.userId, + stars: input.stars, + feedbackTags: input.feedbackTags, + comment: input.comment ?? null, + }) + .onConflictDoUpdate({ + target: [schema.recipeRatings.recipeId, schema.recipeRatings.userId], + set: { + stars: input.stars, + feedbackTags: input.feedbackTags, + comment: input.comment ?? null, + updatedAt: new Date(), + }, + }); + + // Uppdatera aggregat + const [agg] = await app.db + .select({ + avg: sql`avg(${schema.recipeRatings.stars})`, + count: sql`count(*)`, + }) + .from(schema.recipeRatings) + .where(eq(schema.recipeRatings.recipeId, id)); + await app.db + .update(schema.recipes) + .set({ + ratingAverage: agg ? Number(agg.avg) : null, + ratingCount: agg ? Number(agg.count) : 0, + }) + .where(eq(schema.recipes.id, id)); + + // Smaksignaler ur feedback (spec §30) – explicit användarsignal. + const tagToAxis: Record< + string, + { axis: "spice" | "salt" | "acid" | "creaminess"; dir: number } + > = { + too_spicy: { axis: "spice", dir: -1 }, + too_mild: { axis: "spice", dir: 1 }, + too_salty: { axis: "salt", dir: -1 }, + too_sour: { axis: "acid", dir: -1 }, + too_little_sauce: { axis: "creaminess", dir: 1 }, + }; + for (const tag of input.feedbackTags) { + const mapping = tagToAxis[tag]; + if (mapping) { + await app.db.insert(schema.tasteSignals).values({ + userId: req.userId, + axis: mapping.axis, + direction: mapping.dir, + strength: 0.7, + origin: "user_stated", + refRecipeId: id, + }); + } + } + + await emitEvent(app.db, { + type: "RECIPE_RATED", + payload: { recipeId: id, stars: input.stars, feedbackTags: input.feedbackTags }, + userId: req.userId, + correlationId: req.correlationId, + }); + return { ok: true }; + }); + + app.post("/v1/recipes/:id/favorite", auth, async (req) => { + const { id } = parse(idParamSchema, req.params); + await app.db + .insert(schema.recipeFavorites) + .values({ recipeId: id, userId: req.userId }) + .onConflictDoNothing(); + await app.db + .update(schema.recipes) + .set({ favoriteCount: sql`${schema.recipes.favoriteCount} + 1` }) + .where(eq(schema.recipes.id, id)); + return { ok: true }; + }); + + app.delete("/v1/recipes/:id/favorite", auth, async (req) => { + const { id } = parse(idParamSchema, req.params); + await app.db + .delete(schema.recipeFavorites) + .where( + and(eq(schema.recipeFavorites.recipeId, id), eq(schema.recipeFavorites.userId, req.userId)), + ); + await app.db + .update(schema.recipes) + .set({ favoriteCount: sql`GREATEST(${schema.recipes.favoriteCount} - 1, 0)` }) + .where(eq(schema.recipes.id, id)); + return { ok: true }; + }); + + app.get("/v1/recipes/favorites/mine", auth, async (req) => { + const rows = await app.db + .select({ + id: schema.recipes.id, + titleSv: schema.recipes.titleSv, + totalTimeMinutes: schema.recipes.totalTimeMinutes, + nutritionPerPortion: schema.recipes.nutritionPerPortion, + imageUrls: schema.recipes.imageUrls, + }) + .from(schema.recipeFavorites) + .innerJoin(schema.recipes, eq(schema.recipeFavorites.recipeId, schema.recipes.id)) + .where(eq(schema.recipeFavorites.userId, req.userId)) + .orderBy(desc(schema.recipeFavorites.createdAt)); + return { recipes: rows }; + }); + + /** + * "Jag har lagat detta" (spec §23): förslag på lagerdragning (FEFO), + * måltidslogg per ätare, matlådor, events. Kärnflödet i hela appen. + */ + app.post("/v1/recipes/:id/cook", auth, async (req) => { + const { id } = parse(idParamSchema, req.params); + const input = parse(cookRecipeInputSchema, req.body); + const recipe = await loadFullRecipe(app, id); + const householdId = await requireActiveHousehold(app.db, req.userId); + const date = input.date ?? todayIso(); + + // 1. Dra lager enligt FEFO (spec §17: prioritera utgångsdatum) + const deductions: Array<{ itemId: string; quantity: number; unit: string; name: string }> = []; + if (input.deductInventory) { + const factor = input.portionsCooked / recipe.portions; + const overrides = new Map(input.inventoryOverrides.map((o) => [o.canonicalIngredientId, o])); + + for (const ing of recipe.ingredients) { + if (ing.optional) continue; + const override = overrides.get(ing.canonicalIngredientId); + const requiredQty = override ? override.quantityUsed : ing.quantity * factor; + const requiredUnit = override ? override.unit : ing.unit; + if (requiredQty <= 0) continue; + + const stock = await app.db + .select() + .from(schema.inventoryItems) + .where( + and( + eq(schema.inventoryItems.householdId, householdId), + eq(schema.inventoryItems.canonicalIngredientId, ing.canonicalIngredientId), + isNull(schema.inventoryItems.depletedAt), + gt(schema.inventoryItems.quantity, 0), + ), + ); + if (stock.length === 0) continue; + + const [info] = await app.db + .select() + .from(schema.canonicalIngredients) + .where(eq(schema.canonicalIngredients.id, ing.canonicalIngredientId)) + .limit(1); + + const allocation = allocateFefo( + requiredQty, + requiredUnit, + stock.map((s) => ({ + id: s.id, + canonicalIngredientId: s.canonicalIngredientId, + quantity: s.quantity, + unit: s.unit, + bestBeforeDate: s.bestBeforeDate, + useByDate: s.useByDate, + openedAt: s.openedAt, + frozenAt: s.frozenAt, + thawedAt: s.thawedAt, + purchasedAt: s.purchasedAt, + })), + { densityGPerMl: info?.densityGPerMl, gramsPerPiece: info?.gramsPerPiece }, + ); + + for (const alloc of allocation.allocations) { + const item = stock.find((s) => s.id === alloc.itemId)!; + const newQty = Math.max(0, Math.round((item.quantity - alloc.quantity) * 1000) / 1000); + await app.db.insert(schema.inventoryTransactions).values({ + householdId, + inventoryItemId: alloc.itemId, + type: "cook_use", + quantityDelta: -(item.quantity - newQty), + unit: item.unit, + refType: "recipe_cook", + refId: id, + actorUserId: req.userId, + }); + await app.db + .update(schema.inventoryItems) + .set({ + quantity: newQty, + depletedAt: newQty <= 0 ? new Date() : null, + updatedAt: new Date(), + }) + .where(eq(schema.inventoryItems.id, alloc.itemId)); + deductions.push({ + itemId: alloc.itemId, + quantity: alloc.quantity, + unit: alloc.unit, + name: item.displayName, + }); + } + } + } + + // 2. Logga måltid per ätare med portionsandel (spec §7: individuellt) + const eaters = + input.eaters.length > 0 ? input.eaters : [{ userId: req.userId, portionFraction: 1 }]; + const mealIds: string[] = []; + for (const eater of eaters) { + const nutrition = scaleNutrition(recipe.nutritionPerPortion, eater.portionFraction); + const [meal] = await app.db + .insert(schema.meals) + .values({ + userId: eater.userId, + householdId, + date, + mealType: input.mealType, + source: "cooked_recipe", + recipeId: id, + titleSv: recipe.titleSv, + portionFraction: eater.portionFraction, + nutrition, + nutritionIsEstimate: false, + }) + .returning(); + mealIds.push(meal!.id); + await emitEvent(app.db, { + type: "MEAL_LOGGED", + payload: { + mealId: meal!.id, + mealType: input.mealType, + kcal: nutrition.kcal, + source: "cooked_recipe", + }, + userId: eater.userId, + householdId, + correlationId: req.correlationId, + }); + } + + // 3. Matlådor (spec §24) + let mealBoxId: string | null = null; + if (input.mealBoxPortions > 0) { + const locationId = + input.mealBoxStorageLocationId ?? + ( + await app.db + .select({ id: schema.storageLocations.id }) + .from(schema.storageLocations) + .where( + and( + eq(schema.storageLocations.householdId, householdId), + eq(schema.storageLocations.type, input.mealBoxFrozen ? "freezer" : "fridge"), + ), + ) + .limit(1) + )[0]?.id; + if (!locationId) throw errors.badRequest("Ingen förvaringsplats för matlådor hittades."); + + const useByDays = input.mealBoxFrozen ? 90 : 3; + const recommendedUseBy = new Date(Date.now() + useByDays * 86_400_000) + .toISOString() + .slice(0, 10); + const [box] = await app.db + .insert(schema.mealBoxes) + .values({ + householdId, + recipeId: id, + titleSv: recipe.titleSv, + portions: input.mealBoxPortions, + portionsRemaining: input.mealBoxPortions, + nutritionPerPortion: recipe.nutritionPerPortion, + cookedAt: date, + storageLocationId: locationId, + frozen: input.mealBoxFrozen, + recommendedUseBy, + }) + .returning(); + mealBoxId = box!.id; + await emitEvent(app.db, { + type: "MEAL_BOX_CREATED", + payload: { mealBoxId: box!.id, portions: input.mealBoxPortions }, + userId: req.userId, + householdId, + correlationId: req.correlationId, + }); + } + + // 4. Statistik + event + await app.db.insert(schema.recipeCooks).values({ + recipeId: id, + userId: req.userId, + householdId, + portionsCooked: input.portionsCooked, + }); + await app.db + .update(schema.recipes) + .set({ cookCount: sql`${schema.recipes.cookCount} + 1` }) + .where(eq(schema.recipes.id, id)); + await emitEvent(app.db, { + type: "RECIPE_COOKED", + payload: { + recipeId: id, + portions: input.portionsCooked, + mealBoxPortions: input.mealBoxPortions, + }, + userId: req.userId, + householdId, + correlationId: req.correlationId, + }); + + return { ok: true, mealIds, mealBoxId, inventoryDeductions: deductions }; + }); + + /** Substitutionsförslag för en ingrediens (spec §20). */ + app.get("/v1/substitutions", auth, async (req) => { + const q = parse(substitutionQuerySchema, req.query); + const subs = await app.db + .select({ + sub: schema.substitutions, + toName: schema.canonicalIngredients.nameSv, + }) + .from(schema.substitutions) + .innerJoin( + schema.canonicalIngredients, + eq(schema.substitutions.toIngredientId, schema.canonicalIngredients.id), + ) + .where(eq(schema.substitutions.fromIngredientId, q.fromIngredientId)) + .orderBy(desc(schema.substitutions.priority)); + return { + substitutions: subs.map((s) => ({ + ...s.sub, + toNameSv: s.toName, + contextWarning: + q.context && s.sub.notRecommendedFor.includes(q.context) + ? `Rekommenderas inte för ${q.context}.` + : null, + })), + }; + }); + + /** + * Ingrediens-sök (manuell registrering, spec §9; i18n M2). + * Söker i svenska namn/alias OCH i publicerade översättningar för + * användarens språk; svaret bär namn upplöst till användarens språk. + */ + app.get("/v1/ingredients", auth, async (req) => { + const search = String((req.query as { search?: string }).search ?? "").trim(); + const languageTag = await userLanguageTag(app.db, req.userId); + const candidates = languageCandidates(languageTag); + // i18n M7: accentokänsligt (unaccent) + tolerant mot stavfel (pg_trgm). + // "creme" hittar "crème fraiche", "jordgubar" hittar "jordgubbar". + const pattern = `%${search}%`; + const translationMatch = sql`EXISTS ( + SELECT 1 FROM ingredient_translations it + WHERE it.ingredient_id = ${schema.canonicalIngredients.id} + AND it.status = 'published' + AND it.language_tag IN ${candidates} + AND (unaccent(it.name) ILIKE unaccent(${pattern}) + OR similarity(it.name, ${search}) > 0.35 + OR EXISTS (SELECT 1 FROM unnest(it.aliases) ta WHERE unaccent(ta) ILIKE unaccent(${pattern}))) + )`; + const conditions = search + ? or( + sql`unaccent(${schema.canonicalIngredients.nameSv}) ILIKE unaccent(${pattern})`, + sql`similarity(${schema.canonicalIngredients.nameSv}, ${search}) > 0.35`, + sql`EXISTS (SELECT 1 FROM unnest(${schema.canonicalIngredients.aliases}) a WHERE unaccent(a) ILIKE unaccent(${pattern}))`, + ...(candidates.includes("sv") ? [] : [translationMatch]), + ) + : undefined; + const rows = await app.db + .select({ + id: schema.canonicalIngredients.id, + nameSv: schema.canonicalIngredients.nameSv, + category: schema.canonicalIngredients.category, + defaultUnit: schema.canonicalIngredients.defaultUnit, + allergens: schema.canonicalIngredients.allergens, + }) + .from(schema.canonicalIngredients) + .where(conditions) + .orderBy( + search + ? sql`similarity(${schema.canonicalIngredients.nameSv}, ${search}) DESC, ${schema.canonicalIngredients.nameSv}` + : schema.canonicalIngredients.nameSv, + ) + .limit(30); + const names = await resolveIngredientNames( + app.db, + rows.map((r) => r.id), + languageTag, + ); + return { + ingredients: rows.map((r) => ({ ...r, name: names.get(r.id) ?? r.nameSv })), + language: candidates.includes("sv") ? "sv" : languageTag, + }; + }); + + /** + * Marknadsprofil för näringsvisning + allergenframhävning (i18n M6). + * Fallback: EU. Styr endast VISNING – säkerhetsfiltrering per användare + * (spec §61.2) påverkas aldrig av marknadsprofilen. + */ + app.get("/v1/i18n/nutrition-profile", auth, async (req) => { + const requested = String((req.query as { region?: string }).region ?? "").toUpperCase(); + const region = + requested || (await loadLocalePreferences(app.db, req.userId)).regionCode.toUpperCase(); + const [profile] = + (await app.db + .select() + .from(schema.nutritionDisplayProfiles) + .where(eq(schema.nutritionDisplayProfiles.regionCode, region)) + .limit(1)) ?? []; + const [fallback] = profile + ? [profile] + : await app.db + .select() + .from(schema.nutritionDisplayProfiles) + .where(eq(schema.nutritionDisplayProfiles.regionCode, "EU")) + .limit(1); + const effective = fallback!; + const allergens = await app.db + .select({ allergen: schema.allergenMarketRules.allergen }) + .from(schema.allergenMarketRules) + .where(eq(schema.allergenMarketRules.regionCode, effective.regionCode)); + return { + regionCode: effective.regionCode, + requestedRegion: region, + energyDisplay: effective.energyDisplay, + saltDisplay: effective.saltDisplay, + energyLabelKey: effective.energyLabelKey, + highlightAllergens: allergens.map((a) => a.allergen).sort(), + }; + }); + + /** Enhetsetiketter per språk (i18n M2) – för klienter som inte vill hårdkoda. */ + app.get("/v1/i18n/units", auth, async (req) => { + const languageTag = String((req.query as { languageTag?: string }).languageTag ?? "sv"); + const candidates = languageCandidates(languageTag); + const rows = await app.db + .select() + .from(schema.unitTranslations) + .where(inArray(schema.unitTranslations.languageTag, candidates)); + const byUnit = new Map(); + for (const candidate of [...candidates].reverse()) + for (const r of rows) if (r.languageTag === candidate) byUnit.set(r.unitCode, r); + return { + languageTag, + units: [...byUnit.values()].map((r) => ({ + unitCode: r.unitCode, + abbreviation: r.abbreviation, + name: r.name, + })), + }; + }); + + /** + * Användarrecept (spec §35): strukturerat direkt, eller fritext som AAMOS + * strukturerar. Näring/allergener beräknas ALLTID deterministiskt här. + * Publiceringsflöde: submitted → AI-kontroll → moderation → published. + */ + app.post("/v1/recipes", auth, async (req, reply) => { + await requireFeature(app.db, req.userId, "communityPublish", "Egna recept"); + const input = parse(createUserRecipeInputSchema, req.body); + const [user] = await app.db + .select({ displayName: schema.users.displayName }) + .from(schema.users) + .where(eq(schema.users.id, req.userId)) + .limit(1); + + let structured: { + titleSv: string; + descriptionSv: string; + ingredients: Array<{ + canonicalIngredientId: string; + displayNameSv: string; + quantity: number; + unit: string; + optional: boolean; + }>; + steps: Array<{ + instructionSv: string; + timerSeconds?: number | null; + temperatureC?: number | null; + }>; + prepMin: number; + cookMin: number; + portions: number; + cuisine: string; + mealTypes: string[]; + tags: string[]; + methods: string[]; + equipment: string[]; + difficulty: string; + spiceLevel: number; + }; + + if (input.mode === "free_text") { + const result = await app.aamos.runTask( + "STRUCTURE_RECIPE_TEXT", + { text: input.text, marketLocale: "sv-SE" }, + { + correlationId: req.correlationId, + localeContext: await ( + await import("../lib/localeContext.js") + ).getLocaleContext(app.db, req.userId), + }, + ); + if (result.status !== "ok" || !result.output) { + throw errors.badRequest( + "Receptet kunde inte tolkas automatiskt just nu. Prova strukturerad inmatning.", + ); + } + const out = result.output; + const ingredients = out.ingredients + .filter((i) => i.canonicalIngredientId != null && i.quantity != null && i.unit != null) + .map((i) => ({ + canonicalIngredientId: i.canonicalIngredientId!, + displayNameSv: i.displayNameSv, + quantity: i.quantity!, + unit: i.unit!, + optional: i.optional, + })); + if (ingredients.length === 0) { + throw errors.badRequest( + "Inga ingredienser kunde tolkas säkert. Komplettera och försök igen.", + ); + } + structured = { + titleSv: out.titleSv ?? "Mitt recept", + descriptionSv: out.descriptionSv ?? "", + ingredients, + steps: out.steps, + prepMin: out.prepTimeMinutes ?? 15, + cookMin: out.cookTimeMinutes ?? 20, + portions: out.portions ?? 4, + cuisine: out.suggestedCuisine ?? "international", + mealTypes: out.suggestedMealTypes.length > 0 ? out.suggestedMealTypes : ["dinner"], + tags: [], + methods: [], + equipment: [], + difficulty: "easy", + spiceLevel: 0, + }; + } else { + structured = { + titleSv: input.titleSv, + descriptionSv: input.descriptionSv, + ingredients: input.ingredients.map((i) => ({ + canonicalIngredientId: i.canonicalIngredientId, + displayNameSv: i.displayNameSv, + quantity: i.quantity, + unit: i.unit, + optional: i.optional, + })), + steps: input.steps.map((s) => ({ + instructionSv: s.instructionSv, + timerSeconds: s.timerSeconds ?? null, + temperatureC: s.temperatureC ?? null, + })), + prepMin: input.prepTimeMinutes, + cookMin: input.cookTimeMinutes, + portions: input.portions, + cuisine: input.cuisine, + mealTypes: input.mealTypes, + tags: input.tags, + methods: input.methods, + equipment: input.equipment, + difficulty: input.difficulty, + spiceLevel: input.spiceLevel, + }; + } + + // Deterministisk näring + allergener (spec §61.1–2) + const ingredientIds = structured.ingredients.map((i) => i.canonicalIngredientId); + const dbIngredients = await app.db + .select() + .from(schema.canonicalIngredients) + .where(inArray(schema.canonicalIngredients.id, ingredientIds)); + const sourceMap = new Map( + dbIngredients.map((i) => [ + i.id, + { + nutritionPer100: i.nutritionPer100, + densityGPerMl: i.densityGPerMl, + gramsPerPiece: i.gramsPerPiece, + }, + ]), + ); + const missing = ingredientIds.filter((id) => !sourceMap.has(id)); + if (missing.length > 0) { + throw errors.badRequest(`Okända ingredienser: ${missing.join(", ")}`, { missing }); + } + const nutrition = computeRecipeNutrition( + structured.ingredients.map((i) => ({ + canonicalIngredientId: i.canonicalIngredientId, + quantity: i.quantity, + unit: i.unit as never, + optional: i.optional, + })), + structured.portions, + sourceMap, + ); + const safetyInfo = await ingredientSafetyMap(app, ingredientIds); + const allergens = deriveRecipeAllergens( + structured.ingredients.filter((i) => !i.optional).map((i) => i.canonicalIngredientId), + safetyInfo, + ); + + const slug = `${structured.titleSv + .toLowerCase() + .replace(/[åä]/g, "a") + .replace(/ö/g, "o") + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, "") + .slice(0, 60)}-${Date.now().toString(36)}`; + + const [recipe] = await app.db + .insert(schema.recipes) + .values({ + slug, + titleSv: structured.titleSv, + descriptionSv: structured.descriptionSv, + cuisine: structured.cuisine as never, + mealTypes: structured.mealTypes as never, + tags: structured.tags, + methods: structured.methods, + equipment: structured.equipment, + difficulty: structured.difficulty as never, + prepTimeMinutes: structured.prepMin, + cookTimeMinutes: structured.cookMin, + totalTimeMinutes: structured.prepMin + structured.cookMin, + portions: structured.portions, + nutritionPerPortion: nutrition.perPortion, + allergens, + spiceLevel: structured.spiceLevel, + dna: { + cuisine: structured.cuisine as never, + vegetables: [], + flavorProfile: [], + spiceLevel: structured.spiceLevel, + method: (structured.methods[0] ?? "stovetop") as never, + timeMinutes: structured.prepMin + structured.cookMin, + calories: nutrition.perPortion.kcal, + proteinGrams: Math.round(nutrition.perPortion.proteinG), + }, + status: "submitted", + verificationStatus: "unverified", + sourceType: "user_generated", + creatorUserId: req.userId, + creatorDisplayName: user?.displayName ?? "Okänd", + }) + .returning(); + + await app.db.insert(schema.recipeIngredients).values( + structured.ingredients.map((i, idx) => ({ + recipeId: recipe!.id, + canonicalIngredientId: i.canonicalIngredientId, + displayNameSv: i.displayNameSv, + quantity: i.quantity, + unit: i.unit as never, + optional: i.optional, + sortOrder: idx, + })), + ); + await app.db.insert(schema.recipeSteps).values( + structured.steps.map((s, idx) => ({ + recipeId: recipe!.id, + stepNumber: idx + 1, + instructionSv: s.instructionSv, + timerSeconds: s.timerSeconds ?? null, + temperatureC: s.temperatureC ?? null, + })), + ); + + // AI-kontroll + moderering sker asynkront i workern (spec §35 steg 2–4). + await app.jobQueue.add("MODERATE_RECIPE", { + jobType: "MODERATE_RECIPE", + recipeId: recipe!.id, + correlationId: req.correlationId, + }); + + await emitEvent(app.db, { + type: "RECIPE_CREATED", + payload: { recipeId: recipe!.id, sourceType: "user_generated" }, + userId: req.userId, + correlationId: req.correlationId, + }); + + return reply.status(201).send({ + recipe, + uncomputableIngredients: nutrition.uncomputableIngredientIds, + message: "Receptet är inskickat och granskas innan publicering.", + }); + }); +} + +async function loadFullRecipe(app: FastifyInstance, id: string) { + const [recipe] = await app.db + .select() + .from(schema.recipes) + .where(eq(schema.recipes.id, id)) + .limit(1); + if (!recipe) throw errors.notFound("Receptet finns inte."); + const ingredients = await app.db + .select() + .from(schema.recipeIngredients) + .where(eq(schema.recipeIngredients.recipeId, id)) + .orderBy(schema.recipeIngredients.sortOrder); + const steps = await app.db + .select() + .from(schema.recipeSteps) + .where(eq(schema.recipeSteps.recipeId, id)) + .orderBy(schema.recipeSteps.stepNumber); + return { ...recipe, ingredients, steps }; +} + +async function ingredientSafetyMap( + app: FastifyInstance, + ids: string[], +): Promise> { + if (ids.length === 0) return new Map(); + const rows = await app.db + .select() + .from(schema.canonicalIngredients) + .where(inArray(schema.canonicalIngredients.id, ids)); + return new Map( + rows.map((r) => [ + r.id, + { + id: r.id, + allergens: r.allergens, + isVegan: r.isVegan, + isVegetarian: r.isVegetarian, + containsGluten: r.containsGluten, + containsLactose: r.containsLactose, + isPork: r.isPork, + isBeef: r.isBeef, + isAlcohol: r.isAlcohol, + }, + ]), + ); +} diff --git a/apps/api/src/routes/recommendations.ts b/apps/api/src/routes/recommendations.ts new file mode 100644 index 0000000..04ef3f6 --- /dev/null +++ b/apps/api/src/routes/recommendations.ts @@ -0,0 +1,353 @@ +import type { FastifyInstance } from "fastify"; +import { and, desc, eq, gt, inArray, isNull, sql } from "drizzle-orm"; +import { schema } from "@app/database"; +import { whatToEatQuerySchema } from "@app/validation"; +import { + computeCoverage, + checkRecipeSafety, + isRecipeSafe, + type IngredientSafetyInfo, + type PantryItem, +} from "@app/recipe-engine"; +import { + isEventActive, + parseCraving, + rankAll, + seasonForDate, + summarizeContext, + type RecommendationCandidate, + type RecommendationContext, +} from "@app/recommendation-engine"; +import { DEFAULT_TARGETS, computeDailyTargets, summarizeDay } from "@app/nutrition-engine"; +import { parse } from "../lib/errors.js"; +import { requireActiveHousehold, todayIso } from "../lib/helpers.js"; + +/** + * "Vad ska vi äta?" (spec §18) – appens viktigaste endpoint. + * + * Pipeline: + * 1. Hämta hushållets lager, medlemmarnas SAMLADE kostbegränsningar och kontext. + * 2. Deterministisk säkerhetsfiltrering (spec §61.2) – blockers försvinner. + * 3. Täckningsberäkning mot lagret + poängsättning med förklaringar. + * 4. Matlådor rekommenderas före ny matlagning när rimligt (spec §24). + * 5. (Bakom flagga) AAMOS får omranka topplistan – aldrig lägga till recept. + */ +export async function recommendationRoutes(app: FastifyInstance) { + const auth = { preHandler: [app.authenticate] }; + + app.get("/v1/recommendations/what-to-eat", auth, async (req) => { + const q = parse(whatToEatQuerySchema, req.query); + const householdId = await requireActiveHousehold(app.db, req.userId); + const today = new Date(); + + // --- 1. Kontext: lager --- + const stockRows = await app.db + .select({ + item: schema.inventoryItems, + locationType: schema.storageLocations.type, + shelfLife: schema.canonicalIngredients.shelfLifeGuidance, + density: schema.canonicalIngredients.densityGPerMl, + gramsPerPiece: schema.canonicalIngredients.gramsPerPiece, + }) + .from(schema.inventoryItems) + .innerJoin( + schema.storageLocations, + eq(schema.inventoryItems.storageLocationId, schema.storageLocations.id), + ) + .leftJoin( + schema.canonicalIngredients, + eq(schema.inventoryItems.canonicalIngredientId, schema.canonicalIngredients.id), + ) + .where( + and( + eq(schema.inventoryItems.householdId, householdId), + isNull(schema.inventoryItems.depletedAt), + gt(schema.inventoryItems.quantity, 0), + ), + ); + + const pantry: PantryItem[] = stockRows.map((r) => ({ + id: r.item.id, + canonicalIngredientId: r.item.canonicalIngredientId, + quantity: r.item.quantity, + unit: r.item.unit, + bestBeforeDate: r.item.bestBeforeDate, + useByDate: r.item.useByDate, + openedAt: r.item.openedAt, + frozenAt: r.item.frozenAt, + thawedAt: r.item.thawedAt, + purchasedAt: r.item.purchasedAt, + storageLocationType: r.locationType, + shelfLifeGuidance: r.shelfLife, + })); + const unitInfo = new Map( + stockRows + .filter((r) => r.item.canonicalIngredientId) + .map((r) => [ + r.item.canonicalIngredientId!, + { densityGPerMl: r.density, gramsPerPiece: r.gramsPerPiece }, + ]), + ); + + // --- 2. Hushållets samlade begränsningar (spec §7: strängaste gäller) --- + const members = await app.db + .select({ userId: schema.householdMembers.userId }) + .from(schema.householdMembers) + .where(eq(schema.householdMembers.householdId, householdId)); + const memberIds = members.map((m) => m.userId); + const allPrefs = await app.db + .select() + .from(schema.userPreferences) + .where(inArray(schema.userPreferences.userId, memberIds)); + + const combinedAllergens = [...new Set(allPrefs.flatMap((p) => p.allergens))]; + const combinedAvoid = [...new Set(allPrefs.flatMap((p) => p.avoidIngredientIds))]; + const strictestSpice = Math.min(...allPrefs.map((p) => p.spiceLevelMax), 5); + const myPrefs = allPrefs.find((p) => p.userId === req.userId); + + // --- 3. Kandidater: publicerade recept för måltidstypen --- + const candidates = await app.db + .select() + .from(schema.recipes) + .where( + and( + eq(schema.recipes.status, "published"), + sql`${q.mealType} = ANY(${schema.recipes.mealTypes})`, + ), + ) + .limit(200); + + const allIngredients = await app.db + .select() + .from(schema.recipeIngredients) + .where( + inArray( + schema.recipeIngredients.recipeId, + candidates.map((c) => c.id), + ), + ); + const ingredientIds = [...new Set(allIngredients.map((i) => i.canonicalIngredientId))]; + const safetyRows = await app.db + .select() + .from(schema.canonicalIngredients) + .where(inArray(schema.canonicalIngredients.id, ingredientIds)); + const safetyMap = new Map( + safetyRows.map((r) => [ + r.id, + { + id: r.id, + allergens: r.allergens, + isVegan: r.isVegan, + isVegetarian: r.isVegetarian, + containsGluten: r.containsGluten, + containsLactose: r.containsLactose, + isPork: r.isPork, + isBeef: r.isBeef, + isAlcohol: r.isAlcohol, + }, + ]), + ); + for (const r of safetyRows) { + if (!unitInfo.has(r.id)) { + unitInfo.set(r.id, { densityGPerMl: r.densityGPerMl, gramsPerPiece: r.gramsPerPiece }); + } + } + + // --- 4. Näringskontext: vad återstår av dagen? --- + const [profile] = await app.db + .select() + .from(schema.userHealthProfiles) + .where(eq(schema.userHealthProfiles.userId, req.userId)) + .limit(1); + const targets = + profile?.weightKg && profile.heightCm && profile.birthYear + ? computeDailyTargets({ + sex: profile.sex ?? "unspecified", + age: today.getUTCFullYear() - profile.birthYear, + heightCm: profile.heightCm, + weightKg: profile.weightKg, + activityLevel: profile.activityLevel, + primaryGoal: myPrefs?.primaryGoal ?? undefined, + }).targets + : DEFAULT_TARGETS; + const todaysMeals = await app.db + .select({ nutrition: schema.meals.nutrition }) + .from(schema.meals) + .where(and(eq(schema.meals.userId, req.userId), eq(schema.meals.date, todayIso()))); + const daySummary = summarizeDay( + todaysMeals.map((m) => m.nutrition), + targets, + ); + + // --- 5. Säsong & högtid (spec §28) --- + const events = await app.db + .select() + .from(schema.seasonEvents) + .where(and(eq(schema.seasonEvents.active, true), eq(schema.seasonEvents.market, "SE"))); + const activeHolidayTags = events + .filter((e) => isEventActive({ dateRule: e.dateRule, leadDays: e.leadDays }, today)) + .map((e) => e.slug); + + // --- 6. Senast lagat (variation) + hushållsbetyg --- + const cooks = await app.db + .select({ + recipeId: schema.recipeCooks.recipeId, + last: sql`max(${schema.recipeCooks.cookedAt})`, + }) + .from(schema.recipeCooks) + .where(eq(schema.recipeCooks.householdId, householdId)) + .groupBy(schema.recipeCooks.recipeId); + const lastCooked = new Map(cooks.map((c) => [c.recipeId, c.last])); + const householdRatings = await app.db + .select({ + recipeId: schema.recipeRatings.recipeId, + avg: sql`avg(${schema.recipeRatings.stars})`, + }) + .from(schema.recipeRatings) + .where(inArray(schema.recipeRatings.userId, memberIds)) + .groupBy(schema.recipeRatings.recipeId); + const householdRatingMap = new Map(householdRatings.map((r) => [r.recipeId, Number(r.avg)])); + + // --- 7. Tolka "jag är sugen på" (spec §19) --- + const craving = q.craving ? parseCraving(q.craving) : null; + + const ctx: RecommendationContext = { + mealType: q.mealType, + persons: q.persons ?? members.length, + maxMinutes: q.maxMinutes ?? myPrefs?.maxCookingMinutesWeekday ?? undefined, + maxCostMinorPerPortion: q.maxCostMinorPerPortion, + remainingProteinG: Math.max(0, daySummary.remaining.proteinG), + remainingKcal: Math.max(0, daySummary.remaining.kcal), + currentSeason: seasonForDate(today), + activeHolidayTags, + isWeekday: today.getUTCDay() >= 1 && today.getUTCDay() <= 4, + favoriteCuisines: myPrefs?.favoriteCuisines ?? [], + cravingTags: craving?.tags, + cravingCuisine: craving?.cuisine, + cravingMaxKcal: craving?.maxKcal, + }; + + // --- 8. Filtrera säkert + beräkna täckning + poängsätt --- + const scoredCandidates: RecommendationCandidate[] = []; + for (const recipe of candidates) { + const recipeIngredients = allIngredients + .filter((i) => i.recipeId === recipe.id) + .map((i) => ({ + canonicalIngredientId: i.canonicalIngredientId, + displayNameSv: i.displayNameSv, + quantity: i.quantity, + unit: i.unit, + optional: i.optional, + })); + + const violations = checkRecipeSafety( + { + ingredients: recipeIngredients.map((i) => ({ + canonicalIngredientId: i.canonicalIngredientId, + optional: i.optional, + })), + spiceLevel: recipe.spiceLevel, + }, + { + allergens: combinedAllergens, + dietPattern: myPrefs?.dietPattern, + religiousRule: myPrefs?.religiousRule, + avoidIngredientIds: combinedAvoid, + spiceLevelMax: strictestSpice, + }, + safetyMap, + ); + if (!isRecipeSafe(violations)) continue; + + const coverage = computeCoverage(recipeIngredients, pantry, unitInfo, today); + const lastDate = lastCooked.get(recipe.id); + scoredCandidates.push({ + recipeId: recipe.id, + titleSv: recipe.titleSv, + cuisine: recipe.cuisine, + tags: recipe.tags as never, + totalTimeMinutes: recipe.totalTimeMinutes, + nutritionPerPortion: recipe.nutritionPerPortion, + estimatedCostMinorPerPortion: recipe.estimatedCostMinorPerPortion, + ratingAverage: recipe.ratingAverage, + ratingCount: recipe.ratingCount, + peakSeasons: recipe.peakSeasons, + holidayTags: recipe.holidayTags, + spiceLevel: recipe.spiceLevel, + coverage, + daysSinceLastCooked: lastDate + ? Math.floor((today.getTime() - Date.parse(lastDate)) / 86_400_000) + : null, + householdRating: householdRatingMap.get(recipe.id) ?? null, + }); + } + + let recommendations = rankAll(scoredCandidates, ctx, undefined, q.limit); + + // --- 9. AAMOS-omrankning bakom feature flag (aldrig obligatorisk) --- + if (await app.flags.isEnabled("ai_rerank", req.userId)) { + const result = await app.aamos.runTask( + "RANK_RECIPES", + { + candidateIds: recommendations.map((r) => r.recipeId), + deterministicScores: Object.fromEntries( + recommendations.map((r) => [r.recipeId, r.score]), + ), + contextSummary: summarizeContext(ctx), + }, + { + correlationId: req.correlationId, + subjectRef: null, + localeContext: await ( + await import("../lib/localeContext.js") + ).getLocaleContext(app.db, req.userId), + }, + ); + if (result.status === "ok" && result.output) { + const order = new Map(result.output.rankedIds.map((id, i) => [id, i])); + recommendations = [...recommendations].sort( + (a, b) => (order.get(a.recipeId) ?? 99) - (order.get(b.recipeId) ?? 99), + ); + } + } + + // --- 10. Matlådor först när rimligt (spec §24) --- + const mealBoxes = q.includeLeftovers + ? await app.db + .select() + .from(schema.mealBoxes) + .where( + and( + eq(schema.mealBoxes.householdId, householdId), + eq(schema.mealBoxes.status, "available"), + ), + ) + .orderBy(schema.mealBoxes.recommendedUseBy) + .limit(5) + : []; + const mealBoxSuggestions = mealBoxes.map((box) => ({ + mealBoxId: box.id, + titleSv: box.titleSv, + portionsRemaining: box.portionsRemaining, + recommendedUseBy: box.recommendedUseBy, + whySv: + Date.parse(box.recommendedUseBy) <= today.getTime() + 2 * 86_400_000 + ? `Matlådan bör ätas senast ${box.recommendedUseBy}. Noll matlagning, noll svinn.` + : "Färdig mat som väntar – snabbaste middagen i huset.", + })); + + return { + mealType: q.mealType, + context: { + persons: ctx.persons, + season: ctx.currentSeason, + activeHolidays: activeHolidayTags, + remainingKcal: ctx.remainingKcal, + remainingProteinG: ctx.remainingProteinG, + craving: craving ?? null, + }, + mealBoxSuggestions, + recommendations, + }; + }); +} diff --git a/apps/api/src/routes/scans.ts b/apps/api/src/routes/scans.ts new file mode 100644 index 0000000..4b163bd --- /dev/null +++ b/apps/api/src/routes/scans.ts @@ -0,0 +1,368 @@ +import type { FastifyInstance } from "fastify"; +import { and, eq, gt, isNull } from "drizzle-orm"; +import { schema } from "@app/database"; +import type { JobType, ScanType } from "@app/shared-types"; +import { confirmScanInputSchema, createScanInputSchema, idParamSchema } from "@app/validation"; +import { errors, parse } from "../lib/errors.js"; +import { emitEvent, requireActiveHousehold } from "../lib/helpers.js"; +import { consumeAiScan } from "../lib/entitlements.js"; + +/** + * Skanningsflödet (spec §50): + * App → POST /v1/scans (kvotkontroll + presignade upload-URL:er) + * → PUT bild(er) till storage + * → POST /v1/scans/:id/start (läggs på kö → worker → AAMOS) + * → GET /v1/scans/:id (poll: status + resultat) + * → POST /v1/scans/:id/confirm (användaren godkänner → lagret uppdateras) + * + * Användarbekräftelse är obligatorisk innan något skrivs till Food Twin + * (spec §10, §61.5). Korrigeringar sparas som ai_corrections (spec §33). + */ + +const SCAN_TO_JOB: Record = { + fridge: "ANALYZE_FRIDGE_IMAGE", + freezer: "ANALYZE_FRIDGE_IMAGE", + pantry: "ANALYZE_PANTRY_IMAGE", + ingredients: "ANALYZE_PANTRY_IMAGE", + plate: "ANALYZE_MEAL_IMAGE", + receipt: "READ_RECEIPT", + barcode: "NORMALIZE_PRODUCTS", + expiry_date: "READ_EXPIRY_DATE", + nutrition_label: "READ_NUTRITION_LABEL", + product_package: "READ_NUTRITION_LABEL", +}; + +const S3_PREFIX: Partial> = { + fridge: "fridge-scans", + freezer: "fridge-scans", + pantry: "pantry-scans", + ingredients: "pantry-scans", + plate: "meal-scans", + receipt: "receipts", + expiry_date: "product-images", + nutrition_label: "product-images", + product_package: "product-images", +}; + +export async function scanRoutes(app: FastifyInstance) { + const auth = { preHandler: [app.authenticate] }; + + app.post("/v1/scans", auth, async (req, reply) => { + const input = parse(createScanInputSchema, req.body); + const householdId = await requireActiveHousehold(app.db, req.userId); + + // Streckkod är gratis uppslag utan AI – hanteras direkt (spec §11: lokalt + databas). + if (input.scanType === "barcode") { + if (!input.barcode) throw errors.badRequest("barcode krävs för streckkodsskanning."); + const product = await lookupBarcode(app, input.barcode); + const [job] = await app.db + .insert(schema.scanJobs) + .values({ + userId: req.userId, + householdId, + scanType: "barcode", + jobType: "NORMALIZE_PRODUCTS", + status: product ? "completed" : "failed", + result: product ? { product } : null, + error: product + ? null + : "Produkten hittades inte. Fota framsida + näringsdeklaration så lägger vi till den.", + completedAt: new Date(), + }) + .returning(); + return reply.status(201).send({ scan: job, product }); + } + + // AI-skanning: kvotkontroll (fair use, spec §45–46) och presignade URL:er. + await consumeAiScan(app.db, req.userId); + + const prefix = `${S3_PREFIX[input.scanType] ?? "temporary"}/${householdId}`; + const uploads = []; + for (let i = 0; i < Math.max(1, input.imageCount); i++) { + uploads.push(await app.storage.presignUpload(prefix, input.contentType)); + } + + const [job] = await app.db + .insert(schema.scanJobs) + .values({ + userId: req.userId, + householdId, + scanType: input.scanType, + jobType: SCAN_TO_JOB[input.scanType], + status: "queued", + s3Keys: uploads.map((u) => u.key), + context: input.context ?? null, + }) + .returning(); + + return reply.status(201).send({ scan: job, uploads }); + }); + + app.post("/v1/scans/:id/start", auth, async (req) => { + const { id } = parse(idParamSchema, req.params); + const job = await getOwnedScan(app, id, req.userId); + if (job.status !== "queued") throw errors.conflict(`Jobbet är redan ${job.status}.`); + + await app.jobQueue.add(job.jobType, { + scanJobId: job.id, + jobType: job.jobType, + correlationId: req.correlationId, + }); + return { ok: true, status: "queued" }; + }); + + app.get("/v1/scans/:id", auth, async (req) => { + const { id } = parse(idParamSchema, req.params); + return getOwnedScan(app, id, req.userId); + }); + + app.post("/v1/scans/:id/confirm", auth, async (req) => { + const { id } = parse(idParamSchema, req.params); + const input = parse(confirmScanInputSchema, req.body); + const job = await getOwnedScan(app, id, req.userId); + if (job.status !== "awaiting_confirmation" && job.status !== "completed") { + throw errors.conflict("Jobbet har inget resultat att bekräfta ännu."); + } + const householdId = job.householdId ?? (await requireActiveHousehold(app.db, req.userId)); + + const fallbackLocation = + input.storageLocationId ?? (await defaultLocation(app, householdId, job.scanType)); + + const created: string[] = []; + for (const item of input.items) { + if (item.action === "reject") { + await recordCorrection(app, job, item.tempId ?? null, { action: "reject" }); + continue; + } + if (item.action === "edit" || item.action === "add") { + await recordCorrection(app, job, item.tempId ?? null, { + action: item.action, + corrected: { name: item.displayName, quantity: item.quantity, unit: item.unit }, + }); + } + const locationId = item.storageLocationId ?? fallbackLocation; + if (!locationId) + throw errors.badRequest("storageLocationId saknas och ingen standardplats finns."); + + const [inv] = await app.db + .insert(schema.inventoryItems) + .values({ + householdId, + canonicalIngredientId: item.canonicalIngredientId ?? null, + displayName: item.displayName, + brand: item.brand ?? null, + quantity: item.quantity, + unit: item.unit, + storageLocationId: locationId, + sublocation: item.sublocation ?? null, + bestBeforeDate: item.bestBeforeDate ?? null, + useByDate: item.useByDate ?? null, + priceMinor: item.priceMinor ?? null, + purchasedAt: new Date().toISOString().slice(0, 10), + source: scanSource(job.scanType), + confidence: item.action === "accept" ? 0.9 : 1, + verifiedByUser: true, + lastVerifiedAt: new Date(), + modelVersion: job.modelVersion, + promptVersion: job.promptVersion, + }) + .returning(); + + await app.db.insert(schema.inventoryTransactions).values({ + householdId, + inventoryItemId: inv!.id, + type: "purchase", + quantityDelta: item.quantity, + unit: item.unit, + refType: "scan", + refId: job.id, + actorUserId: req.userId, + valueMinor: item.priceMinor ?? null, + }); + await emitEvent(app.db, { + type: "PRODUCT_ADDED", + payload: { + inventoryItemId: inv!.id, + canonicalIngredientId: item.canonicalIngredientId ?? null, + quantity: item.quantity, + unit: item.unit, + source: scanSource(job.scanType), + }, + userId: req.userId, + householdId, + correlationId: req.correlationId, + }); + created.push(inv!.id); + } + + await app.db + .update(schema.scanJobs) + .set({ status: "completed", updatedAt: new Date() }) + .where(eq(schema.scanJobs.id, id)); + + return { ok: true, createdItemIds: created }; + }); + + app.get("/v1/scans", auth, async (req) => { + const jobs = await app.db + .select() + .from(schema.scanJobs) + .where(eq(schema.scanJobs.userId, req.userId)) + .orderBy((await import("drizzle-orm")).desc(schema.scanJobs.createdAt)) + .limit(30); + return { scans: jobs }; + }); +} + +async function getOwnedScan(app: FastifyInstance, id: string, userId: string) { + const [job] = await app.db + .select() + .from(schema.scanJobs) + .where(eq(schema.scanJobs.id, id)) + .limit(1); + if (!job || job.userId !== userId) throw errors.notFound("Skanningen finns inte."); + return job; +} + +async function lookupBarcode(app: FastifyInstance, gtin: string) { + // 1. Egen produktdatabas (aktuell version) + const [own] = await app.db + .select() + .from(schema.products) + .where(and(eq(schema.products.gtin, gtin), isNull(schema.products.validTo))) + .limit(1); + if (own) return own; + + // 2. Open Food Facts (laglig öppen källa, spec §11) + const off = app.connectors.get("open-food-facts"); + if (off && "lookupBarcode" in off) { + try { + const result = await (off as { lookupBarcode(g: string): Promise }).lookupBarcode( + gtin, + ); + if (result && typeof result === "object") { + const p = result as { + gtin: string; + name?: string; + brand?: string; + ingredientsText?: string; + nutrimentsPer100g: Record; + imageUrl?: string; + }; + if (!p.name) return null; + const n = p.nutrimentsPer100g; + const [saved] = await app.db + .insert(schema.products) + .values({ + gtin: p.gtin, + name: p.name, + brand: p.brand ?? null, + ingredientsText: p.ingredientsText ?? null, + nutrition: + n.kcal != null + ? { + basis: "per_100_g", + values: { + kcal: n.kcal ?? 0, + proteinG: n.proteinG ?? 0, + carbsG: n.carbsG ?? 0, + fatG: n.fatG ?? 0, + saturatedFatG: n.saturatedFatG ?? 0, + fiberG: n.fiberG ?? 0, + sugarG: n.sugarG ?? 0, + saltG: n.saltG ?? 0, + }, + } + : null, + imageUrls: p.imageUrl ? [p.imageUrl] : [], + dataSource: "open_food_facts", + verificationStatus: "unverified", + }) + .onConflictDoNothing() + .returning(); + return saved ?? null; + } + } catch (err) { + app.log.warn({ err, gtin }, "OFF-uppslag misslyckades"); + } + } + return null; +} + +function scanSource(scanType: ScanType) { + switch (scanType) { + case "fridge": + return "fridge_photo" as const; + case "freezer": + return "freezer_photo" as const; + case "pantry": + return "pantry_photo" as const; + case "ingredients": + return "ingredient_photo" as const; + case "receipt": + return "receipt" as const; + case "barcode": + return "barcode" as const; + default: + return "label_photo" as const; + } +} + +async function defaultLocation(app: FastifyInstance, householdId: string, scanType: ScanType) { + const wanted = + scanType === "freezer" + ? "freezer" + : scanType === "pantry" || scanType === "ingredients" + ? "pantry" + : "fridge"; + const [loc] = await app.db + .select({ id: schema.storageLocations.id }) + .from(schema.storageLocations) + .where( + and( + eq(schema.storageLocations.householdId, householdId), + eq(schema.storageLocations.type, wanted), + ), + ) + .limit(1); + return loc?.id ?? null; +} + +async function recordCorrection( + app: FastifyInstance, + job: { + id: string; + userId: string; + jobType: string; + result: unknown; + modelVersion: string | null; + promptVersion: string | null; + }, + tempId: string | null, + correction: Record, +) { + const consents = await app.db + .select() + .from(schema.userConsents) + .where(eq(schema.userConsents.userId, job.userId)); + const snapshot = Object.fromEntries(consents.map((c) => [c.kind, c.status])); + await app.db.insert(schema.aiCorrections).values({ + scanJobId: job.id, + userId: job.userId, + taskType: job.jobType, + aiOutput: { tempId, raw: job.result }, + userCorrection: correction, + modelVersion: job.modelVersion, + promptVersion: job.promptVersion, + consentSnapshot: snapshot, + }); + await emitEvent(app.db, { + type: "AI_CORRECTED", + payload: { + scanJobId: job.id, + taskType: job.jobType, + field: String(correction.action ?? "unknown"), + }, + userId: job.userId, + }); +} diff --git a/apps/api/src/routes/shopping.ts b/apps/api/src/routes/shopping.ts new file mode 100644 index 0000000..afb3426 --- /dev/null +++ b/apps/api/src/routes/shopping.ts @@ -0,0 +1,406 @@ +import type { FastifyInstance } from "fastify"; +import { and, eq, gt, inArray, isNull, sql } from "drizzle-orm"; +import { schema } from "@app/database"; +import type { StoreSection } from "@app/shared-types"; +import { + addShoppingItemInputSchema, + completeShoppingInputSchema, + createShoppingListInputSchema, + idParamSchema, + updateShoppingItemInputSchema, +} from "@app/validation"; +import { convert } from "@app/nutrition-engine"; +import { errors, parse } from "../lib/errors.js"; +import { emitEvent, requireActiveHousehold, requireMembership, todayIso } from "../lib/helpers.js"; + +/** + * Inköpslista (spec §27): dra av lager, slå ihop ingredienser, sortera per + * butiksavdelning, dela i hushållet, uppdatera lagret efter köp. + */ + +const CATEGORY_TO_SECTION: Record = { + mejeri: "mejeri", + kott_fagel: "kott_fagel", + fisk: "fisk", + gronsaker: "frukt_gront", + frukt: "frukt_gront", + spannmal: "skafferi", + baljvaxter: "skafferi", + skafferi: "skafferi", + konserver: "konserver", + kryddor: "kryddor_bak", + brod: "brod", +}; + +export async function shoppingRoutes(app: FastifyInstance) { + const auth = { preHandler: [app.authenticate] }; + + app.get("/v1/shopping-lists", auth, async (req) => { + const householdId = await requireActiveHousehold(app.db, req.userId); + const lists = await app.db + .select() + .from(schema.shoppingLists) + .where( + and( + eq(schema.shoppingLists.householdId, householdId), + eq(schema.shoppingLists.status, "active"), + ), + ); + return { lists }; + }); + + app.post("/v1/shopping-lists", auth, async (req, reply) => { + const input = parse(createShoppingListInputSchema, req.body); + const householdId = await requireActiveHousehold(app.db, req.userId); + + const [list] = await app.db + .insert(schema.shoppingLists) + .values({ householdId, name: input.name, weekPlanId: input.weekPlanId ?? null }) + .returning(); + + // Generera från veckoplan: receptbehov − befintligt lager (spec §27) + if (input.generateFromPlan && input.weekPlanId) { + await generateItemsFromPlan(app, list!.id, input.weekPlanId, householdId, req.userId); + } + + const items = await app.db + .select() + .from(schema.shoppingListItems) + .where(eq(schema.shoppingListItems.shoppingListId, list!.id)); + return reply.status(201).send({ list, items }); + }); + + app.get("/v1/shopping-lists/:id", auth, async (req) => { + const { id } = parse(idParamSchema, req.params); + const list = await getOwnedList(app, id, req.userId); + const items = await app.db + .select() + .from(schema.shoppingListItems) + .where(eq(schema.shoppingListItems.shoppingListId, id)) + .orderBy(schema.shoppingListItems.storeSection, schema.shoppingListItems.sortOrder); + const estimatedTotal = items.reduce((sum, i) => sum + (i.estimatedPriceMinor ?? 0), 0); + // Prisuppskattningar härleds ur katalogens baspriser (SEK) tills per-marknads-priser (M8). + return { list, items, estimatedTotalMinor: Math.round(estimatedTotal), currency: "SEK" }; + }); + + app.post("/v1/shopping-lists/:id/items", auth, async (req, reply) => { + const { id } = parse(idParamSchema, req.params); + await getOwnedList(app, id, req.userId); + const input = parse(addShoppingItemInputSchema, req.body); + + let section = input.storeSection; + let estimatedPrice = input.estimatedPriceMinor; + if (input.canonicalIngredientId) { + const [ing] = await app.db + .select() + .from(schema.canonicalIngredients) + .where(eq(schema.canonicalIngredients.id, input.canonicalIngredientId)) + .limit(1); + if (ing) { + section = section ?? CATEGORY_TO_SECTION[ing.category] ?? "hygien_ovrigt"; + if (estimatedPrice == null && ing.defaultPriceMinorPerKg != null) { + const grams = convert(input.quantity, input.unit, "GRAM", { + densityGPerMl: ing.densityGPerMl, + gramsPerPiece: ing.gramsPerPiece, + }); + if (grams != null) + estimatedPrice = Math.round((grams / 1000) * ing.defaultPriceMinorPerKg * 10) / 10; + } + } + } + + // Slå ihop med befintlig rad för samma ingrediens (spec §27) + if (input.canonicalIngredientId) { + const [existing] = await app.db + .select() + .from(schema.shoppingListItems) + .where( + and( + eq(schema.shoppingListItems.shoppingListId, id), + eq(schema.shoppingListItems.canonicalIngredientId, input.canonicalIngredientId), + eq(schema.shoppingListItems.unit, input.unit), + eq(schema.shoppingListItems.checked, false), + ), + ) + .limit(1); + if (existing) { + const [merged] = await app.db + .update(schema.shoppingListItems) + .set({ + quantity: existing.quantity + input.quantity, + estimatedPriceMinor: + existing.estimatedPriceMinor != null && estimatedPrice != null + ? existing.estimatedPriceMinor + estimatedPrice + : (existing.estimatedPriceMinor ?? estimatedPrice ?? null), + }) + .where(eq(schema.shoppingListItems.id, existing.id)) + .returning(); + return reply.send({ item: merged, merged: true }); + } + } + + const [item] = await app.db + .insert(schema.shoppingListItems) + .values({ + shoppingListId: id, + canonicalIngredientId: input.canonicalIngredientId ?? null, + displayName: input.displayName, + quantity: input.quantity, + unit: input.unit, + storeSection: section ?? "hygien_ovrigt", + estimatedPriceMinor: estimatedPrice ?? null, + addedByUserId: req.userId, + origin: "manual", + }) + .returning(); + return reply.status(201).send({ item, merged: false }); + }); + + app.patch("/v1/shopping-lists/:id/items/:itemId", auth, async (req) => { + const params = req.params as { id: string; itemId: string }; + await getOwnedList(app, params.id, req.userId); + const input = parse(updateShoppingItemInputSchema, req.body); + const [item] = await app.db + .update(schema.shoppingListItems) + .set(input) + .where( + and( + eq(schema.shoppingListItems.id, params.itemId), + eq(schema.shoppingListItems.shoppingListId, params.id), + ), + ) + .returning(); + if (!item) throw errors.notFound(); + return item; + }); + + app.delete("/v1/shopping-lists/:id/items/:itemId", auth, async (req) => { + const params = req.params as { id: string; itemId: string }; + await getOwnedList(app, params.id, req.userId); + await app.db + .delete(schema.shoppingListItems) + .where( + and( + eq(schema.shoppingListItems.id, params.itemId), + eq(schema.shoppingListItems.shoppingListId, params.id), + ), + ); + return { ok: true }; + }); + + /** Avsluta köprundan: bockade varor in i lagret (spec §27). */ + app.post("/v1/shopping-lists/:id/complete", auth, async (req) => { + const { id } = parse(idParamSchema, req.params); + const list = await getOwnedList(app, id, req.userId); + const input = parse(completeShoppingInputSchema, req.body); + + const items = await app.db + .select() + .from(schema.shoppingListItems) + .where( + and( + eq(schema.shoppingListItems.shoppingListId, id), + eq(schema.shoppingListItems.checked, true), + ), + ); + + let added = 0; + if (input.addToInventory && items.length > 0) { + const overrides = new Map(input.storageDefaults.map((s) => [s.shoppingListItemId, s])); + const fallback = + input.defaultStorageLocationId ?? + ( + await app.db + .select({ id: schema.storageLocations.id }) + .from(schema.storageLocations) + .where( + and( + eq(schema.storageLocations.householdId, list.householdId), + eq(schema.storageLocations.type, "fridge"), + ), + ) + .limit(1) + )[0]?.id; + if (!fallback) throw errors.badRequest("Ingen standardplats (kyl) hittades i hushållet."); + + for (const item of items) { + const override = overrides.get(item.id); + const [inv] = await app.db + .insert(schema.inventoryItems) + .values({ + householdId: list.householdId, + canonicalIngredientId: item.canonicalIngredientId, + displayName: item.displayName, + quantity: item.quantity, + unit: item.unit, + storageLocationId: override?.storageLocationId ?? fallback, + purchasedAt: todayIso(), + bestBeforeDate: override?.bestBeforeDate ?? null, + priceMinor: override?.priceMinor ?? item.estimatedPriceMinor, + source: "manual_search", + confidence: 1, + verifiedByUser: true, + lastVerifiedAt: new Date(), + }) + .returning(); + await app.db.insert(schema.inventoryTransactions).values({ + householdId: list.householdId, + inventoryItemId: inv!.id, + type: "purchase", + quantityDelta: item.quantity, + unit: item.unit, + refType: "shopping", + refId: id, + actorUserId: req.userId, + valueMinor: override?.priceMinor ?? item.estimatedPriceMinor, + }); + added += 1; + } + } + + await app.db + .update(schema.shoppingLists) + .set({ status: "completed", updatedAt: new Date() }) + .where(eq(schema.shoppingLists.id, id)); + + await emitEvent(app.db, { + type: "SHOPPING_COMPLETED", + payload: { shoppingListId: id, itemsAdded: added }, + userId: req.userId, + householdId: list.householdId, + correlationId: req.correlationId, + }); + return { ok: true, itemsAddedToInventory: added }; + }); +} + +async function getOwnedList(app: FastifyInstance, listId: string, userId: string) { + const [list] = await app.db + .select() + .from(schema.shoppingLists) + .where(eq(schema.shoppingLists.id, listId)) + .limit(1); + if (!list) throw errors.notFound("Listan finns inte."); + await requireMembership(app.db, list.householdId, userId); + return list; +} + +/** Aggregera receptbehov från plan, dra av befintligt lager, skapa rader. */ +async function generateItemsFromPlan( + app: FastifyInstance, + listId: string, + weekPlanId: string, + householdId: string, + userId: string, +) { + const entries = await app.db + .select() + .from(schema.weekPlanEntries) + .where( + and( + eq(schema.weekPlanEntries.weekPlanId, weekPlanId), + eq(schema.weekPlanEntries.status, "planned"), + ), + ); + + const recipeIds = [...new Set(entries.filter((e) => e.recipeId).map((e) => e.recipeId!))]; + if (recipeIds.length === 0) return; + + const allIngredients = await app.db + .select() + .from(schema.recipeIngredients) + .where(inArray(schema.recipeIngredients.recipeId, recipeIds)); + const recipes = await app.db + .select({ id: schema.recipes.id, portions: schema.recipes.portions }) + .from(schema.recipes) + .where(inArray(schema.recipes.id, recipeIds)); + const portionsMap = new Map(recipes.map((r) => [r.id, r.portions])); + + // Aggregera behov per ingrediens (i gram där möjligt) + const needs = new Map(); + const infoRows = await app.db + .select() + .from(schema.canonicalIngredients) + .where( + inArray(schema.canonicalIngredients.id, [ + ...new Set(allIngredients.map((i) => i.canonicalIngredientId)), + ]), + ); + const infoMap = new Map(infoRows.map((r) => [r.id, r])); + + for (const entry of entries) { + if (!entry.recipeId) continue; + const basePortions = portionsMap.get(entry.recipeId) ?? 4; + const factor = entry.portions / basePortions; + for (const ing of allIngredients.filter((i) => i.recipeId === entry.recipeId && !i.optional)) { + const info = infoMap.get(ing.canonicalIngredientId); + const grams = convert(ing.quantity * factor, ing.unit, "GRAM", { + densityGPerMl: info?.densityGPerMl, + gramsPerPiece: info?.gramsPerPiece, + }); + if (grams == null) continue; + const current = needs.get(ing.canonicalIngredientId) ?? { name: ing.displayNameSv, grams: 0 }; + current.grams += grams; + needs.set(ing.canonicalIngredientId, current); + } + } + + // Dra av lager + const stock = await app.db + .select() + .from(schema.inventoryItems) + .where( + and( + eq(schema.inventoryItems.householdId, householdId), + isNull(schema.inventoryItems.depletedAt), + gt(schema.inventoryItems.quantity, 0), + inArray(schema.inventoryItems.canonicalIngredientId, [...needs.keys()]), + ), + ); + for (const item of stock) { + if (!item.canonicalIngredientId) continue; + const need = needs.get(item.canonicalIngredientId); + if (!need) continue; + const info = infoMap.get(item.canonicalIngredientId); + const grams = convert(item.quantity, item.unit, "GRAM", { + densityGPerMl: info?.densityGPerMl, + gramsPerPiece: info?.gramsPerPiece, + }); + if (grams != null) need.grams = Math.max(0, need.grams - grams); + } + + // Skapa rader för det som saknas + let sortOrder = 0; + for (const [ingredientId, need] of needs) { + if (need.grams < 5) continue; + const info = infoMap.get(ingredientId); + const section = info + ? (CATEGORY_TO_SECTION[info.category] ?? "hygien_ovrigt") + : "hygien_ovrigt"; + // Konvertera tillbaka till naturlig enhet + const targetUnit = info?.defaultUnit ?? "GRAM"; + const qty = + convert(need.grams, "GRAM", targetUnit, { + densityGPerMl: info?.densityGPerMl, + gramsPerPiece: info?.gramsPerPiece, + }) ?? need.grams; + const rounded = targetUnit === "COUNT" ? Math.ceil(qty) : Math.ceil(qty * 10) / 10; + const estimatedPrice = + info?.defaultPriceMinorPerKg != null + ? Math.round((need.grams / 1000) * info.defaultPriceMinorPerKg * 10) / 10 + : null; + + await app.db.insert(schema.shoppingListItems).values({ + shoppingListId: listId, + canonicalIngredientId: ingredientId, + displayName: need.name, + quantity: rounded, + unit: targetUnit, + storeSection: section, + estimatedPriceMinor: estimatedPrice, + addedByUserId: userId, + origin: "plan", + sortOrder: sortOrder++, + }); + } +} diff --git a/apps/api/src/routes/subscriptions.ts b/apps/api/src/routes/subscriptions.ts new file mode 100644 index 0000000..b9ade24 --- /dev/null +++ b/apps/api/src/routes/subscriptions.ts @@ -0,0 +1,146 @@ +import type { FastifyInstance } from "fastify"; +import { eq } from "drizzle-orm"; +import { schema } from "@app/database"; +import { verifyPurchaseInputSchema } from "@app/validation"; +import { errors, parse } from "../lib/errors.js"; +import { audit, emitEvent, getActiveHouseholdId } from "../lib/helpers.js"; +import { loadEntitlementsWithToken } from "../lib/entitlements.js"; + +/** + * Prenumerationer (spec §45–47, Del 13). + * Backend verifierar ALLTID mot butiken och är source of truth (spec §61.14). + * Webhooks tas emot råa och processas asynkront av workern. + */ +export async function subscriptionRoutes(app: FastifyInstance) { + const auth = { preHandler: [app.authenticate] }; + + app.post("/v1/subscriptions/verify", auth, async (req) => { + const input = parse(verifyPurchaseInputSchema, req.body); + + const result = + input.provider === "apple" + ? await app.storeVerifier.verifyApple(input.signedTransaction) + : await app.storeVerifier.verifyGoogle( + input.packageName, + input.productId, + input.purchaseToken, + ); + + if (!result.ok) throw errors.badRequest(`Kunde inte verifiera köpet: ${result.error}`); + const purchase = result.purchase; + const householdId = await getActiveHouseholdId(app.db, req.userId); + + // Idempotent på originalTransactionId + const [existing] = await app.db + .select() + .from(schema.subscriptions) + .where(eq(schema.subscriptions.originalTransactionId, purchase.originalTransactionId)) + .limit(1); + + let subscriptionId: string; + if (existing) { + if (existing.userId !== req.userId) { + throw errors.conflict( + "Det här köpet är kopplat till ett annat konto. Använd Återställ köp på rätt konto.", + ); + } + const [updated] = await app.db + .update(schema.subscriptions) + .set({ + status: purchase.status, + expiresAt: purchase.expiresAt, + plan: purchase.plan, + productId: purchase.productId, + lastVerifiedAt: new Date(), + updatedAt: new Date(), + }) + .where(eq(schema.subscriptions.id, existing.id)) + .returning(); + subscriptionId = updated!.id; + } else { + const [created] = await app.db + .insert(schema.subscriptions) + .values({ + userId: req.userId, + householdId, + provider: purchase.provider, + productId: purchase.productId, + plan: purchase.plan, + originalTransactionId: purchase.originalTransactionId, + status: purchase.status, + purchasedAt: purchase.purchasedAt, + expiresAt: purchase.expiresAt, + lastVerifiedAt: new Date(), + }) + .returning(); + subscriptionId = created!.id; + await emitEvent(app.db, { + type: "SUBSCRIPTION_STARTED", + payload: { subscriptionId, plan: purchase.plan, provider: purchase.provider }, + userId: req.userId, + householdId: householdId ?? undefined, + correlationId: req.correlationId, + }); + } + + await app.db.insert(schema.subscriptionEvents).values({ + subscriptionId, + userId: req.userId, + eventType: existing ? "verified" : "purchased", + payload: { productId: purchase.productId }, + }); + await audit(app.db, { + actorUserId: req.userId, + action: "subscription.verified", + targetType: "subscription", + targetId: subscriptionId, + }); + + return { ok: true, entitlements: await loadEntitlementsWithToken(app, req.userId) }; + }); + + app.post("/v1/subscriptions/restore", auth, async (req) => { + // Restore = samma flöde som verify; klienten skickar aktuellt kvitto/token. + return { + ok: true, + message: "Skicka aktuellt kvitto till /v1/subscriptions/verify så återställs köpet.", + }; + }); + + /** + * App Store Server Notifications V2 (spec §47). + * Signaturverifiering av JWS sker i workern (PROCESS_STORE_NOTIFICATION). + */ + app.post( + "/v1/subscriptions/webhooks/apple", + { config: { rateLimit: false } }, + async (req, reply) => { + const [row] = await app.db + .insert(schema.storeNotifications) + .values({ provider: "apple", rawPayload: (req.body ?? {}) as Record }) + .returning(); + await app.jobQueue.add("PROCESS_STORE_NOTIFICATION", { + jobType: "PROCESS_STORE_NOTIFICATION", + notificationId: row!.id, + }); + return reply.status(200).send({ ok: true }); + }, + ); + + /** Google Play Real-time Developer Notifications (via Pub/Sub push). */ + app.post( + "/v1/subscriptions/webhooks/google", + { config: { rateLimit: false } }, + async (req, reply) => { + const [row] = await app.db + .insert(schema.storeNotifications) + .values({ provider: "google", rawPayload: (req.body ?? {}) as Record }) + .returning(); + await app.jobQueue.add("PROCESS_STORE_NOTIFICATION", { + jobType: "PROCESS_STORE_NOTIFICATION", + notificationId: row!.id, + }); + return reply.status(200).send({ ok: true }); + }, + ); +} diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts new file mode 100644 index 0000000..416243b --- /dev/null +++ b/apps/api/src/server.ts @@ -0,0 +1,85 @@ +import Fastify from "fastify"; +import cors from "@fastify/cors"; +import rateLimit from "@fastify/rate-limit"; +import type { AppConfig } from "./config.js"; +import { corePlugin } from "./plugins/core.js"; +import { authPlugin } from "./plugins/auth.js"; +import { storagePlugin } from "./plugins/storage.js"; +import { healthRoutes } from "./routes/health.js"; +import { authRoutes } from "./routes/auth.js"; +import { meRoutes } from "./routes/me.js"; +import { householdRoutes } from "./routes/households.js"; +import { inventoryRoutes } from "./routes/inventory.js"; +import { scanRoutes } from "./routes/scans.js"; +import { recipeRoutes } from "./routes/recipes.js"; +import { mealRoutes } from "./routes/meals.js"; +import { shoppingRoutes } from "./routes/shopping.js"; +import { planningRoutes } from "./routes/planning.js"; +import { recommendationRoutes } from "./routes/recommendations.js"; +import { memoryRoutes } from "./routes/memory.js"; +import { budgetRoutes } from "./routes/budget.js"; +import { subscriptionRoutes } from "./routes/subscriptions.js"; +import { communityRoutes } from "./routes/community.js"; +import { adminRoutes } from "./routes/admin.js"; + +declare module "fastify" { + interface FastifyInstance { + routeCatalog: Array<{ method: string; url: string }>; + } +} + +export async function buildServer(config: AppConfig) { + const app = Fastify({ + logger: { + level: config.LOG_LEVEL, + redact: ["req.headers.authorization", "req.headers.cookie"], + }, + trustProxy: true, + bodyLimit: 1024 * 1024, + }); + + // Endpointkatalog för /docs – hooken måste ligga före route-registreringen. + const routeCatalog: Array<{ method: string; url: string }> = []; + app.decorate("routeCatalog", routeCatalog); + app.addHook("onRoute", (route) => { + const methods = Array.isArray(route.method) ? route.method : [route.method]; + for (const method of methods) { + if (method === "HEAD" || method === "OPTIONS") continue; + routeCatalog.push({ method, url: route.url }); + } + }); + + await app.register(corePlugin, { config }); + await app.register(cors, { + origin: config.CORS_ORIGINS.split(",").map((o) => o.trim()), + credentials: true, + }); + await app.register(rateLimit, { + global: true, + // Överstyrbar för lasttest (scripts/loadtest.mjs) – produktion använder default 300/min. + max: Number(process.env.RATE_LIMIT_MAX ?? 300), + timeWindow: "1 minute", + keyGenerator: (req) => `${req.ip}`, + }); + await app.register(authPlugin); + await app.register(storagePlugin); + + await app.register(healthRoutes); + await app.register(authRoutes); + await app.register(meRoutes); + await app.register(householdRoutes); + await app.register(inventoryRoutes); + await app.register(scanRoutes); + await app.register(recipeRoutes); + await app.register(mealRoutes); + await app.register(shoppingRoutes); + await app.register(planningRoutes); + await app.register(recommendationRoutes); + await app.register(memoryRoutes); + await app.register(budgetRoutes); + await app.register(subscriptionRoutes); + await app.register(communityRoutes); + await app.register(adminRoutes); + + return app; +} diff --git a/apps/api/test/unit.test.ts b/apps/api/test/unit.test.ts new file mode 100644 index 0000000..bdcdbec --- /dev/null +++ b/apps/api/test/unit.test.ts @@ -0,0 +1,187 @@ +import { describe, expect, it } from "vitest"; +import { hashPassword, verifyPassword } from "../src/lib/passwords.js"; +import { generateInviteCode, sha256 } from "../src/lib/helpers.js"; +import { parse, ApiError } from "../src/lib/errors.js"; +import { registerInputSchema, confirmScanInputSchema } from "@app/validation"; + +describe("lösenordshantering (scrypt)", () => { + it("hashar och verifierar", async () => { + const hash = await hashPassword("korrekt häst batteri 99"); + expect(hash.startsWith("scrypt$")).toBe(true); + expect(await verifyPassword("korrekt häst batteri 99", hash)).toBe(true); + expect(await verifyPassword("fel lösenord", hash)).toBe(false); + }); + it("unika salter ger olika hashar", async () => { + const a = await hashPassword("samma lösenord!"); + const b = await hashPassword("samma lösenord!"); + expect(a).not.toBe(b); + expect(await verifyPassword("samma lösenord!", a)).toBe(true); + expect(await verifyPassword("samma lösenord!", b)).toBe(true); + }); + it("hanterar trasig lagrad hash utan att kasta", async () => { + expect(await verifyPassword("x", "inte-en-hash")).toBe(false); + expect(await verifyPassword("x", "scrypt$abc$8$1$AA$BB")).toBe(false); + }); +}); + +describe("hjälpare", () => { + it("inbjudningskoder: 8 tecken utan förväxlingsbara", () => { + for (let i = 0; i < 20; i++) { + const code = generateInviteCode(); + expect(code).toHaveLength(8); + expect(code).not.toMatch(/[01IO]/); + } + }); + it("sha256 är deterministisk", () => { + expect(sha256("abc")).toBe(sha256("abc")); + expect(sha256("abc")).toHaveLength(64); + }); +}); + +describe("validering via parse()", () => { + it("registrering: normaliserar e-post, kräver 10 teckens lösenord", () => { + const result = parse(registerInputSchema, { + email: " Johan@Example.COM", + password: "supersäkert lösen", + displayName: "Johan", + }); + expect(result.email).toBe("johan@example.com"); + expect(() => + parse(registerInputSchema, { email: "a@b.se", password: "kort", displayName: "X" }), + ).toThrowError(ApiError); + }); + it("scan-bekräftelse: avvisar ogiltiga enheter", () => { + expect(() => + parse(confirmScanInputSchema, { + items: [{ action: "accept", displayName: "Mjölk", quantity: 1, unit: "gallon" }], + }), + ).toThrowError(ApiError); + }); + it("ApiError bär status och kod", () => { + try { + parse(registerInputSchema, {}); + } catch (err) { + expect(err).toBeInstanceOf(ApiError); + expect((err as ApiError).statusCode).toBe(400); + expect((err as ApiError).code).toBe("VALIDATION_ERROR"); + } + }); +}); + +describe("lösenordsåterställning (mejlmallar + tokensäkerhet)", () => { + it("renderar återställningsmejl per språk med brand ur konfig", async () => { + const { renderMail } = await import("../src/lib/mailer.js"); + const sv = renderMail("auth.password_reset", "sv-SE", { + email: "a@b.se", + link: "app://reset-password?token=x", + }); + const en = renderMail("auth.password_reset", "en-US", { + email: "a@b.se", + link: "app://reset-password?token=x", + }); + expect(sv.subject).toContain("Återställ"); + expect(en.subject).toContain("Reset"); + expect(sv.text).toContain("30 minuter"); + expect(en.text).toContain("30 minutes"); + // Namnet kommer ur brand.config.json – aldrig hårdkodat i mallen. + const { BRAND } = await import("@app/shared-types"); + expect(sv.subject).toContain(BRAND.name); + }); + + it("alla tolv språk har mallar; okänt språk faller till svenska; okänd mall kastar", async () => { + const { renderMail } = await import("../src/lib/mailer.js"); + const subjects = { + "sv-SE": "Återställ", + "en-US": "Reset", + "es-ES": "Restablece", + "it-IT": "Reimposta", + "de-DE": "Setze", + "fr-FR": "Réinitialisez", + "da-DK": "Nulstil", + "nb-NO": "Tilbakestill", + "fi-FI": "Nollaa", + "nl-NL": "wachtwoord opnieuw", + "pl-PL": "Zresetuj", + "pt-PT": "Reponha", + }; + for (const [tag, word] of Object.entries(subjects)) { + expect(renderMail("auth.password_reset", tag, { email: "x", link: "y" }).subject).toContain( + word, + ); + } + const unknown = renderMail("auth.password_reset", "el-GR", { email: "x", link: "y" }); + expect(unknown.subject).toContain("Återställ"); // ostött språk -> sv + expect(() => renderMail("finns.inte", "sv", {})).toThrow(); + }); + + it("återställningstoken lagras aldrig i klartext (sha256 är envägs)", () => { + const token = "a".repeat(64); + const hash = sha256(token); + expect(hash).not.toBe(token); + expect(hash).toHaveLength(64); + expect(sha256(token)).toBe(hash); // deterministisk – uppslag fungerar + expect(sha256("b".repeat(64))).not.toBe(hash); + }); + + it("log-mailern skickar aldrig på riktigt; okonfigurerad SMTP vägrar högljutt", async () => { + const { createMailer } = await import("../src/lib/mailer.js"); + await expect( + createMailer("log").send({ to: "x@y.se", subject: "t", text: "b" }), + ).resolves.toBeUndefined(); + // smtp utan inställningar/host/avsändare -> tydligt fel direkt (aldrig tyst tappade mejl) + expect(() => createMailer("smtp")).toThrow(/SMTP/); + expect(() => + createMailer("smtp", { + host: "", + port: 587, + secure: false, + user: "", + pass: "", + from: "x@y.se", + }), + ).toThrow(/SMTP_HOST/); + expect(() => + createMailer("smtp", { + host: "smtp.x.se", + port: 587, + secure: false, + user: "", + pass: "", + from: "", + }), + ).toThrow(/MAIL_FROM/); + }); +}); + +describe("TOTP (RFC 6238) – admin-2FA utan externa beroenden", () => { + it("klarar RFC 6238-testvektorn (SHA1, 6 siffror)", async () => { + const { base32Encode, totpCode } = await import("../src/lib/totp.js"); + // RFC 6238 bilaga B: secret "12345678901234567890", T=59 s → 6 sista siffrorna av 94287082. + const secret = base32Encode(Buffer.from("12345678901234567890", "ascii")); + expect(totpCode(secret, 59_000)).toBe("287082"); + expect(totpCode(secret, 1_111_111_109_000)).toBe("081804"); + }); + + it("verifierar med ±1 stegs fönster men inte längre bort", async () => { + const { generateTotpSecret, totpCode, verifyTotp } = await import("../src/lib/totp.js"); + const secret = generateTotpSecret(); + const now = 1_700_000_000_000; + const code = totpCode(secret, now); + expect(verifyTotp(secret, code, now)).not.toBeNull(); + expect(verifyTotp(secret, code, now + 30_000)).not.toBeNull(); // föregående steg ok + expect(verifyTotp(secret, code, now + 90_000)).toBeNull(); // för gammalt + expect(verifyTotp(secret, "000000", now)).toBeNull(); + expect(verifyTotp(secret, "12345", now)).toBeNull(); // fel format + }); + + it("base32 rundtur + otpauth-URL utan hårdkodat namn", async () => { + const { base32Decode, base32Encode, otpauthUrl } = await import("../src/lib/totp.js"); + const buf = Buffer.from("hemlig-nyckel-123", "ascii"); + expect(base32Decode(base32Encode(buf)).equals(buf)).toBe(true); + const { BRAND } = await import("@app/shared-types"); + const url = otpauthUrl("ABC234", "admin@example.com", `${BRAND.name} Admin`); + expect(url.startsWith("otpauth://totp/")).toBe(true); + expect(url).toContain("secret=ABC234"); + expect(url).toContain(encodeURIComponent(BRAND.name)); + }); +}); diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json new file mode 100644 index 0000000..9d3fdee --- /dev/null +++ b/apps/api/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src", "test"], + "compilerOptions": { + "types": ["node"] + } +} diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts new file mode 100644 index 0000000..67e520f --- /dev/null +++ b/apps/mobile/app.config.ts @@ -0,0 +1,46 @@ +// Expo-konfiguration – varumärket läses från brand.config.json (i18n-spec §28). +import type { ConfigContext, ExpoConfig } from "expo/config"; +import brand from "../../brand.config.json"; + +export default ({ config }: ConfigContext): ExpoConfig => ({ + ...config, + name: brand.name, + slug: brand.slug, + version: "0.1.0", + orientation: "portrait", + icon: "./assets/images/icon.png", + scheme: brand.urlScheme, + userInterfaceStyle: "automatic", + ios: { + bundleIdentifier: brand.iosBundleId, + supportsTablet: false, + infoPlist: { + NSCameraUsageDescription: `${brand.name} använder kameran för att skanna kyl, skafferi, kvitton, streckkoder och tallrikar så att ditt matlager hålls uppdaterat.`, + NSPhotoLibraryUsageDescription: `${brand.name} kan läsa bilder du väljer för att analysera mat och kvitton.`, + }, + }, + android: { + package: brand.androidPackage, + permissions: ["CAMERA"], + adaptiveIcon: { + backgroundColor: "#0F2417", + foregroundImage: "./assets/images/android-icon-foreground.png", + }, + }, + plugins: [ + "expo-router", + "expo-secure-store", + [ + "expo-splash-screen", + { backgroundColor: "#0F2417", image: "./assets/images/splash-icon.png", imageWidth: 96 }, + ], + [ + "expo-camera", + { + cameraPermission: `${brand.name} använder kameran för att skanna mat, kvitton och streckkoder.`, + }, + ], + ], + experiments: { typedRoutes: false }, + extra: { apiBaseUrl: process.env.API_BASE_URL ?? "http://localhost:4000" }, +}); diff --git a/apps/mobile/assets/expo.icon/Assets/expo-symbol 2.svg b/apps/mobile/assets/expo.icon/Assets/expo-symbol 2.svg new file mode 100644 index 0000000..51d3676 --- /dev/null +++ b/apps/mobile/assets/expo.icon/Assets/expo-symbol 2.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/assets/expo.icon/Assets/grid.png b/apps/mobile/assets/expo.icon/Assets/grid.png new file mode 100644 index 0000000..eefea24 Binary files /dev/null and b/apps/mobile/assets/expo.icon/Assets/grid.png differ diff --git a/apps/mobile/assets/expo.icon/icon.json b/apps/mobile/assets/expo.icon/icon.json new file mode 100644 index 0000000..9a26f70 --- /dev/null +++ b/apps/mobile/assets/expo.icon/icon.json @@ -0,0 +1,35 @@ +{ + "fill": { + "automatic-gradient": "extended-srgb:0.00000,0.47843,1.00000,1.00000" + }, + "groups": [ + { + "layers": [ + { + "image-name": "expo-symbol 2.svg", + "name": "expo-symbol 2", + "position": { + "scale": 1, + "translation-in-points": [1.1008400065293245e-5, -16.046875] + } + }, + { + "image-name": "grid.png", + "name": "grid" + } + ], + "shadow": { + "kind": "neutral", + "opacity": 0.5 + }, + "translucency": { + "enabled": true, + "value": 0.5 + } + } + ], + "supported-platforms": { + "circles": ["watchOS"], + "squares": "shared" + } +} diff --git a/apps/mobile/assets/images/android-icon-background.png b/apps/mobile/assets/images/android-icon-background.png new file mode 100644 index 0000000..5ffefc5 Binary files /dev/null and b/apps/mobile/assets/images/android-icon-background.png differ diff --git a/apps/mobile/assets/images/android-icon-foreground.png b/apps/mobile/assets/images/android-icon-foreground.png new file mode 100644 index 0000000..3a9e501 Binary files /dev/null and b/apps/mobile/assets/images/android-icon-foreground.png differ diff --git a/apps/mobile/assets/images/android-icon-monochrome.png b/apps/mobile/assets/images/android-icon-monochrome.png new file mode 100644 index 0000000..77484eb Binary files /dev/null and b/apps/mobile/assets/images/android-icon-monochrome.png differ diff --git a/apps/mobile/assets/images/expo-badge-white.png b/apps/mobile/assets/images/expo-badge-white.png new file mode 100644 index 0000000..2863067 Binary files /dev/null and b/apps/mobile/assets/images/expo-badge-white.png differ diff --git a/apps/mobile/assets/images/expo-badge.png b/apps/mobile/assets/images/expo-badge.png new file mode 100644 index 0000000..5d5c5bb Binary files /dev/null and b/apps/mobile/assets/images/expo-badge.png differ diff --git a/apps/mobile/assets/images/expo-logo.png b/apps/mobile/assets/images/expo-logo.png new file mode 100644 index 0000000..6b1642a Binary files /dev/null and b/apps/mobile/assets/images/expo-logo.png differ diff --git a/apps/mobile/assets/images/favicon.png b/apps/mobile/assets/images/favicon.png new file mode 100644 index 0000000..408bd74 Binary files /dev/null and b/apps/mobile/assets/images/favicon.png differ diff --git a/apps/mobile/assets/images/icon.png b/apps/mobile/assets/images/icon.png new file mode 100644 index 0000000..67c777a Binary files /dev/null and b/apps/mobile/assets/images/icon.png differ diff --git a/apps/mobile/assets/images/logo-glow.png b/apps/mobile/assets/images/logo-glow.png new file mode 100644 index 0000000..edc99be Binary files /dev/null and b/apps/mobile/assets/images/logo-glow.png differ diff --git a/apps/mobile/assets/images/react-logo.png b/apps/mobile/assets/images/react-logo.png new file mode 100644 index 0000000..9d72a9f Binary files /dev/null and b/apps/mobile/assets/images/react-logo.png differ diff --git a/apps/mobile/assets/images/react-logo@2x.png b/apps/mobile/assets/images/react-logo@2x.png new file mode 100644 index 0000000..2229b13 Binary files /dev/null and b/apps/mobile/assets/images/react-logo@2x.png differ diff --git a/apps/mobile/assets/images/react-logo@3x.png b/apps/mobile/assets/images/react-logo@3x.png new file mode 100644 index 0000000..a99b203 Binary files /dev/null and b/apps/mobile/assets/images/react-logo@3x.png differ diff --git a/apps/mobile/assets/images/splash-icon.png b/apps/mobile/assets/images/splash-icon.png new file mode 100644 index 0000000..6b1642a Binary files /dev/null and b/apps/mobile/assets/images/splash-icon.png differ diff --git a/apps/mobile/assets/images/tabIcons/explore.png b/apps/mobile/assets/images/tabIcons/explore.png new file mode 100644 index 0000000..73d8258 Binary files /dev/null and b/apps/mobile/assets/images/tabIcons/explore.png differ diff --git a/apps/mobile/assets/images/tabIcons/explore@2x.png b/apps/mobile/assets/images/tabIcons/explore@2x.png new file mode 100644 index 0000000..21b9bd2 Binary files /dev/null and b/apps/mobile/assets/images/tabIcons/explore@2x.png differ diff --git a/apps/mobile/assets/images/tabIcons/explore@3x.png b/apps/mobile/assets/images/tabIcons/explore@3x.png new file mode 100644 index 0000000..422202d Binary files /dev/null and b/apps/mobile/assets/images/tabIcons/explore@3x.png differ diff --git a/apps/mobile/assets/images/tabIcons/home.png b/apps/mobile/assets/images/tabIcons/home.png new file mode 100644 index 0000000..ad5699c Binary files /dev/null and b/apps/mobile/assets/images/tabIcons/home.png differ diff --git a/apps/mobile/assets/images/tabIcons/home@2x.png b/apps/mobile/assets/images/tabIcons/home@2x.png new file mode 100644 index 0000000..22a1f2c Binary files /dev/null and b/apps/mobile/assets/images/tabIcons/home@2x.png differ diff --git a/apps/mobile/assets/images/tabIcons/home@3x.png b/apps/mobile/assets/images/tabIcons/home@3x.png new file mode 100644 index 0000000..f5d1f9a Binary files /dev/null and b/apps/mobile/assets/images/tabIcons/home@3x.png differ diff --git a/apps/mobile/assets/images/tutorial-web.png b/apps/mobile/assets/images/tutorial-web.png new file mode 100644 index 0000000..e4a8c58 Binary files /dev/null and b/apps/mobile/assets/images/tutorial-web.png differ diff --git a/apps/mobile/eas.json b/apps/mobile/eas.json new file mode 100644 index 0000000..c21eae6 --- /dev/null +++ b/apps/mobile/eas.json @@ -0,0 +1,33 @@ +{ + "cli": { + "version": ">= 13.0.0", + "appVersionSource": "remote" + }, + "build": { + "development": { + "developmentClient": true, + "distribution": "internal", + "env": { "API_BASE_URL": "http://localhost:4000" } + }, + "preview": { + "distribution": "internal", + "channel": "preview", + "env": { "API_BASE_URL": "https://api.example.com" } + }, + "production": { + "autoIncrement": true, + "channel": "production", + "env": { "API_BASE_URL": "https://api.example.com" } + } + }, + "submit": { + "production": { + "ios": { + "_kommentar": "Fylls i när butikskonto + namn finns: ascAppId, appleTeamId." + }, + "android": { + "_kommentar": "Fylls i när Play-konto finns: serviceAccountKeyPath, track." + } + } + } +} diff --git a/apps/mobile/metro.config.js b/apps/mobile/metro.config.js new file mode 100644 index 0000000..5e69694 --- /dev/null +++ b/apps/mobile/metro.config.js @@ -0,0 +1,16 @@ +// Metro-konfiguration för pnpm-monorepo (Expo-dokumenterat mönster). +const { getDefaultConfig } = require("expo/metro-config"); +const path = require("path"); + +const projectRoot = __dirname; +const workspaceRoot = path.resolve(projectRoot, "../.."); + +const config = getDefaultConfig(projectRoot); + +config.watchFolders = [workspaceRoot]; +config.resolver.nodeModulesPaths = [ + path.resolve(projectRoot, "node_modules"), + path.resolve(workspaceRoot, "node_modules"), +]; + +module.exports = config; diff --git a/apps/mobile/package.json b/apps/mobile/package.json new file mode 100644 index 0000000..69766ed --- /dev/null +++ b/apps/mobile/package.json @@ -0,0 +1,54 @@ +{ + "name": "@app/mobile", + "version": "0.1.0", + "private": true, + "main": "expo-router/entry", + "scripts": { + "start": "expo start", + "android": "expo start --android", + "ios": "expo start --ios", + "typecheck": "tsc --noEmit", + "test": "vitest run --passWithNoTests" + }, + "dependencies": { + "@app/shared-types": "workspace:*", + "@react-native-async-storage/async-storage": "^3.1.1", + "@tanstack/query-async-storage-persister": "^5.90.0", + "@tanstack/react-query": "^5.90.0", + "@tanstack/react-query-persist-client": "^5.90.0", + "expo": "~57.0.9", + "expo-camera": "~57.0.3", + "expo-constants": "~57.0.8", + "expo-haptics": "~57.0.1", + "expo-image": "~57.0.1", + "expo-image-picker": "~57.0.7", + "expo-keep-awake": "~57.0.1", + "expo-linking": "~57.0.4", + "expo-localization": "^57.0.1", + "expo-router": "~57.0.9", + "expo-secure-store": "~57.0.1", + "expo-splash-screen": "~57.0.5", + "expo-status-bar": "~57.0.1", + "expo-system-ui": "~57.0.2", + "i18next": "^26.3.6", + "react": "19.2.3", + "react-dom": "19.2.3", + "react-native": "0.86.2", + "react-native-gesture-handler": "~2.32.0", + "react-native-reanimated": "4.5.1", + "react-native-safe-area-context": "~5.7.0", + "react-native-screens": "~4.26.0", + "zustand": "^5.0.0" + }, + "devDependencies": { + "@types/react": "~19.2.2", + "typescript": "~5.9.3" + }, + "expo": { + "doctor": { + "reactNativeDirectoryCheck": { + "listUnknownPackages": false + } + } + } +} diff --git a/apps/mobile/src/app/(auth)/forgot-password.tsx b/apps/mobile/src/app/(auth)/forgot-password.tsx new file mode 100644 index 0000000..96b2b1a --- /dev/null +++ b/apps/mobile/src/app/(auth)/forgot-password.tsx @@ -0,0 +1,65 @@ +import { useState } from "react"; +import { Text } from "react-native"; +import { Link } from "expo-router"; +import { api } from "@/lib/api"; +import { t } from "@/lib/i18n"; +import { Body, Button, Input, Screen, Spacer, Title } from "@/components/ui"; +import { colors, spacing } from "@/lib/theme"; + +/** + * Glömt lösenord (steg 1): begär återställningsmejl. Svaret är alltid samma + * oavsett om kontot finns – ingen kontouppräkning. Mejlet innehåller en + * app-länk (urlScheme://reset-password?token=…) som öppnar steg 2. + */ +export default function ForgotPasswordScreen() { + const [email, setEmail] = useState(""); + const [sent, setSent] = useState(false); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + const submit = async () => { + setBusy(true); + setError(null); + try { + await api("/v1/auth/forgot-password", { method: "POST", body: { email } }); + setSent(true); + } catch (err) { + setError(err instanceof Error ? err.message : t("common.error")); + } finally { + setBusy(false); + } + }; + + if (sent) { + return ( + + {t("auth.forgotSentTitle")} + {t("auth.forgotSentBody")} + + + {t("auth.backToLogin")} + + + ); + } + + return ( + + {t("auth.forgotTitle")} + {t("auth.forgotBody")} + + {error && {error}} +