Files
Cibello-app/apps/api/src/routes/cooking-sessions.ts
T
Sven (AAMOS AI) c32a7e33c7
CI / Typecheck, test & build (push) Failing after 2s
ci: trigga på master + formatfix inför Gitea Actions
2026-08-13 17:25:14 +07:00

243 lines
8.3 KiB
TypeScript

import type { FastifyInstance } from "fastify";
import { and, eq, inArray, lt } from "drizzle-orm";
import { schema, markMilestone } from "@app/database";
import {
cookingSessionStartInputSchema,
cookingSessionCompleteInputSchema,
idParamSchema,
} from "@app/validation";
import { errors, parse } from "../lib/errors.js";
import { requireActiveHousehold, requireMembership } from "../lib/helpers.js";
import { cookingSessionStarted, cookingSessionCancelled } from "@app/analytics";
import { trackProductAnalytics } from "../lib/helpers.js";
import { completeCookingSession, undoCookingSession } from "../lib/cooking.js";
import { z } from "zod";
/**
* Cooking Sessions (Fas 3 §6).
*
* - PLANNED reserverar aldrig lager.
* - STARTED har 24 h på sig att complete/cancel (§20 timeout).
* - COMPLETED/CANCELLED är terminala.
* - Gamla POST /v1/recipes/:id/cook finns kvar som kortkommando.
*/
export async function cookingSessionRoutes(app: FastifyInstance) {
const auth = { preHandler: [app.authenticate] };
/** Skapa ny session. Status planned om startNow saknas, annars started. */
app.post("/v1/recipes/:id/cook/start", auth, async (req, reply) => {
const { id } = parse(idParamSchema, req.params);
const input = parse(cookingSessionStartInputSchema, req.body);
const householdId = await requireActiveHousehold(app.db, req.userId);
await requireMembership(app.db, householdId, req.userId);
const [recipe] = await app.db
.select({ id: schema.recipes.id, portions: schema.recipes.portions })
.from(schema.recipes)
.where(eq(schema.recipes.id, id))
.limit(1);
if (!recipe) throw errors.notFound("Receptet finns inte.");
const now = new Date();
const status = input.startNow ? "started" : "planned";
const [session] = await app.db
.insert(schema.cookingSessions)
.values({
recipeId: id,
householdId,
startedByUserId: req.userId,
status,
plannedPortions: input.portions ?? recipe.portions,
plannedMealType: input.mealType ?? "dinner",
startedAt: input.startNow ? now : null,
})
.returning();
// cooking_session_started skickas endast vid faktisk start, inte vid planned.
if (status === "started") {
await trackProductAnalytics(
app.db,
req.userId,
cookingSessionStarted({
householdId,
properties: {
cookingSessionId: session!.id,
recipeId: id,
status,
plannedPortions: input.portions ?? recipe.portions,
},
}),
);
}
return reply.status(201).send({ session: session });
});
/** Starta en planned session. */
app.post("/v1/cooking-sessions/:id/start", auth, async (req) => {
const { id } = parse(idParamSchema, req.params);
const session = await getOwnedSession(app, id, req.userId);
if (session.status !== "planned") throw errors.conflict("Sessionen är inte planerad.");
const [updated] = await app.db
.update(schema.cookingSessions)
.set({ status: "started", startedAt: new Date(), updatedAt: new Date() })
.where(eq(schema.cookingSessions.id, id))
.returning();
await trackProductAnalytics(
app.db,
req.userId,
cookingSessionStarted({
householdId: session.householdId,
properties: { cookingSessionId: id, recipeId: session.recipeId, status: "started" },
}),
);
return { session: updated };
});
/** Avbryt session utan att röra lagret. */
app.post("/v1/cooking-sessions/:id/cancel", auth, async (req) => {
const params = z.object({ id: z.uuid() }).parse(req.params);
const body = z.object({ reason: z.string().max(200).optional() }).parse(req.body ?? {});
const session = await getOwnedSession(app, params.id, req.userId);
if (
session.status === "completed" ||
session.status === "cancelled" ||
session.status === "undone"
) {
throw errors.conflict("Sessionen är redan avslutad.");
}
const [updated] = await app.db
.update(schema.cookingSessions)
.set({
status: "cancelled",
cancelledAt: new Date(),
cancelReason: body.reason ?? null,
updatedAt: new Date(),
})
.where(eq(schema.cookingSessions.id, params.id))
.returning();
await trackProductAnalytics(
app.db,
req.userId,
cookingSessionCancelled({
householdId: session.householdId,
properties: { cookingSessionId: params.id, recipeId: session.recipeId },
}),
);
return { session: updated };
});
/**
* Complete en session.
* Delad kompletteringsväg via completeCookingSession hanterar summavalidering,
* persistens, profiluppdateringar och analytics.
*/
app.post("/v1/cooking-sessions/:id/complete", auth, async (req) => {
const { id } = parse(idParamSchema, req.params);
const input = parse(cookingSessionCompleteInputSchema, req.body);
const session = await getOwnedSession(app, id, req.userId);
if (session.status !== "started") {
throw errors.conflict("Sessionen måste vara startad för att avslutas.");
}
const result = await completeCookingSession(app, session, req.userId, input, req.correlationId);
return result;
});
/** Hämta antagandeprofil för ett recept (per hushåll + ingrediens). */
app.get("/v1/recipes/:id/cooking-assumptions", auth, async (req) => {
const { id } = parse(idParamSchema, req.params);
const householdId = await requireActiveHousehold(app.db, req.userId);
await requireMembership(app.db, householdId, req.userId);
const ings = await app.db
.select({
canonicalIngredientId: schema.recipeIngredients.canonicalIngredientId,
optional: schema.recipeIngredients.optional,
})
.from(schema.recipeIngredients)
.where(eq(schema.recipeIngredients.recipeId, id));
if (ings.length === 0) throw errors.notFound("Receptet finns inte.");
const nonOptionalIds = ings.filter((i) => !i.optional).map((i) => i.canonicalIngredientId);
if (nonOptionalIds.length === 0) {
return {
householdId,
recipeId: id,
defaultActualPortionsEaten: null,
defaultLeftoverEstimatePortions: null,
observationCount: 0,
};
}
const profiles = await app.db
.select()
.from(schema.cookingAssumptionProfiles)
.where(
and(
eq(schema.cookingAssumptionProfiles.householdId, householdId),
inArray(schema.cookingAssumptionProfiles.canonicalIngredientId, nonOptionalIds),
),
);
// Deterministiskt val: profilen med flest observationer (stabil över tid).
const p = profiles.sort((a, b) => b.observationCount - a.observationCount)[0];
return {
householdId,
recipeId: id,
defaultActualPortionsEaten: p?.averageEatenPortions ?? null,
defaultLeftoverEstimatePortions: p?.averageLeftoverPortions ?? null,
observationCount: p?.observationCount ?? 0,
};
});
/**
* Ångra en completed session inom 24 h.
* Ledger är append-only: reverseringstransaktioner skrivs, befintliga
* transaktioner rörs inte.
*/
app.post("/v1/cooking-sessions/:id/undo", auth, async (req) => {
const { id } = parse(idParamSchema, req.params);
const session = await getOwnedSession(app, id, req.userId);
const result = await undoCookingSession(app, session, req.userId, req.correlationId);
return result;
});
/** Lista hushållets aktiva sessioner. */
app.get("/v1/cooking-sessions", auth, async (req) => {
const householdId = await requireActiveHousehold(app.db, req.userId);
await requireMembership(app.db, householdId, req.userId);
const sessions = await app.db
.select()
.from(schema.cookingSessions)
.where(
and(
eq(schema.cookingSessions.householdId, householdId),
eq(schema.cookingSessions.status, "started"),
),
)
.orderBy(schema.cookingSessions.startedAt);
return { sessions };
});
}
async function getOwnedSession(app: FastifyInstance, sessionId: string, userId: string) {
const [session] = await app.db
.select()
.from(schema.cookingSessions)
.where(eq(schema.cookingSessions.id, sessionId))
.limit(1);
if (!session) throw errors.notFound("Sessionen finns inte.");
await requireMembership(app.db, session.householdId, userId);
return session;
}