import type { FastifyInstance } from "fastify"; import { and, desc, eq, gte, sql } from "drizzle-orm"; import { schema } from "@app/database"; import { generateWeekPlanInputSchema, idParamSchema, updatePlanEntryInputSchema, weekPlanQuerySchema, } from "@app/validation"; import { errors, parse } from "../lib/errors.js"; import { emitEvent, requireActiveHousehold, requireMembership } from "../lib/helpers.js"; import { requireFeature } from "../lib/entitlements.js"; /** * Veckoplanering (spec §25). Planen genereras asynkront av workern * (GENERATE_WEEK_PLAN) som väger lager, utgångsdatum, matlådor, budget, * variation och mål – med deterministisk kärna och AAMOS som rådgivare. */ export async function planningRoutes(app: FastifyInstance) { const auth = { preHandler: [app.authenticate] }; app.get("/v1/week-plans", auth, async (req) => { const q = parse(weekPlanQuerySchema, req.query); const householdId = await requireActiveHousehold(app.db, req.userId); const conditions = [eq(schema.weekPlans.householdId, householdId)]; if (q.weekStartDate) conditions.push(eq(schema.weekPlans.weekStartDate, q.weekStartDate)); const plans = await app.db .select() .from(schema.weekPlans) .where(and(...conditions)) .orderBy(desc(schema.weekPlans.weekStartDate)) .limit(8); const result = []; for (const plan of plans) { const entries = await app.db .select() .from(schema.weekPlanEntries) .where(eq(schema.weekPlanEntries.weekPlanId, plan.id)) .orderBy(schema.weekPlanEntries.date, schema.weekPlanEntries.sortOrder); result.push({ ...plan, entries }); } return { plans: result }; }); app.post("/v1/week-plans/generate", auth, async (req, reply) => { await requireFeature(app.db, req.userId, "weekPlanning", "Veckoplanering"); const input = parse(generateWeekPlanInputSchema, req.body); const householdId = await requireActiveHousehold(app.db, req.userId); const [plan] = await app.db .insert(schema.weekPlans) .values({ householdId, weekStartDate: input.weekStartDate, status: "draft", generatedBy: "engine", notes: input.noteSv ?? null, }) .returning(); await app.jobQueue.add("GENERATE_WEEK_PLAN", { jobType: "GENERATE_WEEK_PLAN", weekPlanId: plan!.id, householdId, userId: req.userId, input, correlationId: req.correlationId, }); return reply.status(202).send({ plan, message: "Planen genereras – hämta den om en stund via GET /v1/week-plans.", }); }); app.patch("/v1/week-plans/:id/entries/:entryId", auth, async (req) => { const params = req.params as { id: string; entryId: string }; const [plan] = await app.db .select() .from(schema.weekPlans) .where(eq(schema.weekPlans.id, params.id)) .limit(1); if (!plan) throw errors.notFound("Planen finns inte."); await requireMembership(app.db, plan.householdId, req.userId); const input = parse(updatePlanEntryInputSchema, req.body); const updates: Record = { ...input }; // Dynamisk omplanering med förklaring (spec §25) if (input.status === "skipped") { const [entry] = await app.db .select() .from(schema.weekPlanEntries) .where(eq(schema.weekPlanEntries.id, params.entryId)) .limit(1); if (entry?.recipeId) { // Flytta rätten till nästa lediga dag om råvaror bör användas. const later = await app.db .select() .from(schema.weekPlanEntries) .where( and( eq(schema.weekPlanEntries.weekPlanId, params.id), gte(schema.weekPlanEntries.date, entry.date), eq(schema.weekPlanEntries.status, "planned"), sql`${schema.weekPlanEntries.id} <> ${params.entryId}`, ), ) .orderBy(schema.weekPlanEntries.date) .limit(1); if (later[0]) { await app.db .update(schema.weekPlanEntries) .set({ recipeId: entry.recipeId, titleSv: entry.titleSv, status: "moved", rescheduleReasonSv: `${entry.titleSv} flyttades hit eftersom råvarorna bör användas först.`, }) .where(eq(schema.weekPlanEntries.id, later[0].id)); } } } const [row] = await app.db .update(schema.weekPlanEntries) .set(updates) .where( and( eq(schema.weekPlanEntries.id, params.entryId), eq(schema.weekPlanEntries.weekPlanId, params.id), ), ) .returning(); if (!row) throw errors.notFound("Planposten finns inte."); await emitEvent(app.db, { type: "WEEK_PLAN_UPDATED", payload: { weekPlanId: params.id, reason: input.status ?? null }, userId: req.userId, householdId: plan.householdId, correlationId: req.correlationId, }); return row; }); // 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 }; }); 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; }); }