Fas 3 steg 3a: cooking_sessions-tabell + lifecycle-endpoints + timeout-jobb, gamla /cook kvar som kortkommando
This commit is contained in:
@@ -0,0 +1,198 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { and, eq, 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,
|
||||
cookingSessionCompleted,
|
||||
cookingSessionCancelled,
|
||||
} from "@app/analytics";
|
||||
import { trackProductAnalytics } from "../lib/helpers.js";
|
||||
import { completeCookingSessionCore } 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();
|
||||
|
||||
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") {
|
||||
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.
|
||||
* I steg 3a: anropar samma logik som gamla /cook, men länkar allt till cookingSessionId.
|
||||
*/
|
||||
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 completeCookingSessionCore(app, session, req.userId, input, req.correlationId);
|
||||
|
||||
const [updated] = await app.db
|
||||
.select()
|
||||
.from(schema.cookingSessions)
|
||||
.where(eq(schema.cookingSessions.id, id))
|
||||
.limit(1);
|
||||
|
||||
await trackProductAnalytics(
|
||||
app.db,
|
||||
req.userId,
|
||||
cookingSessionCompleted({
|
||||
householdId: session.householdId,
|
||||
properties: {
|
||||
cookingSessionId: id,
|
||||
recipeId: session.recipeId,
|
||||
portionsCooked: session.plannedPortions,
|
||||
mealBoxPortions: input.mealBoxPortions ?? 0,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
return { ...result, session: updated };
|
||||
});
|
||||
|
||||
/** 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;
|
||||
}
|
||||
Reference in New Issue
Block a user