3c: partial consumption + undo for cooking sessions

- Append-only undo via correction transactions tied to cookingSessionId.
- POST /v1/cooking-sessions/:id/undo with 24h UTC window (409 + i18n after expiry).
- Complete undo scope: meals removed, meal boxes discarded, recipe_cooks
deleted, cookCount decremented; activation milestones left intact.
- Deterministic assumption-profile rollback via lastSessionAnswers.sessionId;
profile deleted when observationCount reaches 0.
- New status 'undone' as TEXT validated against COOKING_SESSION_STATUSES.
- Analytics event cooking_session_undone + domain events MEAL_BOX_DISCARDED,
MEAL_REMOVED, COOKING_SESSION_UNDONE.
- Partial consumption uses actualPortionsEaten/plannedPortions factor.
- i18n coverage for cooked.undoWindowExpired across all 12 locales + server.
- Invariant tests: computeBalance(transactions) === item.quantity after
complete, undo, and undo + new complete; transaction count increases on undo.
- Migration 0014_expand_event_types.sql adds new enum values.
- Updated FAS3-COOKING-SESSIONS-AUDIT.md with chosen semantics.
This commit is contained in:
Sven (AAMOS AI)
2026-08-07 05:47:54 +07:00
parent bbe7526ef3
commit cb493010d0
25 changed files with 969 additions and 30 deletions
+14 -2
View File
@@ -10,7 +10,7 @@ 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 } from "../lib/cooking.js";
import { completeCookingSession, undoCookingSession } from "../lib/cooking.js";
import { z } from "zod";
/**
@@ -103,7 +103,7 @@ export async function cookingSessionRoutes(app: FastifyInstance) {
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") {
if (session.status === "completed" || session.status === "cancelled" || session.status === "undone") {
throw errors.conflict("Sessionen är redan avslutad.");
}
@@ -196,6 +196,18 @@ export async function cookingSessionRoutes(app: FastifyInstance) {
};
});
/**
* Å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);