316ba3cb62
- 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>
324 lines
11 KiB
TypeScript
324 lines
11 KiB
TypeScript
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;
|
||
});
|
||
}
|