Initial commit (unpacked platform)
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
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;
|
||||
});
|
||||
|
||||
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;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user