import type { FastifyInstance } from "fastify"; import { and, desc, eq, gte, sql } from "drizzle-orm"; import { schema } from "@app/database"; import { addPlanEntryInputSchema, generateWeekPlanInputSchema, idParamSchema, updatePlanEntryInputSchema, weekPlanQuerySchema, } from "@app/validation"; import { errors, parse } from "../lib/errors.js"; import { emitEvent, requireActiveHousehold, requireMembership } from "../lib/helpers.js"; import { requireFeature } from "../lib/entitlements.js"; import { resolveRecipeTitles } from "../lib/contentLanguage.js"; import { loadLocalePreferences } from "../lib/localeContext.js"; /** * Veckoplanering (spec §25). Planen genereras asynkront av workern * (GENERATE_WEEK_PLAN) som väger lager, utgångsdatum, matlådor, budget, * variation och mål – med deterministisk kärna och AAMOS som rådgivare. */ /** * Kanonisk svensk motivering vid dynamisk omplanering (spec §25). Samma funktion * används både när planposten skrivs och för att härleda den översättbara i18n- * nyckeln vid läsning (så att klienten kan visa den på användarens språk). */ export const rescheduleMovedReasonSv = (title: string): string => `${title} flyttades hit eftersom råvarorna bör användas först.`; export async function planningRoutes(app: FastifyInstance) { const auth = { preHandler: [app.authenticate] }; app.get("/v1/week-plans", auth, async (req) => { const q = parse(weekPlanQuerySchema, req.query); const householdId = await requireActiveHousehold(app.db, req.userId); const conditions = [eq(schema.weekPlans.householdId, householdId)]; if (q.weekStartDate) conditions.push(eq(schema.weekPlans.weekStartDate, q.weekStartDate)); const plans = await app.db .select() .from(schema.weekPlans) .where(and(...conditions)) .orderBy(desc(schema.weekPlans.weekStartDate), desc(schema.weekPlans.createdAt)) .limit(8); const plansWithEntries = []; for (const plan of plans) { const entries = await app.db .select() .from(schema.weekPlanEntries) .where(eq(schema.weekPlanEntries.weekPlanId, plan.id)) .orderBy(schema.weekPlanEntries.date, schema.weekPlanEntries.sortOrder); plansWithEntries.push({ plan, entries }); } const languageTag = (await loadLocalePreferences(app.db, req.userId)).languageTag; const titleMap = await resolveRecipeTitles( app.db, plansWithEntries.flatMap((p) => p.entries.map((e) => e.recipeId).filter((id): id is string => id != null), ), languageTag, ); const result = plansWithEntries.map(({ plan, entries }) => ({ ...plan, entries: entries.map((e) => { const title = e.recipeId ? (titleMap.get(e.recipeId) ?? e.titleSv) : e.titleSv; // Behåll rescheduleReasonSv (fallback) men lägg till översättbar nyckel + // parametrar för just omplanerings-meningen (andra motiveringar, t.ex. // matlåde-noteringar från workern, faller kvar på svenska). const structured = e.rescheduleReasonSv && e.rescheduleReasonSv === rescheduleMovedReasonSv(e.titleSv) ? { rescheduleReasonKey: "plan.rescheduleReason", rescheduleReasonParams: { title }, } : {}; return { ...e, title, ...structured }; }), })); return { plans: result }; }); app.post("/v1/week-plans/generate", auth, async (req, reply) => { await requireFeature(app.db, req.userId, "weekPlanning", "Veckoplanering"); const input = parse(generateWeekPlanInputSchema, req.body); const householdId = await requireActiveHousehold(app.db, req.userId); // "Planera om veckan" ersätter helt: ta bort ev. tidigare plan(er) för samma // vecka (kaskad tar entries) så vi aldrig samlar dubbletter och alltid visar // den nygenererade planen. Nytt plan-id ger dessutom ny variation (se workern). await app.db .delete(schema.weekPlans) .where( and( eq(schema.weekPlans.householdId, householdId), eq(schema.weekPlans.weekStartDate, input.weekStartDate), ), ); const [plan] = await app.db .insert(schema.weekPlans) .values({ householdId, weekStartDate: input.weekStartDate, status: "draft", generatedBy: "engine", notes: input.noteSv ?? null, }) .returning(); await app.jobQueue.add("GENERATE_WEEK_PLAN", { jobType: "GENERATE_WEEK_PLAN", weekPlanId: plan!.id, householdId, userId: req.userId, input, correlationId: req.correlationId, }); return reply.status(202).send({ plan, message: "Planen genereras – hämta den om en stund via GET /v1/week-plans.", }); }); app.patch("/v1/week-plans/:id/entries/:entryId", auth, async (req) => { const params = req.params as { id: string; entryId: string }; const [plan] = await app.db .select() .from(schema.weekPlans) .where(eq(schema.weekPlans.id, params.id)) .limit(1); if (!plan) throw errors.notFound("Planen finns inte."); await requireMembership(app.db, plan.householdId, req.userId); const input = parse(updatePlanEntryInputSchema, req.body); const updates: Record = { ...input }; // Byt rätt: när ett nytt recept väljs måste titeln (och portioner) följa med, // annars visar kalendern kvar den gamla rätten trots att recipeId har bytts. if (typeof input.recipeId === "string") { const [recipe] = await app.db .select({ id: schema.recipes.id, titleSv: schema.recipes.titleSv, portions: schema.recipes.portions, }) .from(schema.recipes) .where(and(eq(schema.recipes.id, input.recipeId), eq(schema.recipes.status, "published"))) .limit(1); if (!recipe) throw errors.notFound("Receptet finns inte."); updates.titleSv = recipe.titleSv; updates.mealBoxId = null; if (input.portions == null) updates.portions = recipe.portions ?? 2; updates.status = input.status ?? "planned"; updates.rescheduleReasonSv = null; } // Dynamisk omplanering med förklaring (spec §25) if (input.status === "skipped") { const [entry] = await app.db .select() .from(schema.weekPlanEntries) .where(eq(schema.weekPlanEntries.id, params.entryId)) .limit(1); if (entry?.recipeId) { // Flytta rätten till nästa lediga dag om råvaror bör användas. const later = await app.db .select() .from(schema.weekPlanEntries) .where( and( eq(schema.weekPlanEntries.weekPlanId, params.id), gte(schema.weekPlanEntries.date, entry.date), eq(schema.weekPlanEntries.status, "planned"), sql`${schema.weekPlanEntries.id} <> ${params.entryId}`, ), ) .orderBy(schema.weekPlanEntries.date) .limit(1); if (later[0]) { await app.db .update(schema.weekPlanEntries) .set({ recipeId: entry.recipeId, titleSv: entry.titleSv, status: "moved", rescheduleReasonSv: rescheduleMovedReasonSv(entry.titleSv), }) .where(eq(schema.weekPlanEntries.id, later[0].id)); } } } const [row] = await app.db .update(schema.weekPlanEntries) .set(updates) .where( and( eq(schema.weekPlanEntries.id, params.entryId), eq(schema.weekPlanEntries.weekPlanId, params.id), ), ) .returning(); if (!row) throw errors.notFound("Planposten finns inte."); await emitEvent(app.db, { type: "WEEK_PLAN_UPDATED", payload: { weekPlanId: params.id, reason: input.status ?? null }, userId: req.userId, householdId: plan.householdId, correlationId: req.correlationId, }); return row; }); // Ta bort en planpost helt (t.ex. "Ta bort" på en måltid man inte lagat). app.delete("/v1/week-plans/:id/entries/:entryId", auth, async (req) => { const params = req.params as { id: string; entryId: string }; const [plan] = await app.db .select() .from(schema.weekPlans) .where(eq(schema.weekPlans.id, params.id)) .limit(1); if (!plan) throw errors.notFound("Planen finns inte."); await requireMembership(app.db, plan.householdId, req.userId); const [row] = await app.db .delete(schema.weekPlanEntries) .where( and( eq(schema.weekPlanEntries.id, params.entryId), eq(schema.weekPlanEntries.weekPlanId, params.id), ), ) .returning(); if (!row) throw errors.notFound("Planposten finns inte."); await emitEvent(app.db, { type: "WEEK_PLAN_UPDATED", payload: { weekPlanId: params.id, reason: "removed" }, userId: req.userId, householdId: plan.householdId, correlationId: req.correlationId, }); return { ok: true }; }); // Lägg till en enskild måltid i planen (t.ex. på en tom dag). app.post("/v1/week-plans/:id/entries", auth, async (req, reply) => { const params = req.params as { id: string }; const [plan] = await app.db .select() .from(schema.weekPlans) .where(eq(schema.weekPlans.id, params.id)) .limit(1); if (!plan) throw errors.notFound("Planen finns inte."); await requireMembership(app.db, plan.householdId, req.userId); const input = parse(addPlanEntryInputSchema, req.body); const [recipe] = await app.db .select({ id: schema.recipes.id, titleSv: schema.recipes.titleSv, portions: schema.recipes.portions, }) .from(schema.recipes) .where(and(eq(schema.recipes.id, input.recipeId), eq(schema.recipes.status, "published"))) .limit(1); if (!recipe) throw errors.notFound("Receptet finns inte."); const sameDay = await app.db .select({ sortOrder: schema.weekPlanEntries.sortOrder }) .from(schema.weekPlanEntries) .where( and( eq(schema.weekPlanEntries.weekPlanId, params.id), eq(schema.weekPlanEntries.date, input.date), ), ); const nextSort = sameDay.reduce((m, e) => Math.max(m, e.sortOrder ?? 0), -1) + 1; const [row] = await app.db .insert(schema.weekPlanEntries) .values({ weekPlanId: params.id, date: input.date, mealType: input.mealType, recipeId: recipe.id, titleSv: recipe.titleSv, portions: recipe.portions ?? 2, status: "planned", sortOrder: nextSort, }) .returning(); await emitEvent(app.db, { type: "WEEK_PLAN_UPDATED", payload: { weekPlanId: params.id, reason: "entry_added" }, userId: req.userId, householdId: plan.householdId, correlationId: req.correlationId, }); return reply.status(201).send(row); }); app.post("/v1/week-plans/:id/activate", auth, async (req) => { const { id } = parse(idParamSchema, req.params); const [plan] = await app.db .select() .from(schema.weekPlans) .where(eq(schema.weekPlans.id, id)) .limit(1); if (!plan) throw errors.notFound(); await requireMembership(app.db, plan.householdId, req.userId); const [row] = await app.db .update(schema.weekPlans) .set({ status: "active", updatedAt: new Date() }) .where(eq(schema.weekPlans.id, id)) .returning(); return row; }); }