Files
Cibello-app/apps/api/src/routes/planning.ts
T
Claude 316ba3cb62 feat(i18n): server-genererad prosa -> nyckel+params (min dag, ombokning, scan-diff)
- Min-dag-noten: noteKey (estimateNote/computedNote) bredvid svensk note; klient t(noteKey).
- Ombokningsskäl (planning.ts): rescheduleReasonKey + params bredvid rescheduleReasonSv;
  klient t(key, {title}). Workerns två andra ombokningstexter lämnade på svensk fallback.
- Scan-diff: reasonCode-enum (6 koder) bredvid reasonSv; klient t(scanDiff.reason.<code>).
- 8 nya nycklar i alla 12 språk. Ingen beslutslogik rörd; alla Sv-fält kvar som fallback.
- Kvar som egna spår: recept-säkerhetsmeddelanden + substitutions-data (översättningstabell).
- typecheck grönt (20/20), vakt grön, inventory-engine 53/53 tester.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-21 20:36:11 +00:00

324 lines
11 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 {
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<string, unknown> = { ...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;
});
}