Files
Cibello-app/apps/api/src/routes/planning.ts
T
Claude 393bc35ac0 feat(veckoplan): fyll alla maltider, variation, ratt maltidstyp + plan-atgarder
Veckoplanen fyller nu ALLA slots (stegvis fallback, inga tomma dagar),
undviker tva soppor/samma kok i rad och styr soppor mot lunch (inte
middag). Default lunch+middag varje dag. Plan-atgarder: 'Jag lagade
detta' bara ratt dag; 'Hoppa over' ersatt med 'Ta bort' (ny DELETE
/v1/week-plans/:id/entries/:entryId). Verifierat mot 393 recept:
14/14 slots fyllda, 0 upprepningar, 0 soppor pa middag.
2026-08-19 23:33:32 +00:00

198 lines
6.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, unknown> = { ...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;
});
}