393bc35ac0
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.
198 lines
6.6 KiB
TypeScript
198 lines
6.6 KiB
TypeScript
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;
|
||
});
|
||
}
|