diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6812206..6444a21 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: CI on: push: - branches: [main] + branches: [main, master] pull_request: concurrency: diff --git a/HANDOFF.md b/HANDOFF.md index 5974bf3..3d0b4b3 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -1,9 +1,11 @@ # Cibello — HANDOFF / DEPLOY-STATE + Cibello = AI-matapp (recept + bildscanning av kylskåp/etiketter/kvitto/utgångsdatum). pnpm/turbo-monorepo: Fastify+Drizzle+BullMQ (apps/api, apps/worker), Expo (apps/mobile). Roller: OpenClaw bygger. Extern granskare ("Sven", i Johans chatt) verifierar + ger promptar. ## Var saker finns + - Prod-server (EC2): 13.48.40.1, repo /opt/cibello-platform - SSH prod: `ssh cibello-prod` (nyckel ~/.ssh/cibello-prod-key, user ubuntu) - OpenClaw-workspace: cibello-work/cibello @@ -12,6 +14,7 @@ Roller: OpenClaw bygger. Extern granskare ("Sven", i Johans chatt) verifierar + - `.env` på servern härleds ur SSM via `infrastructure/deployment/sync-env-from-ssm.py` ## Infra (uppe) + - EIP 13.48.40.1 → api.cibello.app + admin.cibello.app - CloudFront → cibello.app (marknadssajt, S3 cibello-web) - RDS cibello-prod-db (privat subnät, krypterad, SSL krävs: sslmode=require) @@ -20,6 +23,7 @@ Roller: OpenClaw bygger. Extern granskare ("Sven", i Johans chatt) verifierar + - Caddy reverse proxy på prod-servern: TLS (Let's Encrypt) → 127.0.0.1:4000 ## AI-modell + - AAMOS_MODE=gemini. GeminiAamosClient täcker scan/etikett/kvitto/utgång. - Shadow-capture skriver träningspar → s3://cibello-production/training/cibello/. Insamling PÅ för admin/beta (imageTraining=true); publika användare följer samtycke (gate ej byggd än). @@ -31,6 +35,7 @@ Roller: OpenClaw bygger. Extern granskare ("Sven", i Johans chatt) verifierar + `ai_usage_counters` som övriga planer. ## Hemligheter + - SSM /cibello/prod/* (SecureString). - **ROTERADE (Grupp A):** JWT_ACCESS_SECRET, JWT_REFRESH_SECRET, ENTITLEMENT_SIGNING_SECRET, db-password (cibello_admin). Både RDS och SSM uppdaterade; appen verifierad med nya värden. @@ -39,6 +44,7 @@ Roller: OpenClaw bygger. Extern granskare ("Sven", i Johans chatt) verifierar + - SMTP-pass för hello@cibello.app. ## KLART + - [x] **Skarpt bildflöde:** delad lagringsmodul `@app/storage` (MockStorage + AwsStorage) används av både API och worker. Workerns `readUrl()` ger nu riktig presignerad S3-URL i `aws`-läge istället för `/v1/mock-s3/...`. - [x] **SSM = enda sanningskälla:** `infrastructure/deployment/sync-env-from-ssm.py` hämtar `/cibello/prod/*` och skriver `/opt/cibello-platform/.env`. `first-deploy.sh` kör detta i produktion. - [x] **Gemini-nyckel hälsokoll:** `/readyz` returnerar 503 om `app.aamos.healthCheck()` misslyckas (t.ex. ogiltig Gemini-nyckel). @@ -66,6 +72,7 @@ Roller: OpenClaw bygger. Extern granskare ("Sven", i Johans chatt) verifierar + - `/ops/v1/summary` visar `AI-budget` tile och `budget_exceeded`-larm i `crit`. ## PENDING (i ordning) + 1. **Egen Gemini-nyckel + SMTP-pass** — Johan levererar värden lokalt; claw skriver till SSM/.env. - Nuvarande `.env` innehåller fortfarande den delade `/openclaw`-Gemini-nyckeln. 2. **Skarpt end-to-end-test** när Grupp B är på plats: riktig scan → S3 → Gemini → resultat + träningspar i `s3://cibello-production/training/cibello/`. @@ -74,5 +81,6 @@ Roller: OpenClaw bygger. Extern granskare ("Sven", i Johans chatt) verifierar + 5. **Övervakning/larm**: logga fel, sentry, health-check alert. ## Så här återupptar en färsk session + Läs denna fil + `git log --oneline -8` + senaste prompt från Sven. Arbeta stramt. Echo ALDRIG .env/hemligheter (`set +x`, variabler, aldrig `cat .env`). Uppdatera denna fil innan sessionen tar slut. diff --git a/apps/admin/src/pages/Analytics.tsx b/apps/admin/src/pages/Analytics.tsx index 578863d..4fe920b 100644 --- a/apps/admin/src/pages/Analytics.tsx +++ b/apps/admin/src/pages/Analytics.tsx @@ -84,9 +84,7 @@ export function AnalyticsPage() { }; const loadRetention = () => { - api( - `/admin/v1/analytics/retention?startDate=${retStart}&endDate=${retEnd}`, - ) + api(`/admin/v1/analytics/retention?startDate=${retStart}&endDate=${retEnd}`) .then(setRetention) .catch((e) => setError(String(e.message))); }; diff --git a/apps/admin/src/pages/ReleaseGates.tsx b/apps/admin/src/pages/ReleaseGates.tsx index 233f252..da75aea 100644 --- a/apps/admin/src/pages/ReleaseGates.tsx +++ b/apps/admin/src/pages/ReleaseGates.tsx @@ -114,10 +114,7 @@ export function ReleaseGatesPage() { } }; - const categories = useMemo( - () => Array.from(new Set(gates.map((g) => g.category))), - [gates], - ); + const categories = useMemo(() => Array.from(new Set(gates.map((g) => g.category))), [gates]); const filtered = useMemo( () => (category === "all" ? gates : gates.filter((g) => g.category === category)), @@ -151,8 +148,14 @@ export function ReleaseGatesPage() {
- - {summary.overall === "go" ? "GO" : summary.overall === "no_go" ? "NO-GO" : "Väntar"} + + {summary.overall === "go" + ? "GO" + : summary.overall === "no_go" + ? "NO-GO" + : "Väntar"}
Övergripande beslut
@@ -209,7 +212,9 @@ export function ReleaseGatesPage() { {g.evaluationWindowDays} dagar {g.blocking ? "Ja" : "Nej"} - {g.lastEvaluatedAt ? new Date(g.lastEvaluatedAt).toLocaleString("sv-SE") : "–"} + + {g.lastEvaluatedAt ? new Date(g.lastEvaluatedAt).toLocaleString("sv-SE") : "–"} + ))} diff --git a/apps/api/scripts/backfill-households.ts b/apps/api/scripts/backfill-households.ts index 350abf0..3fff34a 100644 --- a/apps/api/scripts/backfill-households.ts +++ b/apps/api/scripts/backfill-households.ts @@ -32,10 +32,7 @@ async function main() { const usersWithoutHousehold = await db .select({ id: schema.users.id, locale: schema.users.locale }) .from(schema.users) - .leftJoin( - schema.householdMembers, - eq(schema.householdMembers.userId, schema.users.id), - ) + .leftJoin(schema.householdMembers, eq(schema.householdMembers.userId, schema.users.id)) .where(isNull(schema.householdMembers.userId)); console.log(`[backfill] Hittade ${usersWithoutHousehold.length} användare utan hushåll.`); diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts index 27465d9..3148e48 100644 --- a/apps/api/src/config.ts +++ b/apps/api/src/config.ts @@ -107,7 +107,9 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig { process.exit(1); } if (cfg.AAMOS_MODE === "http" && (!cfg.AAMOS_API_URL || !cfg.AAMOS_API_KEY)) { - console.error("SÄKERHETSSTOPP: AAMOS_MODE=http kräver AAMOS_API_URL och AAMOS_API_KEY i produktion."); + console.error( + "SÄKERHETSSTOPP: AAMOS_MODE=http kräver AAMOS_API_URL och AAMOS_API_KEY i produktion.", + ); process.exit(1); } if (!cfg.OPS_TOKEN || cfg.OPS_TOKEN.length < 32 || cfg.OPS_TOKEN.startsWith("dev-ops-token")) { diff --git a/apps/api/src/lib/cooking.ts b/apps/api/src/lib/cooking.ts index 4d60735..a047724 100644 --- a/apps/api/src/lib/cooking.ts +++ b/apps/api/src/lib/cooking.ts @@ -48,7 +48,13 @@ export interface CompleteCookingResult { mealBoxId: string | null; mealBoxMutations: Array<{ mealBoxId: string; deltaPortions: number; frozen: boolean }>; inventoryDeductions: Array<{ itemId: string; quantity: number; unit: string; name: string }>; - recipeIngredients: Array<{ canonicalIngredientId: string; displayName: string; quantity: number; unit: string; optional: boolean }>; + recipeIngredients: Array<{ + canonicalIngredientId: string; + displayName: string; + quantity: number; + unit: string; + optional: boolean; + }>; } /** @@ -66,7 +72,8 @@ export async function completeCookingSession( ): Promise { const plannedPortions = input.portionsCooked ?? session.plannedPortions; const mealBoxPortions = input.mealBoxPortions ?? 0; - const actualPortionsEaten = input.actualPortionsEaten ?? Math.max(0, plannedPortions - mealBoxPortions); + const actualPortionsEaten = + input.actualPortionsEaten ?? Math.max(0, plannedPortions - mealBoxPortions); const leftoverEstimatePortions = input.leftoverEstimatePortions ?? mealBoxPortions; if (actualPortionsEaten + leftoverEstimatePortions > plannedPortions) { @@ -151,7 +158,11 @@ export async function completeCookingSession( sessionId: session.id, date, }, - existing ?? { averageEatenPortions: null, averageLeftoverPortions: null, observationCount: 0 }, + existing ?? { + averageEatenPortions: null, + averageLeftoverPortions: null, + observationCount: 0, + }, ); await app.db @@ -227,7 +238,9 @@ export async function completeCookingSessionCore( actualPortionsEaten + leftoverEstimatePortions, ); const factor = consumptionPortions / recipe.portions; - const overrides = new Map(input.inventoryOverrides?.map((o) => [o.canonicalIngredientId, o]) ?? []); + const overrides = new Map( + input.inventoryOverrides?.map((o) => [o.canonicalIngredientId, o]) ?? [], + ); for (const ing of recipe.ingredients) { if (ing.optional) continue; @@ -307,9 +320,7 @@ export async function completeCookingSessionCore( // 2. Måltider const eaters = - input.eaters && input.eaters.length > 0 - ? input.eaters - : [{ userId, portionFraction: 1 }]; + input.eaters && input.eaters.length > 0 ? input.eaters : [{ userId, portionFraction: 1 }]; const mealIds: string[] = []; for (const eater of eaters) { const nutrition = scaleNutrition(recipe.nutritionPerPortion, eater.portionFraction); @@ -396,7 +407,11 @@ export async function completeCookingSessionCore( }) .where(eq(schema.mealBoxes.id, existingBox.id)); mealBoxId = existingBox.id; - mealBoxMutations.push({ mealBoxId: existingBox.id, deltaPortions: leftoverEstimatePortionsForBox, frozen }); + mealBoxMutations.push({ + mealBoxId: existingBox.id, + deltaPortions: leftoverEstimatePortionsForBox, + frozen, + }); await emitEvent(app.db, { type: "MEAL_BOX_UPDATED", payload: { @@ -427,7 +442,11 @@ export async function completeCookingSessionCore( }) .returning(); mealBoxId = box!.id; - mealBoxMutations.push({ mealBoxId: box!.id, deltaPortions: leftoverEstimatePortionsForBox, frozen }); + mealBoxMutations.push({ + mealBoxId: box!.id, + deltaPortions: leftoverEstimatePortionsForBox, + frozen, + }); await emitEvent(app.db, { type: "MEAL_BOX_CREATED", payload: { mealBoxId: box!.id, portions: leftoverEstimatePortionsForBox }, @@ -468,7 +487,11 @@ export async function completeCookingSessionCore( .where(eq(schema.recipes.id, session.recipeId)); await emitEvent(app.db, { type: "RECIPE_COOKED", - payload: { recipeId: session.recipeId, portions: portionsCooked, mealBoxPortions: input.mealBoxPortions ?? 0 }, + payload: { + recipeId: session.recipeId, + portions: portionsCooked, + mealBoxPortions: input.mealBoxPortions ?? 0, + }, userId, householdId, correlationId, @@ -578,7 +601,11 @@ export async function undoCookingSession( // 2. Återställ inventory-saldon från transaktionerna. for (const itemId of affectedItemIds) { const txs = await app.db - .select({ type: schema.inventoryTransactions.type, quantityDelta: schema.inventoryTransactions.quantityDelta, unit: schema.inventoryTransactions.unit }) + .select({ + type: schema.inventoryTransactions.type, + quantityDelta: schema.inventoryTransactions.quantityDelta, + unit: schema.inventoryTransactions.unit, + }) .from(schema.inventoryTransactions) .where(eq(schema.inventoryTransactions.inventoryItemId, itemId)); const balance = computeBalance(txs); @@ -631,7 +658,11 @@ export async function undoCookingSession( discardedMealBoxIds.push(box.id); await emitEvent(app.db, { type: "MEAL_BOX_DISCARDED", - payload: { mealBoxId: box.id, portions: mutation.deltaPortions, source: "cooking_session_undo" }, + payload: { + mealBoxId: box.id, + portions: mutation.deltaPortions, + source: "cooking_session_undo", + }, userId, householdId: session.householdId, correlationId, @@ -643,7 +674,11 @@ export async function undoCookingSession( .where(eq(schema.mealBoxes.id, box.id)); await emitEvent(app.db, { type: "MEAL_BOX_UPDATED", - payload: { mealBoxId: box.id, addedPortions: -mutation.deltaPortions, totalPortions: newPortions }, + payload: { + mealBoxId: box.id, + addedPortions: -mutation.deltaPortions, + totalPortions: newPortions, + }, userId, householdId: session.householdId, correlationId, @@ -673,7 +708,9 @@ export async function undoCookingSession( } // 5. Ta bort recipe_cooks-raden och backa cookCount. - await app.db.delete(schema.recipeCooks).where(eq(schema.recipeCooks.cookingSessionId, session.id)); + await app.db + .delete(schema.recipeCooks) + .where(eq(schema.recipeCooks.cookingSessionId, session.id)); await app.db .update(schema.recipes) .set({ cookCount: sql`GREATEST(${schema.recipes.cookCount} - 1, 0)` }) diff --git a/apps/api/src/lib/helpers.ts b/apps/api/src/lib/helpers.ts index fc1e0be..b3ecac7 100644 --- a/apps/api/src/lib/helpers.ts +++ b/apps/api/src/lib/helpers.ts @@ -155,7 +155,10 @@ export function newCorrelationId(): string { * funktionen returnerar alltid en giltig profil (default fallback). */ export async function getActiveDecayProfile(db: Database): Promise { const [profile] = await db - .select({ halfLifeDays: schema.inventoryDecayProfiles.halfLifeDays, staleAfterDays: schema.inventoryDecayProfiles.staleAfterDays }) + .select({ + halfLifeDays: schema.inventoryDecayProfiles.halfLifeDays, + staleAfterDays: schema.inventoryDecayProfiles.staleAfterDays, + }) .from(schema.inventoryDecayProfiles) .where(eq(schema.inventoryDecayProfiles.active, true)) .orderBy(schema.inventoryDecayProfiles.createdAt) diff --git a/apps/api/src/lib/inventorySearchResponse.ts b/apps/api/src/lib/inventorySearchResponse.ts index 8ada9a5..3bc92e2 100644 --- a/apps/api/src/lib/inventorySearchResponse.ts +++ b/apps/api/src/lib/inventorySearchResponse.ts @@ -212,7 +212,10 @@ function formatList(items: SearchResultItem[], languageTag: string): string { const loc = formatLocation(item, languageTag); return `• ${item.displayName} ${loc}`; }); - const prefix = resolve(MULTI_RESULT_PREFIX, languageTag).replace("{{count}}", String(items.length)); + const prefix = resolve(MULTI_RESULT_PREFIX, languageTag).replace( + "{{count}}", + String(items.length), + ); const suffix = resolve(MULTI_RESULT_SUFFIX, languageTag); return `${prefix}\n${parts.join("\n")}\n${suffix}`; } @@ -221,10 +224,7 @@ function formatList(items: SearchResultItem[], languageTag: string): string { * Bygg ett naturligt-språkligt svar för fritextsökning i hushållslagret. * Ingen data fabriceras – allt som visas kommer från `items`. */ -export function buildNaturalSearchResponse( - items: SearchResultItem[], - languageTag: string, -): string { +export function buildNaturalSearchResponse(items: SearchResultItem[], languageTag: string): string { if (items.length === 0) { return resolve(ZERO_RESULTS, languageTag); } diff --git a/apps/api/src/lib/memoryImpact.ts b/apps/api/src/lib/memoryImpact.ts index d06a0f1..b726f82 100644 --- a/apps/api/src/lib/memoryImpact.ts +++ b/apps/api/src/lib/memoryImpact.ts @@ -57,14 +57,28 @@ export interface MemoryImpactResult { * andra hushållsmedlemmars personliga data blir aldrig lästa eller returnerade. * - Återanvänder rankAll/scoreCandidate från recommendation-engine. */ -export async function computeMemoryImpact(options: MemoryImpactOptions): Promise { - const { db, userId, memoryItem, mealType = "dinner", persons, maxMinutes, craving, view = "default", limit = 10 } = options; +export async function computeMemoryImpact( + options: MemoryImpactOptions, +): Promise { + const { + db, + userId, + memoryItem, + mealType = "dinner", + persons, + maxMinutes, + craving, + view = "default", + limit = 10, + } = options; // R2: personalization-samtycke krävs för impact. const [consent] = await db .select() .from(schema.userConsents) - .where(and(eq(schema.userConsents.userId, userId), eq(schema.userConsents.kind, "personalization"))) + .where( + and(eq(schema.userConsents.userId, userId), eq(schema.userConsents.kind, "personalization")), + ) .limit(1); if (consent?.status !== "granted") { return { memoryItemId: memoryItem.id, personalizationEnabled: false, impacted: [] }; diff --git a/apps/api/src/lib/onboardingMemory.ts b/apps/api/src/lib/onboardingMemory.ts index 4d5cc58..6c49de7 100644 --- a/apps/api/src/lib/onboardingMemory.ts +++ b/apps/api/src/lib/onboardingMemory.ts @@ -141,11 +141,7 @@ export async function syncOnboardingMemory( origin: ONBOARDING_ORIGIN, }) .onConflictDoUpdate({ - target: [ - schema.tasteSignals.userId, - schema.tasteSignals.axis, - schema.tasteSignals.target, - ], + target: [schema.tasteSignals.userId, schema.tasteSignals.axis, schema.tasteSignals.target], set: { direction: signal.direction, strength: TASTE_STRENGTH, diff --git a/apps/api/src/plugins/storage.ts b/apps/api/src/plugins/storage.ts index d04c12a..8bafc56 100644 --- a/apps/api/src/plugins/storage.ts +++ b/apps/api/src/plugins/storage.ts @@ -1,11 +1,6 @@ import fp from "fastify-plugin"; import type { FastifyInstance } from "fastify"; -import { - MockStorage, - AwsStorage, - type StorageService, - createStorageService, -} from "@app/storage"; +import { MockStorage, AwsStorage, type StorageService, createStorageService } from "@app/storage"; declare module "fastify" { interface FastifyInstance { diff --git a/apps/api/src/routes/admin-analytics.ts b/apps/api/src/routes/admin-analytics.ts index 3516568..4256d21 100644 --- a/apps/api/src/routes/admin-analytics.ts +++ b/apps/api/src/routes/admin-analytics.ts @@ -21,7 +21,9 @@ export async function adminAnalyticsRoutes(app: FastifyInstance) { /** Compute a funnel between two UTC dates (YYYY-MM-DD). */ app.get("/admin/v1/analytics/funnels/:name", admin, async (req) => { - const params = z.object({ name: z.enum(Object.keys(FUNNELS) as [string, ...string[]]) }).parse(req.params); + const params = z + .object({ name: z.enum(Object.keys(FUNNELS) as [string, ...string[]]) }) + .parse(req.params); const query = z .object({ startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), @@ -69,7 +71,10 @@ export async function adminAnalyticsRoutes(app: FastifyInstance) { const querySql = `WITH ${ctes.join(", ")} SELECT ${selects.join(", ")}`; const result = await app.db.execute(sql.raw(querySql)); - const row = (Array.isArray(result) ? result[0] : (result.rows[0] ?? {})) as Record; + const row = (Array.isArray(result) ? result[0] : (result.rows[0] ?? {})) as Record< + string, + number + >; const stepCounts = steps.map((event, i) => ({ step: i + 1, @@ -97,7 +102,10 @@ export async function adminAnalyticsRoutes(app: FastifyInstance) { const query = z .object({ startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), - endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), + endDate: z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/) + .optional(), }) .parse(req.query); @@ -147,7 +155,12 @@ export async function adminAnalyticsRoutes(app: FastifyInstance) { string, { cohortSize: number; retention: Array<{ day: number; active: number; rate: number }> } > = {}; - for (const r of rows as Array<{ cohort_date: string; day: number; cohort_size: number; active_users: number }>) { + for (const r of rows as Array<{ + cohort_date: string; + day: number; + cohort_size: number; + active_users: number; + }>) { const key = String(r.cohort_date).slice(0, 10); if (!cohorts[key]) { cohorts[key] = { cohortSize: Number(r.cohort_size), retention: [] }; diff --git a/apps/api/src/routes/admin-release-gates.ts b/apps/api/src/routes/admin-release-gates.ts index 545e11b..601bb46 100644 --- a/apps/api/src/routes/admin-release-gates.ts +++ b/apps/api/src/routes/admin-release-gates.ts @@ -1,6 +1,11 @@ import type { FastifyInstance } from "fastify"; import { eq } from "drizzle-orm"; -import { schema, evaluateReleaseGates, seedReleaseGates, summarizeReleaseGates } from "@app/database"; +import { + schema, + evaluateReleaseGates, + seedReleaseGates, + summarizeReleaseGates, +} from "@app/database"; import { releaseGateUpdateSchema } from "@app/validation"; import { z } from "zod"; import { errors, parse } from "../lib/errors.js"; @@ -40,7 +45,10 @@ export async function adminReleaseGateRoutes(app: FastifyInstance) { /** List current gate definitions without re-evaluating. */ app.get("/admin/v1/release-gates", admin, async () => { - const gates = await app.db.select().from(schema.releaseGates).orderBy(schema.releaseGates.category, schema.releaseGates.gateKey); + const gates = await app.db + .select() + .from(schema.releaseGates) + .orderBy(schema.releaseGates.category, schema.releaseGates.gateKey); const summary = summarizeReleaseGates( gates.map((g) => ({ gateKey: g.gateKey, diff --git a/apps/api/src/routes/admin-workers.ts b/apps/api/src/routes/admin-workers.ts index 2497a01..b853553 100644 --- a/apps/api/src/routes/admin-workers.ts +++ b/apps/api/src/routes/admin-workers.ts @@ -64,7 +64,9 @@ export async function adminWorkersRoutes(app: FastifyInstance) { const { id } = req.params as { id: string }; const job = await Job.fromId(dlq, id); if (!job) { - return reply.status(404).send({ error: { code: "NOT_FOUND", message: "Jobbet finns inte i dead letter queue." } }); + return reply + .status(404) + .send({ error: { code: "NOT_FOUND", message: "Jobbet finns inte i dead letter queue." } }); } const mainQueue = new Queue(JOB_QUEUE_NAME, { connection: app.redis }); @@ -82,7 +84,9 @@ export async function adminWorkersRoutes(app: FastifyInstance) { const { id } = req.params as { id: string }; const job = await Job.fromId(dlq, id); if (!job) { - return reply.status(404).send({ error: { code: "NOT_FOUND", message: "Jobbet finns inte i dead letter queue." } }); + return reply + .status(404) + .send({ error: { code: "NOT_FOUND", message: "Jobbet finns inte i dead letter queue." } }); } await job.remove(); return reply.send({ ok: true, message: "Jobbet har tagits bort från dead letter queue." }); diff --git a/apps/api/src/routes/analytics.ts b/apps/api/src/routes/analytics.ts index a99cc2d..7a81e68 100644 --- a/apps/api/src/routes/analytics.ts +++ b/apps/api/src/routes/analytics.ts @@ -24,7 +24,8 @@ export async function analyticsRoutes(app: FastifyInstance) { rateLimit: { max: ANALYTICS_RATE_LIMIT_MAX, timeWindow: "1 minute", - keyGenerator: (req) => `analytics:${req.ip}:${(req as unknown as { userId?: string }).userId ?? "anon"}`, + keyGenerator: (req) => + `analytics:${req.ip}:${(req as unknown as { userId?: string }).userId ?? "anon"}`, }, }, }, diff --git a/apps/api/src/routes/cooking-sessions.ts b/apps/api/src/routes/cooking-sessions.ts index cc5f63c..64f1f35 100644 --- a/apps/api/src/routes/cooking-sessions.ts +++ b/apps/api/src/routes/cooking-sessions.ts @@ -103,7 +103,11 @@ export async function cookingSessionRoutes(app: FastifyInstance) { const params = z.object({ id: z.uuid() }).parse(req.params); const body = z.object({ reason: z.string().max(200).optional() }).parse(req.body ?? {}); const session = await getOwnedSession(app, params.id, req.userId); - if (session.status === "completed" || session.status === "cancelled" || session.status === "undone") { + if ( + session.status === "completed" || + session.status === "cancelled" || + session.status === "undone" + ) { throw errors.conflict("Sessionen är redan avslutad."); } diff --git a/apps/api/src/routes/inventory.ts b/apps/api/src/routes/inventory.ts index 7348188..e8d90ec 100644 --- a/apps/api/src/routes/inventory.ts +++ b/apps/api/src/routes/inventory.ts @@ -461,7 +461,7 @@ export async function inventoryRoutes(app: FastifyInstance) { } const trustState = computeTrust({ - confidence: updates.confidence as number | undefined ?? item.confidence, + confidence: (updates.confidence as number | undefined) ?? item.confidence, verifiedByUser: (updates.verifiedByUser as boolean | undefined) ?? item.verifiedByUser, lastVerifiedAt: (updates.lastVerifiedAt as Date | null | undefined) ?? item.lastVerifiedAt, quantity: (updates.quantity as number | undefined) ?? item.quantity, diff --git a/apps/api/src/routes/me.ts b/apps/api/src/routes/me.ts index 43648c9..707563b 100644 --- a/apps/api/src/routes/me.ts +++ b/apps/api/src/routes/me.ts @@ -173,7 +173,9 @@ export async function meRoutes(app: FastifyInstance) { const [row] = await app.db .insert(schema.userTermsConsents) .values({ userId: req.userId, termsVersion: TERMS_VERSION, acceptedAt: now }) - .onConflictDoNothing({ target: [schema.userTermsConsents.userId, schema.userTermsConsents.termsVersion] }) + .onConflictDoNothing({ + target: [schema.userTermsConsents.userId, schema.userTermsConsents.termsVersion], + }) .returning(); return { accepted: true, diff --git a/apps/api/src/routes/onboarding.ts b/apps/api/src/routes/onboarding.ts index 4a4564b..3920453 100644 --- a/apps/api/src/routes/onboarding.ts +++ b/apps/api/src/routes/onboarding.ts @@ -4,11 +4,7 @@ import { z } from "zod"; import { schema } from "@app/database"; import { quickStartInputSchema, onboardingInputSchema } from "@app/validation"; import { errors, parse } from "../lib/errors.js"; -import { - audit, - createHouseholdWithDefaults, - getActiveHouseholdId, -} from "../lib/helpers.js"; +import { audit, createHouseholdWithDefaults, getActiveHouseholdId } from "../lib/helpers.js"; import { t } from "../lib/i18n.js"; import { KNOWN_FLAGS } from "@app/feature-flags"; @@ -70,11 +66,7 @@ export async function onboardingRoutes(app: FastifyInstance) { const input = parse(quickStartInputSchema, req.body); // Normalize goals: multi-select UI sends `goals`; legacy clients send `primaryGoal`. - const goals = input.goals?.length - ? input.goals - : input.primaryGoal - ? [input.primaryGoal] - : []; + const goals = input.goals?.length ? input.goals : input.primaryGoal ? [input.primaryGoal] : []; const primaryGoal = goals[0]; // Upsert minimal preferences @@ -262,10 +254,7 @@ export async function onboardingRoutes(app: FastifyInstance) { /** POST /v1/onboarding/skip – skip remaining steps (GDPR-friendly, user's choice). */ app.post("/v1/onboarding/skip", auth, async (req) => { - const body = parse( - z.object({ step: z.enum(["b", "c"]).optional() }), - req.body, - ); + const body = parse(z.object({ step: z.enum(["b", "c"]).optional() }), req.body); const [user] = await app.db .update(schema.users) diff --git a/apps/api/src/routes/recipes.ts b/apps/api/src/routes/recipes.ts index f4b7319..c89953d 100644 --- a/apps/api/src/routes/recipes.ts +++ b/apps/api/src/routes/recipes.ts @@ -433,9 +433,16 @@ export async function recipeRoutes(app: FastifyInstance) { }) .returning(); - const result = await completeCookingSession(app, session!, req.userId, input, req.correlationId, { - emitStartedEvent: true, - }); + const result = await completeCookingSession( + app, + session!, + req.userId, + input, + req.correlationId, + { + emitStartedEvent: true, + }, + ); return { sessionId: session!.id, ...result }; }); diff --git a/apps/api/src/routes/reconciliations.ts b/apps/api/src/routes/reconciliations.ts index b9633b8..f665280 100644 --- a/apps/api/src/routes/reconciliations.ts +++ b/apps/api/src/routes/reconciliations.ts @@ -4,11 +4,13 @@ import { schema } from "@app/database"; import { z } from "zod"; import { buildReconciliationCandidates, classifyExpiry, computeTrust } from "@app/inventory-engine"; import { errors, parse } from "../lib/errors.js"; -import { getActiveDecayProfile, requireActiveHousehold, requireMembership, trackProductAnalytics } from "../lib/helpers.js"; import { - reconciliationResolveInputSchema, - reconciliationStartInputSchema, -} from "@app/validation"; + getActiveDecayProfile, + requireActiveHousehold, + requireMembership, + trackProductAnalytics, +} from "../lib/helpers.js"; +import { reconciliationResolveInputSchema, reconciliationStartInputSchema } from "@app/validation"; import { inventoryReconciliationCompleted, inventoryReconciliationStarted } from "@app/analytics"; /** Quick Reconciliation (Fas 2 §5.4) */ @@ -63,10 +65,15 @@ export async function reconciliationRoutes(app: FastifyInstance) { ); if (upcomingEntries.length > 0) { - const recipeIds = [...new Set(upcomingEntries.map((m) => m.recipeId).filter(Boolean))] as string[]; + const recipeIds = [ + ...new Set(upcomingEntries.map((m) => m.recipeId).filter(Boolean)), + ] as string[]; if (recipeIds.length > 0) { const ingredients = await app.db - .select({ recipeId: schema.recipeIngredients.recipeId, canonicalId: schema.recipeIngredients.canonicalIngredientId }) + .select({ + recipeId: schema.recipeIngredients.recipeId, + canonicalId: schema.recipeIngredients.canonicalIngredientId, + }) .from(schema.recipeIngredients) .where(inArray(schema.recipeIngredients.recipeId, recipeIds)); for (const ing of ingredients) { @@ -255,7 +262,11 @@ export async function reconciliationRoutes(app: FastifyInstance) { req.userId, inventoryReconciliationCompleted({ householdId, - properties: { itemId: params.itemId, action: input.action, hadAdjustment: quantityChange !== 0 }, + properties: { + itemId: params.itemId, + action: input.action, + hadAdjustment: quantityChange !== 0, + }, }), ); diff --git a/apps/api/src/routes/scan-diff.ts b/apps/api/src/routes/scan-diff.ts index b8ca246..e07c1a6 100644 --- a/apps/api/src/routes/scan-diff.ts +++ b/apps/api/src/routes/scan-diff.ts @@ -5,7 +5,11 @@ import { diffScans, type ScanDiffRow, computeBalance } from "@app/inventory-engi import { z } from "zod"; import type { Unit } from "@app/shared-types"; import { errors, parse } from "../lib/errors.js"; -import { requireActiveHousehold, requireMembership, trackProductAnalytics } from "../lib/helpers.js"; +import { + requireActiveHousehold, + requireMembership, + trackProductAnalytics, +} from "../lib/helpers.js"; import { scanDiffApplyInputSchema } from "@app/validation"; import { inventoryConflictCreated, inventoryConflictResolved } from "@app/analytics"; @@ -25,7 +29,11 @@ export async function scanDiffRoutes(app: FastifyInstance) { const householdId = await requireActiveHousehold(app.db, req.userId); await requireMembership(app.db, householdId, req.userId); - const [job] = await app.db.select().from(schema.scanJobs).where(eq(schema.scanJobs.id, params.id)).limit(1); + const [job] = await app.db + .select() + .from(schema.scanJobs) + .where(eq(schema.scanJobs.id, params.id)) + .limit(1); if (!job || job.userId !== req.userId) throw errors.notFound("Skanningen finns inte."); if (!job.result) throw errors.badRequest("Skanningen har inget resultat än."); @@ -57,7 +65,10 @@ export async function scanDiffRoutes(app: FastifyInstance) { kind: row.kind, displayName: row.displayName, previousItemId: row.previousItemId, - proposedResolution: row.kind === "vanished" ? "depleted" : { quantity: row.newQuantity, locationId: row.newLocationId }, + proposedResolution: + row.kind === "vanished" + ? "depleted" + : { quantity: row.newQuantity, locationId: row.newLocationId }, })); return { @@ -96,14 +107,19 @@ export async function scanDiffRoutes(app: FastifyInstance) { } } - const [job] = await app.db.select().from(schema.scanJobs).where(eq(schema.scanJobs.id, params.id)).limit(1); + const [job] = await app.db + .select() + .from(schema.scanJobs) + .where(eq(schema.scanJobs.id, params.id)) + .limit(1); if (!job || job.userId !== req.userId) throw errors.notFound("Skanningen finns inte."); const appliedItemIds: string[] = []; for (const row of input.rows) { if (row.kind === "new_item") { - const locationId = row.newLocationId ?? (await defaultLocation(app, householdId, job.scanType)); + const locationId = + row.newLocationId ?? (await defaultLocation(app, householdId, job.scanType)); if (!locationId) continue; const [inv] = await app.db .insert(schema.inventoryItems) @@ -219,7 +235,11 @@ export async function scanDiffRoutes(app: FastifyInstance) { .where(eq(schema.inventoryItems.id, itemId)) .limit(1); const txs = await app.db - .select({ type: schema.inventoryTransactions.type, quantityDelta: schema.inventoryTransactions.quantityDelta, unit: schema.inventoryTransactions.unit }) + .select({ + type: schema.inventoryTransactions.type, + quantityDelta: schema.inventoryTransactions.quantityDelta, + unit: schema.inventoryTransactions.unit, + }) .from(schema.inventoryTransactions) .where(eq(schema.inventoryTransactions.inventoryItemId, itemId)); const balance = computeBalance(txs); @@ -253,7 +273,12 @@ export async function scanDiffRoutes(app: FastifyInstance) { const conflicts = await app.db .select() .from(schema.inventoryConflicts) - .where(and(eq(schema.inventoryConflicts.householdId, householdId), eq(schema.inventoryConflicts.status, "open"))) + .where( + and( + eq(schema.inventoryConflicts.householdId, householdId), + eq(schema.inventoryConflicts.status, "open"), + ), + ) .orderBy(schema.inventoryConflicts.createdAt); return { conflicts }; }); @@ -278,7 +303,8 @@ export async function scanDiffRoutes(app: FastifyInstance) { .from(schema.inventoryConflicts) .where(eq(schema.inventoryConflicts.id, params.id)) .limit(1); - if (!conflict || conflict.householdId !== householdId) throw errors.notFound("Konflikten finns inte."); + if (!conflict || conflict.householdId !== householdId) + throw errors.notFound("Konflikten finns inte."); if (conflict.status !== "open") throw errors.badRequest("Konflikten är redan löst."); const resolved = resolveConflictPayload(conflict, body); @@ -349,7 +375,11 @@ function extractObservations(result: Record | unknown[]): Array useByDate?: string | null; observationConfidence: number; }> { - const raw = Array.isArray(result) ? result : Array.isArray(result.items) ? (result.items as unknown[]) : []; + const raw = Array.isArray(result) + ? result + : Array.isArray(result.items) + ? (result.items as unknown[]) + : []; return raw .map((r) => { const item = r as Record; @@ -382,18 +412,31 @@ function scanSource(scanType: string) { async function defaultLocation(app: FastifyInstance, householdId: string, scanType: string) { const wanted = - scanType === "freezer" ? "freezer" : scanType === "pantry" || scanType === "ingredients" ? "pantry" : "fridge"; + 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))) + .where( + and( + eq(schema.storageLocations.householdId, householdId), + eq(schema.storageLocations.type, wanted), + ), + ) .limit(1); return loc?.id ?? null; } function resolveConflictPayload( conflict: typeof schema.inventoryConflicts.$inferSelect, - body: { resolution: "source_a" | "source_b" | "manual"; manualQuantity?: number; manualLocationId?: string }, + body: { + resolution: "source_a" | "source_b" | "manual"; + manualQuantity?: number; + manualLocationId?: string; + }, ): { quantity?: number; locationId?: string; status?: string } { const a = (conflict.payloadA ?? {}) as Record; const b = (conflict.payloadB ?? {}) as Record; diff --git a/apps/api/test/admin-trust.test.ts b/apps/api/test/admin-trust.test.ts index 520852a..330ccbd 100644 --- a/apps/api/test/admin-trust.test.ts +++ b/apps/api/test/admin-trust.test.ts @@ -19,18 +19,29 @@ describe("admin household trust score", () => { .from(schema.users) .where(inArray(schema.users.email, [adminEmail])); for (const u of existing) { - await testDb.db.delete(schema.inventoryTransactions).where(eq(schema.inventoryTransactions.actorUserId, u.id)); + await testDb.db + .delete(schema.inventoryTransactions) + .where(eq(schema.inventoryTransactions.actorUserId, u.id)); const owned = await testDb.db .select({ id: schema.households.id }) .from(schema.households) - .innerJoin(schema.householdMembers, eq(schema.householdMembers.householdId, schema.households.id)) + .innerJoin( + schema.householdMembers, + eq(schema.householdMembers.householdId, schema.households.id), + ) .where(eq(schema.householdMembers.userId, u.id)); for (const h of owned) { - await testDb.db.delete(schema.inventoryItems).where(eq(schema.inventoryItems.householdId, h.id)); - await testDb.db.delete(schema.storageLocations).where(eq(schema.storageLocations.householdId, h.id)); + await testDb.db + .delete(schema.inventoryItems) + .where(eq(schema.inventoryItems.householdId, h.id)); + await testDb.db + .delete(schema.storageLocations) + .where(eq(schema.storageLocations.householdId, h.id)); await testDb.db.delete(schema.households).where(eq(schema.households.id, h.id)); } - await testDb.db.delete(schema.householdMembers).where(eq(schema.householdMembers.userId, u.id)); + await testDb.db + .delete(schema.householdMembers) + .where(eq(schema.householdMembers.userId, u.id)); await testDb.db.delete(schema.userPreferences).where(eq(schema.userPreferences.userId, u.id)); await testDb.db.delete(schema.users).where(eq(schema.users.id, u.id)); } @@ -74,7 +85,12 @@ describe("admin household trust score", () => { headers: { authorization: `Bearer ${adminToken}` }, }); expect(res.statusCode).toBe(200); - const body = JSON.parse(res.body) as { householdId: string; score: number; status: string; itemCount: number }; + const body = JSON.parse(res.body) as { + householdId: string; + score: number; + status: string; + itemCount: number; + }; expect(body.householdId).toBe(householdId); expect(typeof body.score).toBe("number"); expect(["up_to_date", "needs_check", "uncertain"]).toContain(body.status); diff --git a/apps/api/test/cooking-sessions.test.ts b/apps/api/test/cooking-sessions.test.ts index 746ae5b..02fdfff 100644 --- a/apps/api/test/cooking-sessions.test.ts +++ b/apps/api/test/cooking-sessions.test.ts @@ -18,7 +18,12 @@ describe("cooking sessions", () => { let recipeId: string; const email = "cooking-session-test@example.invalid"; - async function createItemWithPurchase(canonicalIngredientId: string, displayName: string, quantity: number, unit: Unit) { + async function createItemWithPurchase( + canonicalIngredientId: string, + displayName: string, + quantity: number, + unit: Unit, + ) { const [location] = await testDb.db .select({ id: schema.storageLocations.id }) .from(schema.storageLocations) @@ -64,26 +69,46 @@ describe("cooking sessions", () => { .where(eq(schema.inventoryTransactions.cookingSessionId, s.id)); await testDb.db.delete(schema.meals).where(eq(schema.meals.cookingSessionId, s.id)); await testDb.db.delete(schema.mealBoxes).where(eq(schema.mealBoxes.cookingSessionId, s.id)); - await testDb.db.delete(schema.recipeCooks).where(eq(schema.recipeCooks.cookingSessionId, s.id)); + await testDb.db + .delete(schema.recipeCooks) + .where(eq(schema.recipeCooks.cookingSessionId, s.id)); } - await testDb.db.delete(schema.cookingSessions).where(eq(schema.cookingSessions.startedByUserId, u.id)); + await testDb.db + .delete(schema.cookingSessions) + .where(eq(schema.cookingSessions.startedByUserId, u.id)); const memberships = await testDb.db .select({ householdId: schema.householdMembers.householdId }) .from(schema.householdMembers) .where(eq(schema.householdMembers.userId, u.id)); for (const m of memberships) { - await testDb.db.delete(schema.inventoryItems).where(eq(schema.inventoryItems.householdId, m.householdId)); - await testDb.db.delete(schema.mealBoxes).where(eq(schema.mealBoxes.householdId, m.householdId)); + await testDb.db + .delete(schema.inventoryItems) + .where(eq(schema.inventoryItems.householdId, m.householdId)); + await testDb.db + .delete(schema.mealBoxes) + .where(eq(schema.mealBoxes.householdId, m.householdId)); await testDb.db.delete(schema.meals).where(eq(schema.meals.householdId, m.householdId)); - await testDb.db.delete(schema.recipeCooks).where(eq(schema.recipeCooks.householdId, m.householdId)); - await testDb.db.delete(schema.cookingSessions).where(eq(schema.cookingSessions.householdId, m.householdId)); - await testDb.db.delete(schema.storageLocations).where(eq(schema.storageLocations.householdId, m.householdId)); - await testDb.db.delete(schema.cookingAssumptionProfiles).where(eq(schema.cookingAssumptionProfiles.householdId, m.householdId)); - await testDb.db.delete(schema.householdMembers).where(eq(schema.householdMembers.householdId, m.householdId)); + await testDb.db + .delete(schema.recipeCooks) + .where(eq(schema.recipeCooks.householdId, m.householdId)); + await testDb.db + .delete(schema.cookingSessions) + .where(eq(schema.cookingSessions.householdId, m.householdId)); + await testDb.db + .delete(schema.storageLocations) + .where(eq(schema.storageLocations.householdId, m.householdId)); + await testDb.db + .delete(schema.cookingAssumptionProfiles) + .where(eq(schema.cookingAssumptionProfiles.householdId, m.householdId)); + await testDb.db + .delete(schema.householdMembers) + .where(eq(schema.householdMembers.householdId, m.householdId)); await testDb.db.delete(schema.households).where(eq(schema.households.id, m.householdId)); } await testDb.db.delete(schema.userPreferences).where(eq(schema.userPreferences.userId, u.id)); - await testDb.db.delete(schema.productAnalyticsEvents).where(eq(schema.productAnalyticsEvents.userId, u.id)); + await testDb.db + .delete(schema.productAnalyticsEvents) + .where(eq(schema.productAnalyticsEvents.userId, u.id)); await testDb.db.delete(schema.users).where(eq(schema.users.id, u.id)); } } @@ -99,7 +124,11 @@ describe("cooking sessions", () => { payload: { email, password: "Password123!", displayName: "Cooking Test" }, }); token = (JSON.parse(res.body) as { accessToken: string }).accessToken; - const profile = await app.inject({ method: "GET", url: "/v1/me", headers: { authorization: `Bearer ${token}` } }); + const profile = await app.inject({ + method: "GET", + url: "/v1/me", + headers: { authorization: `Bearer ${token}` }, + }); userId = (JSON.parse(profile.body) as { id: string }).id; const quick = await app.inject({ @@ -168,7 +197,9 @@ describe("cooking sessions", () => { .from(schema.productAnalyticsEvents) .where(eq(schema.productAnalyticsEvents.userId, userId)) .orderBy(schema.productAnalyticsEvents.occurredAt); - expect(started.filter((e) => e.eventName === "cooking_session_started").length).toBeGreaterThanOrEqual(1); + expect( + started.filter((e) => e.eventName === "cooking_session_started").length, + ).toBeGreaterThanOrEqual(1); }); it("cancels a session without touching inventory", async () => { @@ -183,7 +214,10 @@ describe("cooking sessions", () => { const before = await testDb.db .select({ count: count(schema.inventoryTransactions.id) }) .from(schema.inventoryTransactions) - .innerJoin(schema.inventoryItems, eq(schema.inventoryTransactions.inventoryItemId, schema.inventoryItems.id)) + .innerJoin( + schema.inventoryItems, + eq(schema.inventoryTransactions.inventoryItemId, schema.inventoryItems.id), + ) .where(eq(schema.inventoryItems.householdId, householdId)); const res = await app.inject({ @@ -199,7 +233,10 @@ describe("cooking sessions", () => { const after = await testDb.db .select({ count: count(schema.inventoryTransactions.id) }) .from(schema.inventoryTransactions) - .innerJoin(schema.inventoryItems, eq(schema.inventoryTransactions.inventoryItemId, schema.inventoryItems.id)) + .innerJoin( + schema.inventoryItems, + eq(schema.inventoryTransactions.inventoryItemId, schema.inventoryItems.id), + ) .where(eq(schema.inventoryItems.householdId, householdId)); expect(after[0]!.count).toBe(before[0]!.count); }); @@ -248,7 +285,11 @@ describe("cooking sessions", () => { .where(eq(schema.inventoryItems.id, d.itemId)) .limit(1); const txs = await testDb.db - .select({ type: schema.inventoryTransactions.type, quantityDelta: schema.inventoryTransactions.quantityDelta, unit: schema.inventoryTransactions.unit }) + .select({ + type: schema.inventoryTransactions.type, + quantityDelta: schema.inventoryTransactions.quantityDelta, + unit: schema.inventoryTransactions.unit, + }) .from(schema.inventoryTransactions) .where(eq(schema.inventoryTransactions.inventoryItemId, d.itemId)); const balance = computeBalance(txs); @@ -264,7 +305,11 @@ describe("cooking sessions", () => { payload: { portionsCooked: 4, mealBoxPortions: 2, deductInventory: true }, }); expect(res.statusCode).toBe(200); - const body = JSON.parse(res.body) as { ok: boolean; sessionId: string; mealBoxId: string | null }; + const body = JSON.parse(res.body) as { + ok: boolean; + sessionId: string; + mealBoxId: string | null; + }; expect(body.ok).toBe(true); expect(body.sessionId).toBeDefined(); expect(body.mealBoxId).toBeDefined(); @@ -298,14 +343,20 @@ describe("cooking sessions", () => { }); it("legacy /cook updates cooking assumption profiles", async () => { - await testDb.db.delete(schema.cookingAssumptionProfiles).where(eq(schema.cookingAssumptionProfiles.householdId, householdId)); + await testDb.db + .delete(schema.cookingAssumptionProfiles) + .where(eq(schema.cookingAssumptionProfiles.householdId, householdId)); - const recipe = (await app.inject({ - method: "GET", - url: `/v1/recipes/${recipeId}`, - headers: { authorization: `Bearer ${token}` }, - })).json() as { ingredients: Array<{ canonicalIngredientId: string; optional?: boolean }> }; - const firstNonOptionalIngredientId = recipe.ingredients.find((i) => i.canonicalIngredientId && !i.optional)?.canonicalIngredientId; + const recipe = ( + await app.inject({ + method: "GET", + url: `/v1/recipes/${recipeId}`, + headers: { authorization: `Bearer ${token}` }, + }) + ).json() as { ingredients: Array<{ canonicalIngredientId: string; optional?: boolean }> }; + const firstNonOptionalIngredientId = recipe.ingredients.find( + (i) => i.canonicalIngredientId && !i.optional, + )?.canonicalIngredientId; expect(firstNonOptionalIngredientId).toBeDefined(); await app.inject({ @@ -338,7 +389,10 @@ describe("cooking sessions", () => { it("legacy /cook writes cooking_session_started and cooking_session_completed analytics", async () => { const before = await testDb.db - .select({ id: schema.productAnalyticsEvents.id, eventName: schema.productAnalyticsEvents.eventName }) + .select({ + id: schema.productAnalyticsEvents.id, + eventName: schema.productAnalyticsEvents.eventName, + }) .from(schema.productAnalyticsEvents) .where(eq(schema.productAnalyticsEvents.userId, userId)); const beforeIds = new Set(before.map((e) => e.id)); @@ -357,9 +411,14 @@ describe("cooking sessions", () => { .from(schema.productAnalyticsEvents) .where(eq(schema.productAnalyticsEvents.userId, userId)); const newEvents = after.filter((e) => !beforeIds.has(e.id)); - const props = (e: (typeof after)[number]) => (e.properties ?? {}) as { cookingSessionId?: string }; - const started = newEvents.filter((e) => e.eventName === "cooking_session_started" && props(e).cookingSessionId === sessionId); - const completed = newEvents.filter((e) => e.eventName === "cooking_session_completed" && props(e).cookingSessionId === sessionId); + const props = (e: (typeof after)[number]) => + (e.properties ?? {}) as { cookingSessionId?: string }; + const started = newEvents.filter( + (e) => e.eventName === "cooking_session_started" && props(e).cookingSessionId === sessionId, + ); + const completed = newEvents.filter( + (e) => e.eventName === "cooking_session_completed" && props(e).cookingSessionId === sessionId, + ); expect(started.length).toBe(1); expect(completed.length).toBe(1); }); @@ -395,7 +454,9 @@ describe("cooking sessions", () => { payload: { mealBoxPortions: 1, actualPortionsEaten: 2, leftoverEstimatePortions: 1 }, }); expect(res.statusCode).toBe(200); - const body = JSON.parse(res.body) as { session: { actualPortionsEaten: number; leftoverEstimatePortions: number } }; + const body = JSON.parse(res.body) as { + session: { actualPortionsEaten: number; leftoverEstimatePortions: number }; + }; expect(body.session.actualPortionsEaten).toBe(2); expect(body.session.leftoverEstimatePortions).toBe(1); }); @@ -419,7 +480,9 @@ describe("cooking sessions", () => { }); it("updates cooking assumption profiles per household and ingredient", async () => { - await testDb.db.delete(schema.cookingAssumptionProfiles).where(eq(schema.cookingAssumptionProfiles.householdId, householdId)); + await testDb.db + .delete(schema.cookingAssumptionProfiles) + .where(eq(schema.cookingAssumptionProfiles.householdId, householdId)); const start = await app.inject({ method: "POST", @@ -429,12 +492,16 @@ describe("cooking sessions", () => { }); const sessionId = (JSON.parse(start.body) as { session: { id: string } }).session.id; - const recipe = (await app.inject({ - method: "GET", - url: `/v1/recipes/${recipeId}`, - headers: { authorization: `Bearer ${token}` }, - })).json() as { ingredients: Array<{ canonicalIngredientId: string; optional?: boolean }> }; - const firstNonOptionalIngredientId = recipe.ingredients.find((i) => i.canonicalIngredientId && !i.optional)?.canonicalIngredientId; + const recipe = ( + await app.inject({ + method: "GET", + url: `/v1/recipes/${recipeId}`, + headers: { authorization: `Bearer ${token}` }, + }) + ).json() as { ingredients: Array<{ canonicalIngredientId: string; optional?: boolean }> }; + const firstNonOptionalIngredientId = recipe.ingredients.find( + (i) => i.canonicalIngredientId && !i.optional, + )?.canonicalIngredientId; expect(firstNonOptionalIngredientId).toBeDefined(); await app.inject({ @@ -461,7 +528,9 @@ describe("cooking sessions", () => { }); it("cooking-assumptions ignores optional first ingredient and returns defaults from a non-optional one", async () => { - await testDb.db.delete(schema.cookingAssumptionProfiles).where(eq(schema.cookingAssumptionProfiles.householdId, householdId)); + await testDb.db + .delete(schema.cookingAssumptionProfiles) + .where(eq(schema.cookingAssumptionProfiles.householdId, householdId)); // Skapa ett recept där den första ingrediensen är valfri. const [recipe] = await testDb.db @@ -480,7 +549,16 @@ describe("cooking sessions", () => { cookTimeMinutes: 10, totalTimeMinutes: 15, portions: 4, - nutritionPerPortion: { kcal: 100, proteinG: 5, fatG: 3, carbsG: 12, saturatedFatG: 1, fiberG: 1, sugarG: 2, saltG: 0.1 }, + nutritionPerPortion: { + kcal: 100, + proteinG: 5, + fatG: 3, + carbsG: 12, + saturatedFatG: 1, + fiberG: 1, + sugarG: 2, + saltG: 0.1, + }, allergens: [], spiceLevel: 0, dna: { @@ -554,7 +632,9 @@ describe("cooking sessions", () => { expect(body.defaultLeftoverEstimatePortions).toBe(1); // Städa upp testreceptet. - await testDb.db.delete(schema.recipeIngredients).where(eq(schema.recipeIngredients.recipeId, testRecipeId)); + await testDb.db + .delete(schema.recipeIngredients) + .where(eq(schema.recipeIngredients.recipeId, testRecipeId)); await testDb.db.delete(schema.recipes).where(eq(schema.recipes.id, testRecipeId)); }); @@ -619,12 +699,16 @@ describe("cooking sessions", () => { }); it("undo restores inventory, removes meals, discards meal boxes and deletes recipe_cooks", async () => { - const recipe = (await app.inject({ - method: "GET", - url: `/v1/recipes/${recipeId}`, - headers: { authorization: `Bearer ${token}` }, - })).json() as { ingredients: Array<{ canonicalIngredientId: string; optional?: boolean }> }; - const firstNonOptional = recipe.ingredients.find((i) => i.canonicalIngredientId && !i.optional)?.canonicalIngredientId; + const recipe = ( + await app.inject({ + method: "GET", + url: `/v1/recipes/${recipeId}`, + headers: { authorization: `Bearer ${token}` }, + }) + ).json() as { ingredients: Array<{ canonicalIngredientId: string; optional?: boolean }> }; + const firstNonOptional = recipe.ingredients.find( + (i) => i.canonicalIngredientId && !i.optional, + )?.canonicalIngredientId; // Sätt upp ett känt lager om receptet har en icke-valfri ingrediens. let itemId: string | undefined; @@ -660,7 +744,11 @@ describe("cooking sessions", () => { payload: {}, }); expect(undo.statusCode).toBe(200); - const undoBody = JSON.parse(undo.body) as { ok: boolean; removedMealIds: string[]; discardedMealBoxIds: string[] }; + const undoBody = JSON.parse(undo.body) as { + ok: boolean; + removedMealIds: string[]; + discardedMealBoxIds: string[]; + }; expect(undoBody.ok).toBe(true); // Sessionstatus = undone. @@ -679,16 +767,25 @@ describe("cooking sessions", () => { expect(Number(txsAfter[0]!.count)).toBeGreaterThan(Number(txsBefore[0]!.count)); // Meals borttagna. - const meals = await testDb.db.select({ count: count(schema.meals.id) }).from(schema.meals).where(eq(schema.meals.cookingSessionId, sessionId)); + const meals = await testDb.db + .select({ count: count(schema.meals.id) }) + .from(schema.meals) + .where(eq(schema.meals.cookingSessionId, sessionId)); expect(Number(meals[0]!.count)).toBe(0); // Matlådor markerade discarded. - const boxes = await testDb.db.select().from(schema.mealBoxes).where(eq(schema.mealBoxes.cookingSessionId, sessionId)); + const boxes = await testDb.db + .select() + .from(schema.mealBoxes) + .where(eq(schema.mealBoxes.cookingSessionId, sessionId)); expect(boxes.length).toBe(undoBody.discardedMealBoxIds.length); for (const box of boxes) expect(box.status).toBe("discarded"); // recipe_cooks borttagen och cookCount backad. - const cooks = await testDb.db.select().from(schema.recipeCooks).where(eq(schema.recipeCooks.cookingSessionId, sessionId)); + const cooks = await testDb.db + .select() + .from(schema.recipeCooks) + .where(eq(schema.recipeCooks.cookingSessionId, sessionId)); expect(cooks.length).toBe(0); // Inventory-transaktionsinvariant. @@ -699,7 +796,11 @@ describe("cooking sessions", () => { .where(eq(schema.inventoryItems.id, itemId)) .limit(1); const itemTxs = await testDb.db - .select({ type: schema.inventoryTransactions.type, quantityDelta: schema.inventoryTransactions.quantityDelta, unit: schema.inventoryTransactions.unit }) + .select({ + type: schema.inventoryTransactions.type, + quantityDelta: schema.inventoryTransactions.quantityDelta, + unit: schema.inventoryTransactions.unit, + }) .from(schema.inventoryTransactions) .where(eq(schema.inventoryTransactions.inventoryItemId, itemId)); const balance = computeBalance(itemTxs); @@ -712,13 +813,16 @@ describe("cooking sessions", () => { .from(schema.productAnalyticsEvents) .where(eq(schema.productAnalyticsEvents.userId, userId)) .orderBy(schema.productAnalyticsEvents.occurredAt); - const props = (e: (typeof analytics)[number]) => (e.properties ?? {}) as { cookingSessionId?: string }; - expect(analytics.some((e) => e.eventName === "cooking_session_undone" && props(e).cookingSessionId === sessionId)).toBe(true); + const props = (e: (typeof analytics)[number]) => + (e.properties ?? {}) as { cookingSessionId?: string }; + expect( + analytics.some( + (e) => e.eventName === "cooking_session_undone" && props(e).cookingSessionId === sessionId, + ), + ).toBe(true); if (itemId) { - await testDb.db - .delete(schema.inventoryItems) - .where(eq(schema.inventoryItems.id, itemId)); + await testDb.db.delete(schema.inventoryItems).where(eq(schema.inventoryItems.id, itemId)); } }); @@ -771,10 +875,28 @@ describe("cooking sessions", () => { cookTimeMinutes: 10, totalTimeMinutes: 15, portions: 4, - nutritionPerPortion: { kcal: 100, proteinG: 5, fatG: 3, carbsG: 12, saturatedFatG: 1, fiberG: 1, sugarG: 2, saltG: 0.1 }, + nutritionPerPortion: { + kcal: 100, + proteinG: 5, + fatG: 3, + carbsG: 12, + saturatedFatG: 1, + fiberG: 1, + sugarG: 2, + saltG: 0.1, + }, allergens: [], spiceLevel: 0, - dna: { cuisine: "international", vegetables: [], flavorProfile: [], spiceLevel: 0, method: "stovetop", timeMinutes: 15, calories: 100, proteinGrams: 5 }, + dna: { + cuisine: "international", + vegetables: [], + flavorProfile: [], + spiceLevel: 0, + method: "stovetop", + timeMinutes: 15, + calories: 100, + proteinGrams: 5, + }, status: "published", verificationStatus: "unverified", sourceType: "own_editorial", @@ -794,7 +916,12 @@ describe("cooking sessions", () => { await testDb.db .delete(schema.inventoryItems) - .where(and(eq(schema.inventoryItems.householdId, householdId), eq(schema.inventoryItems.canonicalIngredientId, "pasta_dry"))); + .where( + and( + eq(schema.inventoryItems.householdId, householdId), + eq(schema.inventoryItems.canonicalIngredientId, "pasta_dry"), + ), + ); const itemId = await createItemWithPurchase("pasta_dry", "Pasta", 400, "GRAM"); const start = await app.inject({ @@ -838,8 +965,15 @@ describe("cooking sessions", () => { // Städa testreceptet och lagerposten. await testDb.db .delete(schema.inventoryItems) - .where(and(eq(schema.inventoryItems.householdId, householdId), eq(schema.inventoryItems.canonicalIngredientId, "pasta_dry"))); - await testDb.db.delete(schema.recipeIngredients).where(eq(schema.recipeIngredients.recipeId, testRecipeId)); + .where( + and( + eq(schema.inventoryItems.householdId, householdId), + eq(schema.inventoryItems.canonicalIngredientId, "pasta_dry"), + ), + ); + await testDb.db + .delete(schema.recipeIngredients) + .where(eq(schema.recipeIngredients.recipeId, testRecipeId)); await testDb.db.delete(schema.recipes).where(eq(schema.recipes.id, testRecipeId)); }); @@ -879,10 +1013,28 @@ describe("cooking sessions", () => { cookTimeMinutes: 10, totalTimeMinutes: 15, portions: 4, - nutritionPerPortion: { kcal: 100, proteinG: 5, fatG: 3, carbsG: 12, saturatedFatG: 1, fiberG: 1, sugarG: 2, saltG: 0.1 }, + nutritionPerPortion: { + kcal: 100, + proteinG: 5, + fatG: 3, + carbsG: 12, + saturatedFatG: 1, + fiberG: 1, + sugarG: 2, + saltG: 0.1, + }, allergens: [], spiceLevel: 0, - dna: { cuisine: "international", vegetables: [], flavorProfile: [], spiceLevel: 0, method: "stovetop", timeMinutes: 15, calories: 100, proteinGrams: 5 }, + dna: { + cuisine: "international", + vegetables: [], + flavorProfile: [], + spiceLevel: 0, + method: "stovetop", + timeMinutes: 15, + calories: 100, + proteinGrams: 5, + }, status: "published", verificationStatus: "unverified", sourceType: "own_editorial", @@ -902,7 +1054,12 @@ describe("cooking sessions", () => { await testDb.db .delete(schema.inventoryItems) - .where(and(eq(schema.inventoryItems.householdId, householdId), eq(schema.inventoryItems.canonicalIngredientId, "chicken_breast"))); + .where( + and( + eq(schema.inventoryItems.householdId, householdId), + eq(schema.inventoryItems.canonicalIngredientId, "chicken_breast"), + ), + ); const itemId = await createItemWithPurchase("chicken_breast", "Kyckling", 400, "GRAM"); const start = await app.inject({ @@ -930,13 +1087,20 @@ describe("cooking sessions", () => { expect(item[0]!.quantity).toBeCloseTo(0, 1); // Matlådan ska ha 2 portioner. - const boxes = await testDb.db.select().from(schema.mealBoxes).where(eq(schema.mealBoxes.cookingSessionId, sessionId)); + const boxes = await testDb.db + .select() + .from(schema.mealBoxes) + .where(eq(schema.mealBoxes.cookingSessionId, sessionId)); expect(boxes.length).toBe(1); expect(boxes[0]!.portions).toBe(2); // Invariant. const txs = await testDb.db - .select({ type: schema.inventoryTransactions.type, quantityDelta: schema.inventoryTransactions.quantityDelta, unit: schema.inventoryTransactions.unit }) + .select({ + type: schema.inventoryTransactions.type, + quantityDelta: schema.inventoryTransactions.quantityDelta, + unit: schema.inventoryTransactions.unit, + }) .from(schema.inventoryTransactions) .where(eq(schema.inventoryTransactions.inventoryItemId, itemId)); const balance = computeBalance(txs); @@ -944,8 +1108,15 @@ describe("cooking sessions", () => { await testDb.db .delete(schema.inventoryItems) - .where(and(eq(schema.inventoryItems.householdId, householdId), eq(schema.inventoryItems.canonicalIngredientId, "chicken_breast"))); - await testDb.db.delete(schema.recipeIngredients).where(eq(schema.recipeIngredients.recipeId, testRecipeId)); + .where( + and( + eq(schema.inventoryItems.householdId, householdId), + eq(schema.inventoryItems.canonicalIngredientId, "chicken_breast"), + ), + ); + await testDb.db + .delete(schema.recipeIngredients) + .where(eq(schema.recipeIngredients.recipeId, testRecipeId)); await testDb.db.delete(schema.recipes).where(eq(schema.recipes.id, testRecipeId)); }); @@ -966,10 +1137,28 @@ describe("cooking sessions", () => { cookTimeMinutes: 10, totalTimeMinutes: 15, portions: 4, - nutritionPerPortion: { kcal: 100, proteinG: 5, fatG: 3, carbsG: 12, saturatedFatG: 1, fiberG: 1, sugarG: 2, saltG: 0.1 }, + nutritionPerPortion: { + kcal: 100, + proteinG: 5, + fatG: 3, + carbsG: 12, + saturatedFatG: 1, + fiberG: 1, + sugarG: 2, + saltG: 0.1, + }, allergens: [], spiceLevel: 0, - dna: { cuisine: "international", vegetables: [], flavorProfile: [], spiceLevel: 0, method: "stovetop", timeMinutes: 15, calories: 100, proteinGrams: 5 }, + dna: { + cuisine: "international", + vegetables: [], + flavorProfile: [], + spiceLevel: 0, + method: "stovetop", + timeMinutes: 15, + calories: 100, + proteinGrams: 5, + }, status: "published", verificationStatus: "unverified", sourceType: "own_editorial", @@ -989,7 +1178,12 @@ describe("cooking sessions", () => { await testDb.db .delete(schema.inventoryItems) - .where(and(eq(schema.inventoryItems.householdId, householdId), eq(schema.inventoryItems.canonicalIngredientId, "carrot"))); + .where( + and( + eq(schema.inventoryItems.householdId, householdId), + eq(schema.inventoryItems.canonicalIngredientId, "carrot"), + ), + ); const itemId = await createItemWithPurchase("carrot", "Morötter", 400, "GRAM"); const start = await app.inject({ @@ -1017,7 +1211,11 @@ describe("cooking sessions", () => { expect(item[0]!.quantity).toBeCloseTo(100, 1); const txs = await testDb.db - .select({ type: schema.inventoryTransactions.type, quantityDelta: schema.inventoryTransactions.quantityDelta, unit: schema.inventoryTransactions.unit }) + .select({ + type: schema.inventoryTransactions.type, + quantityDelta: schema.inventoryTransactions.quantityDelta, + unit: schema.inventoryTransactions.unit, + }) .from(schema.inventoryTransactions) .where(eq(schema.inventoryTransactions.inventoryItemId, itemId)); const balance = computeBalance(txs); @@ -1025,8 +1223,15 @@ describe("cooking sessions", () => { await testDb.db .delete(schema.inventoryItems) - .where(and(eq(schema.inventoryItems.householdId, householdId), eq(schema.inventoryItems.canonicalIngredientId, "carrot"))); - await testDb.db.delete(schema.recipeIngredients).where(eq(schema.recipeIngredients.recipeId, testRecipeId)); + .where( + and( + eq(schema.inventoryItems.householdId, householdId), + eq(schema.inventoryItems.canonicalIngredientId, "carrot"), + ), + ); + await testDb.db + .delete(schema.recipeIngredients) + .where(eq(schema.recipeIngredients.recipeId, testRecipeId)); await testDb.db.delete(schema.recipes).where(eq(schema.recipes.id, testRecipeId)); }); @@ -1047,10 +1252,28 @@ describe("cooking sessions", () => { cookTimeMinutes: 10, totalTimeMinutes: 15, portions: 4, - nutritionPerPortion: { kcal: 100, proteinG: 5, fatG: 3, carbsG: 12, saturatedFatG: 1, fiberG: 1, sugarG: 2, saltG: 0.1 }, + nutritionPerPortion: { + kcal: 100, + proteinG: 5, + fatG: 3, + carbsG: 12, + saturatedFatG: 1, + fiberG: 1, + sugarG: 2, + saltG: 0.1, + }, allergens: [], spiceLevel: 0, - dna: { cuisine: "international", vegetables: [], flavorProfile: [], spiceLevel: 0, method: "stovetop", timeMinutes: 15, calories: 100, proteinGrams: 5 }, + dna: { + cuisine: "international", + vegetables: [], + flavorProfile: [], + spiceLevel: 0, + method: "stovetop", + timeMinutes: 15, + calories: 100, + proteinGrams: 5, + }, status: "published", verificationStatus: "unverified", sourceType: "own_editorial", @@ -1070,7 +1293,12 @@ describe("cooking sessions", () => { await testDb.db .delete(schema.inventoryItems) - .where(and(eq(schema.inventoryItems.householdId, householdId), eq(schema.inventoryItems.canonicalIngredientId, "potato"))); + .where( + and( + eq(schema.inventoryItems.householdId, householdId), + eq(schema.inventoryItems.canonicalIngredientId, "potato"), + ), + ); const itemId = await createItemWithPurchase("potato", "Potatis", 400, "GRAM"); const start = await app.inject({ @@ -1099,13 +1327,20 @@ describe("cooking sessions", () => { expect(item[0]!.quantity).toBeCloseTo(0, 1); // Lådan ska ha 1 portion. - const boxes = await testDb.db.select().from(schema.mealBoxes).where(eq(schema.mealBoxes.cookingSessionId, sessionId)); + const boxes = await testDb.db + .select() + .from(schema.mealBoxes) + .where(eq(schema.mealBoxes.cookingSessionId, sessionId)); expect(boxes.length).toBe(1); expect(boxes[0]!.portions).toBe(1); // Invariant. const txs = await testDb.db - .select({ type: schema.inventoryTransactions.type, quantityDelta: schema.inventoryTransactions.quantityDelta, unit: schema.inventoryTransactions.unit }) + .select({ + type: schema.inventoryTransactions.type, + quantityDelta: schema.inventoryTransactions.quantityDelta, + unit: schema.inventoryTransactions.unit, + }) .from(schema.inventoryTransactions) .where(eq(schema.inventoryTransactions.inventoryItemId, itemId)); const balance = computeBalance(txs); @@ -1113,8 +1348,15 @@ describe("cooking sessions", () => { await testDb.db .delete(schema.inventoryItems) - .where(and(eq(schema.inventoryItems.householdId, householdId), eq(schema.inventoryItems.canonicalIngredientId, "potato"))); - await testDb.db.delete(schema.recipeIngredients).where(eq(schema.recipeIngredients.recipeId, testRecipeId)); + .where( + and( + eq(schema.inventoryItems.householdId, householdId), + eq(schema.inventoryItems.canonicalIngredientId, "potato"), + ), + ); + await testDb.db + .delete(schema.recipeIngredients) + .where(eq(schema.recipeIngredients.recipeId, testRecipeId)); await testDb.db.delete(schema.recipes).where(eq(schema.recipes.id, testRecipeId)); }); @@ -1135,10 +1377,28 @@ describe("cooking sessions", () => { cookTimeMinutes: 10, totalTimeMinutes: 15, portions: 4, - nutritionPerPortion: { kcal: 100, proteinG: 5, fatG: 3, carbsG: 12, saturatedFatG: 1, fiberG: 1, sugarG: 2, saltG: 0.1 }, + nutritionPerPortion: { + kcal: 100, + proteinG: 5, + fatG: 3, + carbsG: 12, + saturatedFatG: 1, + fiberG: 1, + sugarG: 2, + saltG: 0.1, + }, allergens: [], spiceLevel: 0, - dna: { cuisine: "international", vegetables: [], flavorProfile: [], spiceLevel: 0, method: "stovetop", timeMinutes: 15, calories: 100, proteinGrams: 5 }, + dna: { + cuisine: "international", + vegetables: [], + flavorProfile: [], + spiceLevel: 0, + method: "stovetop", + timeMinutes: 15, + calories: 100, + proteinGrams: 5, + }, status: "published", verificationStatus: "unverified", sourceType: "own_editorial", @@ -1158,7 +1418,12 @@ describe("cooking sessions", () => { await testDb.db .delete(schema.inventoryItems) - .where(and(eq(schema.inventoryItems.householdId, householdId), eq(schema.inventoryItems.canonicalIngredientId, "rice_white"))); + .where( + and( + eq(schema.inventoryItems.householdId, householdId), + eq(schema.inventoryItems.canonicalIngredientId, "rice_white"), + ), + ); const itemId = await createItemWithPurchase("rice_white", "Ris", 400, "GRAM"); async function assertInvariant() { @@ -1168,7 +1433,11 @@ describe("cooking sessions", () => { .where(eq(schema.inventoryItems.id, itemId)) .limit(1); const txs = await testDb.db - .select({ type: schema.inventoryTransactions.type, quantityDelta: schema.inventoryTransactions.quantityDelta, unit: schema.inventoryTransactions.unit }) + .select({ + type: schema.inventoryTransactions.type, + quantityDelta: schema.inventoryTransactions.quantityDelta, + unit: schema.inventoryTransactions.unit, + }) .from(schema.inventoryTransactions) .where(eq(schema.inventoryTransactions.inventoryItemId, itemId)); const balance = computeBalance(txs); @@ -1217,8 +1486,15 @@ describe("cooking sessions", () => { await testDb.db .delete(schema.inventoryItems) - .where(and(eq(schema.inventoryItems.householdId, householdId), eq(schema.inventoryItems.canonicalIngredientId, "rice_white"))); - await testDb.db.delete(schema.recipeIngredients).where(eq(schema.recipeIngredients.recipeId, testRecipeId)); + .where( + and( + eq(schema.inventoryItems.householdId, householdId), + eq(schema.inventoryItems.canonicalIngredientId, "rice_white"), + ), + ); + await testDb.db + .delete(schema.recipeIngredients) + .where(eq(schema.recipeIngredients.recipeId, testRecipeId)); await testDb.db.delete(schema.recipes).where(eq(schema.recipes.id, testRecipeId)); }); @@ -1247,26 +1523,40 @@ describe("cooking sessions", () => { .limit(1); expect(session[0]!.status).toBe("undone"); - const boxes = await testDb.db.select().from(schema.mealBoxes).where(eq(schema.mealBoxes.cookingSessionId, sessionId)); + const boxes = await testDb.db + .select() + .from(schema.mealBoxes) + .where(eq(schema.mealBoxes.cookingSessionId, sessionId)); expect(boxes.every((b) => b.status === "discarded")).toBe(true); }); it("undo rolls back cooking assumption profiles", async () => { - await testDb.db.delete(schema.cookingAssumptionProfiles).where(eq(schema.cookingAssumptionProfiles.householdId, householdId)); + await testDb.db + .delete(schema.cookingAssumptionProfiles) + .where(eq(schema.cookingAssumptionProfiles.householdId, householdId)); - const recipe = (await app.inject({ - method: "GET", - url: `/v1/recipes/${recipeId}`, - headers: { authorization: `Bearer ${token}` }, - })).json() as { ingredients: Array<{ canonicalIngredientId: string; optional?: boolean }> }; - const firstNonOptionalIngredientId = recipe.ingredients.find((i) => i.canonicalIngredientId && !i.optional)?.canonicalIngredientId; + const recipe = ( + await app.inject({ + method: "GET", + url: `/v1/recipes/${recipeId}`, + headers: { authorization: `Bearer ${token}` }, + }) + ).json() as { ingredients: Array<{ canonicalIngredientId: string; optional?: boolean }> }; + const firstNonOptionalIngredientId = recipe.ingredients.find( + (i) => i.canonicalIngredientId && !i.optional, + )?.canonicalIngredientId; expect(firstNonOptionalIngredientId).toBeDefined(); const cook = await app.inject({ method: "POST", url: `/v1/recipes/${recipeId}/cook`, headers: { authorization: `Bearer ${token}` }, - payload: { portionsCooked: 4, actualPortionsEaten: 3, leftoverEstimatePortions: 1, deductInventory: true }, + payload: { + portionsCooked: 4, + actualPortionsEaten: 3, + leftoverEstimatePortions: 1, + deductInventory: true, + }, }); const { sessionId } = JSON.parse(cook.body) as { sessionId: string }; @@ -1315,12 +1605,20 @@ describe("cooking sessions", () => { method: "POST", url: `/v1/recipes/${recipeId}/cook`, headers: { authorization: `Bearer ${token}` }, - payload: { portionsCooked: 4, mealBoxPortions: 1, mealBoxFrozen: false, deductInventory: true }, + payload: { + portionsCooked: 4, + mealBoxPortions: 1, + mealBoxFrozen: false, + deductInventory: true, + }, }); expect(cook1.statusCode).toBe(200); const { sessionId: sessionId1 } = JSON.parse(cook1.body) as { sessionId: string }; - const boxes1 = await testDb.db.select().from(schema.mealBoxes).where(eq(schema.mealBoxes.cookingSessionId, sessionId1)); + const boxes1 = await testDb.db + .select() + .from(schema.mealBoxes) + .where(eq(schema.mealBoxes.cookingSessionId, sessionId1)); expect(boxes1.length).toBe(1); const boxId = boxes1[0]!.id; @@ -1328,12 +1626,20 @@ describe("cooking sessions", () => { method: "POST", url: `/v1/recipes/${recipeId}/cook`, headers: { authorization: `Bearer ${token}` }, - payload: { portionsCooked: 4, mealBoxPortions: 2, mealBoxFrozen: false, deductInventory: true }, + payload: { + portionsCooked: 4, + mealBoxPortions: 2, + mealBoxFrozen: false, + deductInventory: true, + }, }); expect(cook2.statusCode).toBe(200); const { sessionId: sessionId2 } = JSON.parse(cook2.body) as { sessionId: string }; - const boxes2 = await testDb.db.select().from(schema.mealBoxes).where(eq(schema.mealBoxes.id, boxId)); + const boxes2 = await testDb.db + .select() + .from(schema.mealBoxes) + .where(eq(schema.mealBoxes.id, boxId)); expect(boxes2[0]!.portions).toBe(3); expect(boxes2[0]!.portionsRemaining).toBe(3); @@ -1352,7 +1658,13 @@ describe("cooking sessions", () => { method: "POST", url: `/v1/recipes/${recipeId}/cook`, headers: { authorization: `Bearer ${token}` }, - payload: { portionsCooked: 4, mealBoxPortions: 1, mealBoxFrozen: false, date: yesterday, deductInventory: true }, + payload: { + portionsCooked: 4, + mealBoxPortions: 1, + mealBoxFrozen: false, + date: yesterday, + deductInventory: true, + }, }); expect(cook1.statusCode).toBe(200); const { sessionId: sessionId1 } = JSON.parse(cook1.body) as { sessionId: string }; @@ -1361,7 +1673,12 @@ describe("cooking sessions", () => { method: "POST", url: `/v1/recipes/${recipeId}/cook`, headers: { authorization: `Bearer ${token}` }, - payload: { portionsCooked: 4, mealBoxPortions: 1, mealBoxFrozen: false, deductInventory: true }, + payload: { + portionsCooked: 4, + mealBoxPortions: 1, + mealBoxFrozen: false, + deductInventory: true, + }, }); expect(cook2.statusCode).toBe(200); const { sessionId: sessionId2 } = JSON.parse(cook2.body) as { sessionId: string }; @@ -1385,7 +1702,12 @@ describe("cooking sessions", () => { method: "POST", url: `/v1/recipes/${recipeId}/cook`, headers: { authorization: `Bearer ${token}` }, - payload: { portionsCooked: 4, mealBoxPortions: 1, mealBoxFrozen: false, deductInventory: true }, + payload: { + portionsCooked: 4, + mealBoxPortions: 1, + mealBoxFrozen: false, + deductInventory: true, + }, }); expect(cook1.statusCode).toBe(200); const { sessionId: sessionId1 } = JSON.parse(cook1.body) as { sessionId: string }; @@ -1394,7 +1716,12 @@ describe("cooking sessions", () => { method: "POST", url: `/v1/recipes/${recipeId}/cook`, headers: { authorization: `Bearer ${token}` }, - payload: { portionsCooked: 4, mealBoxPortions: 1, mealBoxFrozen: true, deductInventory: true }, + payload: { + portionsCooked: 4, + mealBoxPortions: 1, + mealBoxFrozen: true, + deductInventory: true, + }, }); expect(cook2.statusCode).toBe(200); const { sessionId: sessionId2 } = JSON.parse(cook2.body) as { sessionId: string }; @@ -1420,20 +1747,38 @@ describe("cooking sessions", () => { method: "POST", url: `/v1/recipes/${recipeId}/cook`, headers: { authorization: `Bearer ${token}` }, - payload: { portionsCooked: 4, mealBoxPortions: 1, mealBoxFrozen: false, deductInventory: true }, + payload: { + portionsCooked: 4, + mealBoxPortions: 1, + mealBoxFrozen: false, + deductInventory: true, + }, }); const { sessionId: sessionId1 } = JSON.parse(cook1.body) as { sessionId: string }; - const boxId = (await testDb.db.select().from(schema.mealBoxes).where(eq(schema.mealBoxes.cookingSessionId, sessionId1)))[0]!.id; + const boxId = ( + await testDb.db + .select() + .from(schema.mealBoxes) + .where(eq(schema.mealBoxes.cookingSessionId, sessionId1)) + )[0]!.id; const cook2 = await app.inject({ method: "POST", url: `/v1/recipes/${recipeId}/cook`, headers: { authorization: `Bearer ${token}` }, - payload: { portionsCooked: 4, mealBoxPortions: 2, mealBoxFrozen: false, deductInventory: true }, + payload: { + portionsCooked: 4, + mealBoxPortions: 2, + mealBoxFrozen: false, + deductInventory: true, + }, }); const { sessionId: sessionId2 } = JSON.parse(cook2.body) as { sessionId: string }; - const beforeUndo = await testDb.db.select().from(schema.mealBoxes).where(eq(schema.mealBoxes.id, boxId)); + const beforeUndo = await testDb.db + .select() + .from(schema.mealBoxes) + .where(eq(schema.mealBoxes.id, boxId)); expect(beforeUndo[0]!.portions).toBe(3); const undo = await app.inject({ @@ -1444,7 +1789,10 @@ describe("cooking sessions", () => { }); expect(undo.statusCode).toBe(200); - const afterUndo = await testDb.db.select().from(schema.mealBoxes).where(eq(schema.mealBoxes.id, boxId)); + const afterUndo = await testDb.db + .select() + .from(schema.mealBoxes) + .where(eq(schema.mealBoxes.id, boxId)); expect(afterUndo[0]!.portions).toBe(1); expect(afterUndo[0]!.portionsRemaining).toBe(1); expect(afterUndo[0]!.status).toBe("available"); @@ -1469,7 +1817,10 @@ describe("cooking sessions", () => { }); expect(undo.statusCode).toBe(200); - const boxes = await testDb.db.select().from(schema.mealBoxes).where(eq(schema.mealBoxes.cookingSessionId, sessionId)); + const boxes = await testDb.db + .select() + .from(schema.mealBoxes) + .where(eq(schema.mealBoxes.cookingSessionId, sessionId)); expect(boxes[0]!.status).toBe("discarded"); expect(boxes[0]!.portionsRemaining).toBe(0); }); @@ -1504,7 +1855,11 @@ describe("cooking sessions", () => { .from(schema.inventoryTransactions) .where(eq(schema.inventoryTransactions.inventoryItemId, itemId)); const balance = computeBalance(txs); - const item = await testDb.db.select().from(schema.inventoryItems).where(eq(schema.inventoryItems.id, itemId)).limit(1); + const item = await testDb.db + .select() + .from(schema.inventoryItems) + .where(eq(schema.inventoryItems.id, itemId)) + .limit(1); expect(item[0]!.quantity).toBeCloseTo(balance.balance, 6); }; diff --git a/apps/api/test/i18n-memory.test.ts b/apps/api/test/i18n-memory.test.ts index 9a62977..40c199b 100644 --- a/apps/api/test/i18n-memory.test.ts +++ b/apps/api/test/i18n-memory.test.ts @@ -61,7 +61,10 @@ describe("S6 minnes-i18n + transparens-paritet", () => { expect(renderMemorySummary({ summarySv: "", value }, "sv-SE")).toBe(expectedSv); for (const lang of EXPECTED_LANGS) { const rendered = renderMemorySummary({ summarySv: "", value }, `${lang}-XX`); - expect(rendered.length, `tom summary för ${lang}, ${JSON.stringify(value)}`).toBeGreaterThan(0); + expect( + rendered.length, + `tom summary för ${lang}, ${JSON.stringify(value)}`, + ).toBeGreaterThan(0); expect(rendered).not.toBe(""); } } diff --git a/apps/api/test/inventory-natural-search.test.ts b/apps/api/test/inventory-natural-search.test.ts index b4c6695..be5ef90 100644 --- a/apps/api/test/inventory-natural-search.test.ts +++ b/apps/api/test/inventory-natural-search.test.ts @@ -52,10 +52,18 @@ describe("GET /v1/inventory/natural-search", () => { .where(eq(schema.householdMembers.userId, u.id)); const householdIds = memberships.map((m) => m.householdId); if (householdIds.length > 0) { - await testDb.db.delete(schema.inventoryItems).where(inArray(schema.inventoryItems.householdId, householdIds)); - await testDb.db.delete(schema.storageLocations).where(inArray(schema.storageLocations.householdId, householdIds)); - await testDb.db.delete(schema.householdMembers).where(inArray(schema.householdMembers.householdId, householdIds)); - await testDb.db.delete(schema.households).where(inArray(schema.households.id, householdIds)); + await testDb.db + .delete(schema.inventoryItems) + .where(inArray(schema.inventoryItems.householdId, householdIds)); + await testDb.db + .delete(schema.storageLocations) + .where(inArray(schema.storageLocations.householdId, householdIds)); + await testDb.db + .delete(schema.householdMembers) + .where(inArray(schema.householdMembers.householdId, householdIds)); + await testDb.db + .delete(schema.households) + .where(inArray(schema.households.id, householdIds)); } await testDb.db.delete(schema.users).where(eq(schema.users.id, u.id)); } @@ -69,7 +77,10 @@ describe("GET /v1/inventory/natural-search", () => { const [household] = await testDb.db .select({ id: schema.households.id }) .from(schema.households) - .innerJoin(schema.householdMembers, eq(schema.households.id, schema.householdMembers.householdId)) + .innerJoin( + schema.householdMembers, + eq(schema.households.id, schema.householdMembers.householdId), + ) .where(eq(schema.householdMembers.userId, userId)) .limit(1); diff --git a/apps/api/test/inventory.test.ts b/apps/api/test/inventory.test.ts index 96a8d40..0b6ac26 100644 --- a/apps/api/test/inventory.test.ts +++ b/apps/api/test/inventory.test.ts @@ -20,18 +20,29 @@ describe("inventory trust read-time computation", () => { .from(schema.users) .where(inArray(schema.users.email, [userEmail])); for (const u of existing) { - await testDb.db.delete(schema.inventoryTransactions).where(eq(schema.inventoryTransactions.actorUserId, u.id)); + await testDb.db + .delete(schema.inventoryTransactions) + .where(eq(schema.inventoryTransactions.actorUserId, u.id)); const owned = await testDb.db .select({ id: schema.households.id }) .from(schema.households) - .innerJoin(schema.householdMembers, eq(schema.householdMembers.householdId, schema.households.id)) + .innerJoin( + schema.householdMembers, + eq(schema.householdMembers.householdId, schema.households.id), + ) .where(eq(schema.householdMembers.userId, u.id)); for (const h of owned) { - await testDb.db.delete(schema.inventoryItems).where(eq(schema.inventoryItems.householdId, h.id)); - await testDb.db.delete(schema.storageLocations).where(eq(schema.storageLocations.householdId, h.id)); + await testDb.db + .delete(schema.inventoryItems) + .where(eq(schema.inventoryItems.householdId, h.id)); + await testDb.db + .delete(schema.storageLocations) + .where(eq(schema.storageLocations.householdId, h.id)); await testDb.db.delete(schema.households).where(eq(schema.households.id, h.id)); } - await testDb.db.delete(schema.householdMembers).where(eq(schema.householdMembers.userId, u.id)); + await testDb.db + .delete(schema.householdMembers) + .where(eq(schema.householdMembers.userId, u.id)); await testDb.db.delete(schema.userPreferences).where(eq(schema.userPreferences.userId, u.id)); await testDb.db.delete(schema.users).where(eq(schema.users.id, u.id)); } @@ -99,7 +110,9 @@ describe("inventory trust read-time computation", () => { }); expect(res.statusCode).toBe(200); - const body = JSON.parse(res.body) as { items: Array<{ id: string; trustState: string; trustScore: number }> }; + const body = JSON.parse(res.body) as { + items: Array<{ id: string; trustState: string; trustScore: number }>; + }; const found = body.items.find((i) => i.id === item!.id); expect(found).toBeTruthy(); expect(["decaying", "stale"]).toContain(found!.trustState); diff --git a/apps/api/test/me.residual.test.ts b/apps/api/test/me.residual.test.ts index 756e078..ca70bd8 100644 --- a/apps/api/test/me.residual.test.ts +++ b/apps/api/test/me.residual.test.ts @@ -28,8 +28,12 @@ describe("DELETE /v1/me — GDPR residual completeness", () => { await testDb.db.delete(schema.recipeRatings).where(eq(schema.recipeRatings.userId, u.id)); await testDb.db.delete(schema.recipeFavorites).where(eq(schema.recipeFavorites.userId, u.id)); await testDb.db.delete(schema.recipeCooks).where(eq(schema.recipeCooks.userId, u.id)); - await testDb.db.delete(schema.creatorFollows).where(eq(schema.creatorFollows.followerUserId, u.id)); - await testDb.db.delete(schema.creatorFollows).where(eq(schema.creatorFollows.creatorUserId, u.id)); + await testDb.db + .delete(schema.creatorFollows) + .where(eq(schema.creatorFollows.followerUserId, u.id)); + await testDb.db + .delete(schema.creatorFollows) + .where(eq(schema.creatorFollows.creatorUserId, u.id)); await testDb.db.delete(schema.creatorStats).where(eq(schema.creatorStats.userId, u.id)); await testDb.db.delete(schema.recipes).where(eq(schema.recipes.creatorUserId, u.id)); await testDb.db.delete(schema.foodMemories).where(eq(schema.foodMemories.userId, u.id)); @@ -37,8 +41,12 @@ describe("DELETE /v1/me — GDPR residual completeness", () => { await testDb.db.delete(schema.tasteSignals).where(eq(schema.tasteSignals.userId, u.id)); await testDb.db.delete(schema.meals).where(eq(schema.meals.userId, u.id)); await testDb.db.delete(schema.subscriptions).where(eq(schema.subscriptions.userId, u.id)); - await testDb.db.delete(schema.subscriptionEvents).where(eq(schema.subscriptionEvents.userId, u.id)); - await testDb.db.delete(schema.storeTransactions).where(eq(schema.storeTransactions.userId, u.id)); + await testDb.db + .delete(schema.subscriptionEvents) + .where(eq(schema.subscriptionEvents.userId, u.id)); + await testDb.db + .delete(schema.storeTransactions) + .where(eq(schema.storeTransactions.userId, u.id)); await testDb.db.delete(schema.trials).where(eq(schema.trials.userId, u.id)); await testDb.db.delete(schema.aiUsageCounters).where(eq(schema.aiUsageCounters.userId, u.id)); await testDb.db.delete(schema.notifications).where(eq(schema.notifications.userId, u.id)); @@ -46,22 +54,34 @@ describe("DELETE /v1/me — GDPR residual completeness", () => { await testDb.db.delete(schema.idempotencyKeys).where(eq(schema.idempotencyKeys.userId, u.id)); await testDb.db.delete(schema.userConsents).where(eq(schema.userConsents.userId, u.id)); await testDb.db.delete(schema.userPreferences).where(eq(schema.userPreferences.userId, u.id)); - await testDb.db.delete(schema.userHealthProfiles).where(eq(schema.userHealthProfiles.userId, u.id)); - await testDb.db.delete(schema.userLocalePreferences).where(eq(schema.userLocalePreferences.userId, u.id)); + await testDb.db + .delete(schema.userHealthProfiles) + .where(eq(schema.userHealthProfiles.userId, u.id)); + await testDb.db + .delete(schema.userLocalePreferences) + .where(eq(schema.userLocalePreferences.userId, u.id)); await testDb.db.delete(schema.userCredentials).where(eq(schema.userCredentials.userId, u.id)); await testDb.db.delete(schema.refreshTokens).where(eq(schema.refreshTokens.userId, u.id)); - await testDb.db.delete(schema.emailVerificationTokens).where(eq(schema.emailVerificationTokens.userId, u.id)); - await testDb.db.delete(schema.passwordResetTokens).where(eq(schema.passwordResetTokens.userId, u.id)); + await testDb.db + .delete(schema.emailVerificationTokens) + .where(eq(schema.emailVerificationTokens.userId, u.id)); + await testDb.db + .delete(schema.passwordResetTokens) + .where(eq(schema.passwordResetTokens.userId, u.id)); await testDb.db.delete(schema.adminTotp).where(eq(schema.adminTotp.userId, u.id)); await testDb.db.delete(schema.auditLogs).where(eq(schema.auditLogs.actorUserId, u.id)); await testDb.db.delete(schema.domainEvents).where(eq(schema.domainEvents.userId, u.id)); - await testDb.db.delete(schema.productAnalyticsEvents).where(eq(schema.productAnalyticsEvents.userId, u.id)); + await testDb.db + .delete(schema.productAnalyticsEvents) + .where(eq(schema.productAnalyticsEvents.userId, u.id)); // Remove memberships and any orphaned single-member households. const memberships = await testDb.db .select({ householdId: schema.householdMembers.householdId }) .from(schema.householdMembers) .where(eq(schema.householdMembers.userId, u.id)); - await testDb.db.delete(schema.householdMembers).where(eq(schema.householdMembers.userId, u.id)); + await testDb.db + .delete(schema.householdMembers) + .where(eq(schema.householdMembers.userId, u.id)); for (const { householdId } of memberships) { const remaining = await testDb.db .select({ count: sql`count(*)::int` }) @@ -124,7 +144,9 @@ describe("DELETE /v1/me — GDPR residual completeness", () => { { userId, kind: "personalization", status: "granted" }, { userId, kind: "image_training", status: "granted" }, ]); - await testDb.db.insert(schema.pushTokens).values({ userId, token: "expo-token-1", platform: "ios" }); + await testDb.db + .insert(schema.pushTokens) + .values({ userId, token: "expo-token-1", platform: "ios" }); await testDb.db.insert(schema.notifications).values({ userId, type: "subscription_status", @@ -201,7 +223,16 @@ describe("DELETE /v1/me — GDPR residual completeness", () => { source: "manual", titleSv: "Resttest-lunch", date: new Date().toISOString().slice(0, 10), - nutrition: { kcal: 0, proteinG: 0, carbsG: 0, fatG: 0, saturatedFatG: 0, fiberG: 0, sugarG: 0, saltG: 0 }, + nutrition: { + kcal: 0, + proteinG: 0, + carbsG: 0, + fatG: 0, + saturatedFatG: 0, + fiberG: 0, + sugarG: 0, + saltG: 0, + }, }); const [draftRecipe] = await testDb.db @@ -215,7 +246,16 @@ describe("DELETE /v1/me — GDPR residual completeness", () => { creatorUserId: userId, creatorDisplayName: "Test", status: "draft", - nutritionPerPortion: { kcal: 0, proteinG: 0, carbsG: 0, fatG: 0, saturatedFatG: 0, fiberG: 0, sugarG: 0, saltG: 0 }, + nutritionPerPortion: { + kcal: 0, + proteinG: 0, + carbsG: 0, + fatG: 0, + saturatedFatG: 0, + fiberG: 0, + sugarG: 0, + saltG: 0, + }, dna: { cuisine: "swedish", vegetables: [], @@ -241,7 +281,16 @@ describe("DELETE /v1/me — GDPR residual completeness", () => { creatorDisplayName: "Test", status: "published", verificationStatus: "editorial", - nutritionPerPortion: { kcal: 0, proteinG: 0, carbsG: 0, fatG: 0, saturatedFatG: 0, fiberG: 0, sugarG: 0, saltG: 0 }, + nutritionPerPortion: { + kcal: 0, + proteinG: 0, + carbsG: 0, + fatG: 0, + saturatedFatG: 0, + fiberG: 0, + sugarG: 0, + saltG: 0, + }, dna: { cuisine: "swedish", vegetables: [], @@ -255,9 +304,15 @@ describe("DELETE /v1/me — GDPR residual completeness", () => { }) .returning(); - await testDb.db.insert(schema.recipeRatings).values({ recipeId: publishedRecipe!.id, userId, stars: 5 }); - await testDb.db.insert(schema.recipeFavorites).values({ recipeId: publishedRecipe!.id, userId }); - await testDb.db.insert(schema.recipeCooks).values({ recipeId: publishedRecipe!.id, userId, portionsCooked: 2 }); + await testDb.db + .insert(schema.recipeRatings) + .values({ recipeId: publishedRecipe!.id, userId, stars: 5 }); + await testDb.db + .insert(schema.recipeFavorites) + .values({ recipeId: publishedRecipe!.id, userId }); + await testDb.db + .insert(schema.recipeCooks) + .values({ recipeId: publishedRecipe!.id, userId, portionsCooked: 2 }); await testDb.db.insert(schema.creatorStats).values({ userId }); await testDb.db.insert(schema.subscriptions).values({ @@ -431,7 +486,10 @@ describe("DELETE /v1/me — GDPR residual completeness", () => { const result = await testDb.db.execute<{ count: number }>( sql.raw(`SELECT count(*)::int AS count FROM "${table}" WHERE "${column}" = '${userId}'`), ); - expect(Number(result.rows[0]?.count ?? 0), `Residual ${table}.${column} for deleted user`).toBe(0); + expect( + Number(result.rows[0]?.count ?? 0), + `Residual ${table}.${column} for deleted user`, + ).toBe(0); } // Receipts in surviving households must have image stripped. @@ -440,12 +498,18 @@ describe("DELETE /v1/me — GDPR residual completeness", () => { .from(schema.receipts) .where(eq(schema.receipts.householdId, (await getHouseholdId(userId))!)); for (const r of survivingReceipts) { - expect(r.imageUrl, "Receipt image must be null after deletion in surviving household").toBeNull(); + expect( + r.imageUrl, + "Receipt image must be null after deletion in surviving household", + ).toBeNull(); } // Public recipe must be anonymized. const [publicAfter] = await testDb.db - .select({ creatorUserId: schema.recipes.creatorUserId, creatorDisplayName: schema.recipes.creatorDisplayName }) + .select({ + creatorUserId: schema.recipes.creatorUserId, + creatorDisplayName: schema.recipes.creatorDisplayName, + }) .from(schema.recipes) .where(eq(schema.recipes.id, publishedRecipe!.id)); expect(publicAfter?.creatorUserId).toBeNull(); @@ -460,7 +524,11 @@ describe("DELETE /v1/me — GDPR residual completeness", () => { // User row is soft-deleted and anonymized. const [userAfter] = await testDb.db - .select({ email: schema.users.email, displayName: schema.users.displayName, deletedAt: schema.users.deletedAt }) + .select({ + email: schema.users.email, + displayName: schema.users.displayName, + deletedAt: schema.users.deletedAt, + }) .from(schema.users) .where(eq(schema.users.id, userId)); expect(userAfter?.deletedAt).not.toBeNull(); diff --git a/apps/api/test/me.test.ts b/apps/api/test/me.test.ts index 1dee28b..4bc8496 100644 --- a/apps/api/test/me.test.ts +++ b/apps/api/test/me.test.ts @@ -21,14 +21,12 @@ describe("DELETE /v1/me — GDPR-radering", () => { .from(schema.aiCorrections) .where(eq(schema.aiCorrections.userId, u.id)); if (corrections.length > 0) { - await testDb.db - .delete(schema.aiTrainingBank) - .where( - inArray( - schema.aiTrainingBank.correctionId, - corrections.map((r) => r.id), - ), - ); + await testDb.db.delete(schema.aiTrainingBank).where( + inArray( + schema.aiTrainingBank.correctionId, + corrections.map((r) => r.id), + ), + ); } await testDb.db.delete(schema.aiCorrections).where(eq(schema.aiCorrections.userId, u.id)); await testDb.db.delete(schema.scanJobs).where(eq(schema.scanJobs.userId, u.id)); diff --git a/apps/api/test/memory.test.ts b/apps/api/test/memory.test.ts index 9656a8c..2e7bba2 100644 --- a/apps/api/test/memory.test.ts +++ b/apps/api/test/memory.test.ts @@ -41,7 +41,9 @@ describe("/v1/me/memory", () => { await testDb.db.delete(schema.tasteSignals).where(eq(schema.tasteSignals.userId, u.id)); await testDb.db.delete(schema.userConsents).where(eq(schema.userConsents.userId, u.id)); await testDb.db.delete(schema.userPreferences).where(eq(schema.userPreferences.userId, u.id)); - await testDb.db.delete(schema.householdMembers).where(eq(schema.householdMembers.userId, u.id)); + await testDb.db + .delete(schema.householdMembers) + .where(eq(schema.householdMembers.userId, u.id)); const ownedHouseholds = await testDb.db .select({ id: schema.households.id }) .from(schema.households) @@ -53,7 +55,9 @@ describe("/v1/me/memory", () => { and(eq(schema.householdMembers.userId, u.id), eq(schema.householdMembers.role, "owner")), ); for (const h of ownedHouseholds) { - await testDb.db.delete(schema.storageLocations).where(eq(schema.storageLocations.householdId, h.id)); + await testDb.db + .delete(schema.storageLocations) + .where(eq(schema.storageLocations.householdId, h.id)); await testDb.db.delete(schema.households).where(eq(schema.households.id, h.id)); } await testDb.db.delete(schema.users).where(eq(schema.users.id, u.id)); @@ -100,7 +104,11 @@ describe("/v1/me/memory", () => { }); expect(patchRes.statusCode).toBe(200); - const body = JSON.parse(patchRes.body) as { origin: string; confidence: number; verifiedByUser: boolean }; + const body = JSON.parse(patchRes.body) as { + origin: string; + confidence: number; + verifiedByUser: boolean; + }; expect(body.origin).toBe("user_stated"); expect(body.confidence).toBe(1); expect(body.verifiedByUser).toBe(true); @@ -237,7 +245,9 @@ describe("DELETE /v1/me/memory GDPR-regression", () => { await testDb.db.delete(schema.tasteSignals).where(eq(schema.tasteSignals.userId, u.id)); await testDb.db.delete(schema.userConsents).where(eq(schema.userConsents.userId, u.id)); await testDb.db.delete(schema.userPreferences).where(eq(schema.userPreferences.userId, u.id)); - await testDb.db.delete(schema.householdMembers).where(eq(schema.householdMembers.userId, u.id)); + await testDb.db + .delete(schema.householdMembers) + .where(eq(schema.householdMembers.userId, u.id)); const ownedHouseholds = await testDb.db .select({ id: schema.households.id }) .from(schema.households) @@ -249,7 +259,9 @@ describe("DELETE /v1/me/memory GDPR-regression", () => { and(eq(schema.householdMembers.userId, u.id), eq(schema.householdMembers.role, "owner")), ); for (const h of ownedHouseholds) { - await testDb.db.delete(schema.storageLocations).where(eq(schema.storageLocations.householdId, h.id)); + await testDb.db + .delete(schema.storageLocations) + .where(eq(schema.storageLocations.householdId, h.id)); await testDb.db.delete(schema.households).where(eq(schema.households.id, h.id)); } await testDb.db.delete(schema.users).where(eq(schema.users.id, u.id)); diff --git a/apps/api/test/onboarding-memory.test.ts b/apps/api/test/onboarding-memory.test.ts index ece9cd9..0a91fdd 100644 --- a/apps/api/test/onboarding-memory.test.ts +++ b/apps/api/test/onboarding-memory.test.ts @@ -19,7 +19,9 @@ describe("S5 — onboarding → minne + smaksignaler", () => { await testDb.db.delete(schema.memoryItems).where(eq(schema.memoryItems.userId, u.id)); await testDb.db.delete(schema.tasteSignals).where(eq(schema.tasteSignals.userId, u.id)); await testDb.db.delete(schema.userPreferences).where(eq(schema.userPreferences.userId, u.id)); - await testDb.db.delete(schema.userHealthProfiles).where(eq(schema.userHealthProfiles.userId, u.id)); + await testDb.db + .delete(schema.userHealthProfiles) + .where(eq(schema.userHealthProfiles.userId, u.id)); await testDb.db.delete(schema.userCredentials).where(eq(schema.userCredentials.userId, u.id)); await testDb.db.delete(schema.refreshTokens).where(eq(schema.refreshTokens.userId, u.id)); await testDb.db.delete(schema.users).where(eq(schema.users.id, u.id)); @@ -102,16 +104,36 @@ describe("S5 — onboarding → minne + smaksignaler", () => { .select() .from(schema.tasteSignals) .where(eq(schema.tasteSignals.userId, userId)); - const cuisineSignals = signals.filter((s) => s.axis === "cuisine").sort((a, b) => (a.target ?? "").localeCompare(b.target ?? "")); - const avoidSignals = signals.filter((s) => s.axis === "ingredient_avoid").sort((a, b) => (a.target ?? "").localeCompare(b.target ?? "")); + const cuisineSignals = signals + .filter((s) => s.axis === "cuisine") + .sort((a, b) => (a.target ?? "").localeCompare(b.target ?? "")); + const avoidSignals = signals + .filter((s) => s.axis === "ingredient_avoid") + .sort((a, b) => (a.target ?? "").localeCompare(b.target ?? "")); expect(cuisineSignals).toHaveLength(2); - expect(cuisineSignals[0]).toMatchObject({ target: "italian", direction: 1, origin: "user_stated" }); - expect(cuisineSignals[1]).toMatchObject({ target: "thai", direction: 1, origin: "user_stated" }); + expect(cuisineSignals[0]).toMatchObject({ + target: "italian", + direction: 1, + origin: "user_stated", + }); + expect(cuisineSignals[1]).toMatchObject({ + target: "thai", + direction: 1, + origin: "user_stated", + }); expect(avoidSignals).toHaveLength(2); - expect(avoidSignals[0]).toMatchObject({ target: "anchovy", direction: -1, origin: "user_stated" }); - expect(avoidSignals[1]).toMatchObject({ target: "broccoli", direction: -1, origin: "user_stated" }); + expect(avoidSignals[0]).toMatchObject({ + target: "anchovy", + direction: -1, + origin: "user_stated", + }); + expect(avoidSignals[1]).toMatchObject({ + target: "broccoli", + direction: -1, + origin: "user_stated", + }); await cleanupUser(email); }); @@ -219,8 +241,14 @@ describe("S5 — onboarding → minne + smaksignaler", () => { .from(schema.tasteSignals) .where(eq(schema.tasteSignals.userId, userId)); expect(signals).toHaveLength(2); - expect(signals.some((s) => s.axis === "cuisine" && s.target === "italian" && s.direction === 1)).toBe(true); - expect(signals.some((s) => s.axis === "ingredient_avoid" && s.target === "mushroom" && s.direction === -1)).toBe(true); + expect( + signals.some((s) => s.axis === "cuisine" && s.target === "italian" && s.direction === 1), + ).toBe(true); + expect( + signals.some( + (s) => s.axis === "ingredient_avoid" && s.target === "mushroom" && s.direction === -1, + ), + ).toBe(true); await cleanupUser(email); }); diff --git a/apps/api/test/onboarding.test.ts b/apps/api/test/onboarding.test.ts index 68e34c3..cb5c33e 100644 --- a/apps/api/test/onboarding.test.ts +++ b/apps/api/test/onboarding.test.ts @@ -29,15 +29,13 @@ describe("progressive onboarding validering (FAS 1b)", () => { }); it("quick-start avvisar ogiltigt mål", () => { - expect(() => - parse(quickStartInputSchema, { primaryGoal: "invalid_goal" }), - ).toThrowError(ApiError); + expect(() => parse(quickStartInputSchema, { primaryGoal: "invalid_goal" })).toThrowError( + ApiError, + ); }); it("quick-start avvisar ogiltig precision", () => { - expect(() => - parse(quickStartInputSchema, { precisionMode: "medium" }), - ).toThrowError(ApiError); + expect(() => parse(quickStartInputSchema, { precisionMode: "medium" })).toThrowError(ApiError); }); it("onboarding-status schema validerar korrekt struktur", () => { diff --git a/apps/api/test/reconciliation.test.ts b/apps/api/test/reconciliation.test.ts index a25cf6f..666376b 100644 --- a/apps/api/test/reconciliation.test.ts +++ b/apps/api/test/reconciliation.test.ts @@ -30,11 +30,19 @@ describe("quick reconciliation", () => { .from(schema.inventoryItems) .where(eq(schema.inventoryItems.householdId, m.householdId)); for (const it of items) { - await testDb.db.delete(schema.inventoryTransactions).where(eq(schema.inventoryTransactions.inventoryItemId, it.id)); + await testDb.db + .delete(schema.inventoryTransactions) + .where(eq(schema.inventoryTransactions.inventoryItemId, it.id)); } - await testDb.db.delete(schema.inventoryItems).where(eq(schema.inventoryItems.householdId, m.householdId)); - await testDb.db.delete(schema.storageLocations).where(eq(schema.storageLocations.householdId, m.householdId)); - await testDb.db.delete(schema.householdMembers).where(eq(schema.householdMembers.householdId, m.householdId)); + await testDb.db + .delete(schema.inventoryItems) + .where(eq(schema.inventoryItems.householdId, m.householdId)); + await testDb.db + .delete(schema.storageLocations) + .where(eq(schema.storageLocations.householdId, m.householdId)); + await testDb.db + .delete(schema.householdMembers) + .where(eq(schema.householdMembers.householdId, m.householdId)); await testDb.db.delete(schema.households).where(eq(schema.households.id, m.householdId)); } await testDb.db.delete(schema.userPreferences).where(eq(schema.userPreferences.userId, u.id)); @@ -97,7 +105,9 @@ describe("quick reconciliation", () => { payload: {}, }); expect(res.statusCode).toBe(200); - const body = JSON.parse(res.body) as { candidates: Array<{ itemId: string; reasons: unknown[]; suggestedAction: string }> }; + const body = JSON.parse(res.body) as { + candidates: Array<{ itemId: string; reasons: unknown[]; suggestedAction: string }>; + }; expect(body.candidates.length).toBeGreaterThan(0); expect(body.candidates[0]?.reasons.length).toBeGreaterThan(0); }); @@ -119,7 +129,11 @@ describe("quick reconciliation", () => { payload: { action: "exists", quantity: 0.5 }, }); expect(res.statusCode).toBe(200); - const body = JSON.parse(res.body) as { action: string; quantity: number; verifiedByUser: boolean }; + const body = JSON.parse(res.body) as { + action: string; + quantity: number; + verifiedByUser: boolean; + }; expect(body.action).toBe("exists"); expect(body.quantity).toBe(0.5); expect(body.verifiedByUser).toBe(true); @@ -142,10 +156,16 @@ describe("quick reconciliation", () => { .where(eq(schema.inventoryItems.id, itemId)) .limit(1); const txs = await testDb.db - .select({ type: schema.inventoryTransactions.type, quantityDelta: schema.inventoryTransactions.quantityDelta, unit: schema.inventoryTransactions.unit }) + .select({ + type: schema.inventoryTransactions.type, + quantityDelta: schema.inventoryTransactions.quantityDelta, + unit: schema.inventoryTransactions.unit, + }) .from(schema.inventoryTransactions) .where(eq(schema.inventoryTransactions.inventoryItemId, itemId)); - const balance = computeBalance(txs.map((tx) => ({ type: tx.type, quantityDelta: tx.quantityDelta, unit: tx.unit }))); + const balance = computeBalance( + txs.map((tx) => ({ type: tx.type, quantityDelta: tx.quantityDelta, unit: tx.unit })), + ); expect(balance.balance).toBeCloseTo(item!.quantity, 5); } diff --git a/apps/api/test/scan-diff.test.ts b/apps/api/test/scan-diff.test.ts index 01f01d7..c96dee9 100644 --- a/apps/api/test/scan-diff.test.ts +++ b/apps/api/test/scan-diff.test.ts @@ -31,13 +31,25 @@ describe("scan-to-scan-diff", () => { .from(schema.inventoryItems) .where(eq(schema.inventoryItems.householdId, m.householdId)); for (const it of items) { - await testDb.db.delete(schema.inventoryTransactions).where(eq(schema.inventoryTransactions.inventoryItemId, it.id)); - await testDb.db.delete(schema.inventoryConflicts).where(eq(schema.inventoryConflicts.inventoryItemId, it.id)); + await testDb.db + .delete(schema.inventoryTransactions) + .where(eq(schema.inventoryTransactions.inventoryItemId, it.id)); + await testDb.db + .delete(schema.inventoryConflicts) + .where(eq(schema.inventoryConflicts.inventoryItemId, it.id)); } - await testDb.db.delete(schema.inventoryItems).where(eq(schema.inventoryItems.householdId, m.householdId)); - await testDb.db.delete(schema.inventoryConflicts).where(eq(schema.inventoryConflicts.householdId, m.householdId)); - await testDb.db.delete(schema.storageLocations).where(eq(schema.storageLocations.householdId, m.householdId)); - await testDb.db.delete(schema.householdMembers).where(eq(schema.householdMembers.householdId, m.householdId)); + await testDb.db + .delete(schema.inventoryItems) + .where(eq(schema.inventoryItems.householdId, m.householdId)); + await testDb.db + .delete(schema.inventoryConflicts) + .where(eq(schema.inventoryConflicts.householdId, m.householdId)); + await testDb.db + .delete(schema.storageLocations) + .where(eq(schema.storageLocations.householdId, m.householdId)); + await testDb.db + .delete(schema.householdMembers) + .where(eq(schema.householdMembers.householdId, m.householdId)); await testDb.db.delete(schema.households).where(eq(schema.households.id, m.householdId)); } await testDb.db.delete(schema.scanJobs).where(eq(schema.scanJobs.userId, u.id)); @@ -107,8 +119,20 @@ describe("scan-to-scan-diff", () => { jobType: "ANALYZE_FRIDGE_IMAGE", status: "completed", result: [ - { displayName: "Mjölk", quantity: 0.5, unit: "LITER", storageLocationId: location!.id, confidence: 0.9 }, - { displayName: "Ost", quantity: 1, unit: "COUNT", storageLocationId: location!.id, confidence: 0.9 }, + { + displayName: "Mjölk", + quantity: 0.5, + unit: "LITER", + storageLocationId: location!.id, + confidence: 0.9, + }, + { + displayName: "Ost", + quantity: 1, + unit: "COUNT", + storageLocationId: location!.id, + confidence: 0.9, + }, ], modelVersion: "v1", promptVersion: "p1", @@ -151,7 +175,17 @@ describe("scan-to-scan-diff", () => { headers: { authorization: `Bearer ${token}` }, payload: {}, }); - const { rows } = JSON.parse(diff.body) as { rows: Array<{ kind: string; itemId?: string; previousItemId?: string; displayName: string; newQuantity?: number; newLocationId?: string; confidence: number }> }; + const { rows } = JSON.parse(diff.body) as { + rows: Array<{ + kind: string; + itemId?: string; + previousItemId?: string; + displayName: string; + newQuantity?: number; + newLocationId?: string; + confidence: number; + }>; + }; const changed = rows.find((r) => r.kind === "quantity_changed")!; const apply = await app.inject({ diff --git a/apps/api/test/scans.test.ts b/apps/api/test/scans.test.ts index 7e79ea5..e4d36c8 100644 --- a/apps/api/test/scans.test.ts +++ b/apps/api/test/scans.test.ts @@ -33,13 +33,25 @@ describe("scan confirmation → ai_corrections", () => { .from(schema.inventoryItems) .where(eq(schema.inventoryItems.householdId, m.householdId)); for (const it of items) { - await testDb.db.delete(schema.inventoryTransactions).where(eq(schema.inventoryTransactions.inventoryItemId, it.id)); - await testDb.db.delete(schema.inventoryConflicts).where(eq(schema.inventoryConflicts.inventoryItemId, it.id)); + await testDb.db + .delete(schema.inventoryTransactions) + .where(eq(schema.inventoryTransactions.inventoryItemId, it.id)); + await testDb.db + .delete(schema.inventoryConflicts) + .where(eq(schema.inventoryConflicts.inventoryItemId, it.id)); } - await testDb.db.delete(schema.inventoryItems).where(eq(schema.inventoryItems.householdId, m.householdId)); - await testDb.db.delete(schema.inventoryConflicts).where(eq(schema.inventoryConflicts.householdId, m.householdId)); - await testDb.db.delete(schema.storageLocations).where(eq(schema.storageLocations.householdId, m.householdId)); - await testDb.db.delete(schema.householdMembers).where(eq(schema.householdMembers.householdId, m.householdId)); + await testDb.db + .delete(schema.inventoryItems) + .where(eq(schema.inventoryItems.householdId, m.householdId)); + await testDb.db + .delete(schema.inventoryConflicts) + .where(eq(schema.inventoryConflicts.householdId, m.householdId)); + await testDb.db + .delete(schema.storageLocations) + .where(eq(schema.storageLocations.householdId, m.householdId)); + await testDb.db + .delete(schema.householdMembers) + .where(eq(schema.householdMembers.householdId, m.householdId)); await testDb.db.delete(schema.households).where(eq(schema.households.id, m.householdId)); } await testDb.db.delete(schema.scanJobs).where(eq(schema.scanJobs.userId, u.id)); @@ -62,7 +74,11 @@ describe("scan confirmation → ai_corrections", () => { } await testDb.db .insert(schema.userConsents) - .values({ userId, kind: "image_training" as const, status: (imageTraining ? "granted" : "denied") as "granted" | "denied" }) + .values({ + userId, + kind: "image_training" as const, + status: (imageTraining ? "granted" : "denied") as "granted" | "denied", + }) .onConflictDoUpdate({ target: [schema.userConsents.userId, schema.userConsents.kind], set: { status: (imageTraining ? "granted" : "denied") as "granted" | "denied" }, @@ -168,7 +184,9 @@ describe("scan confirmation → ai_corrections", () => { expect(corrections).toHaveLength(1); expect((corrections[0]!.userCorrection as Record).action).toBe("accept"); expect((corrections[0]!.proposal as Record).detectedName).toBe("Mellanmjölk"); - expect((corrections[0]!.userCorrection as Record>).corrected).toMatchObject({ + expect( + (corrections[0]!.userCorrection as Record>).corrected, + ).toMatchObject({ displayName: "Mellanmjölk", quantity: 1, unit: "LITER", @@ -203,7 +221,9 @@ describe("scan confirmation → ai_corrections", () => { .from(schema.aiCorrections) .where(eq(schema.aiCorrections.scanJobId, scanJobId)); expect(corrections[0]!.imageS3Key).toBe("fridge-scans/test-image.jpg"); - expect((corrections[0]!.consentSnapshot as Record).image_training).toBe("granted"); + expect((corrections[0]!.consentSnapshot as Record).image_training).toBe( + "granted", + ); }); it("does not save image reference when image_training consent is denied", async () => { diff --git a/apps/mobile/src/app/(tabs)/home.tsx b/apps/mobile/src/app/(tabs)/home.tsx index a8fa539..fde011c 100644 --- a/apps/mobile/src/app/(tabs)/home.tsx +++ b/apps/mobile/src/app/(tabs)/home.tsx @@ -40,7 +40,10 @@ interface BudgetSummary { export default function HomeScreen() { const inventory = useQuery({ queryKey: ["inventory"], - queryFn: () => api<{ items: InventoryItem[]; trustStatus?: "up_to_date" | "needs_check" | "uncertain" }>("/v1/inventory?limit=100"), + queryFn: () => + api<{ items: InventoryItem[]; trustStatus?: "up_to_date" | "needs_check" | "uncertain" }>( + "/v1/inventory?limit=100", + ), }); const expiring = useQuery({ queryKey: ["inventory-expiring"], diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index 16a5e42..4b0d7d3 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -54,42 +54,42 @@ export default function RootLayout() { > - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + diff --git a/apps/mobile/src/app/cooking/[id].tsx b/apps/mobile/src/app/cooking/[id].tsx index deaed90..61a039c 100644 --- a/apps/mobile/src/app/cooking/[id].tsx +++ b/apps/mobile/src/app/cooking/[id].tsx @@ -60,10 +60,10 @@ export default function CookingScreen() { const cook = useMutation({ mutationFn: (body: unknown) => - api<{ sessionId: string; mealBoxMutations?: Array<{ mealBoxId: string; deltaPortions: number; frozen: boolean }> }>( - `/v1/recipes/${id}/cook`, - { method: "POST", body }, - ), + api<{ + sessionId: string; + mealBoxMutations?: Array<{ mealBoxId: string; deltaPortions: number; frozen: boolean }>; + }>(`/v1/recipes/${id}/cook`, { method: "POST", body }), onSuccess: async (data) => { await queryClient.invalidateQueries({ queryKey: ["inventory"] }); await queryClient.invalidateQueries({ queryKey: ["day"] }); @@ -125,7 +125,9 @@ export default function CookingScreen() { ✅ {t("cooked.title")} {recipe.titleSv} - {t("cooked.portionsCooked")}: {portionsCooked} + + {t("cooked.portionsCooked")}: {portionsCooked} + {t("mealbox.guidanceNote")}