243 lines
8.3 KiB
TypeScript
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;
|
|
}
|