Fas 3 steg 3a: cooking_sessions-tabell + lifecycle-endpoints + timeout-jobb, gamla /cook kvar som kortkommando

This commit is contained in:
Sven (AAMOS AI)
2026-08-07 03:14:13 +07:00
parent 4a4f448e1c
commit e9fb4a17d4
17 changed files with 10619 additions and 196 deletions
+23
View File
@@ -0,0 +1,23 @@
import { and, eq, lt } from "drizzle-orm";
import * as schema from "./schema/index.js";
const SESSION_TIMEOUT_MS = 24 * 60 * 60 * 1000;
/**
* Timeout-jobb (Fas 3 §6 / §20): avbryt STARTED-sessioner äldre än 24 h utan complete.
* PLANNED reserverar aldrig lager, så den behöver ingen timeout.
*/
export async function cancelTimedOutCookingSessions(db: { update: Function }) {
const deadline = new Date(Date.now() - SESSION_TIMEOUT_MS);
const sessions = await db
.update(schema.cookingSessions)
.set({ status: "cancelled", cancelledAt: new Date(), updatedAt: new Date() })
.where(
and(
eq(schema.cookingSessions.status, "started"),
lt(schema.cookingSessions.startedAt, deadline),
),
)
.returning();
return sessions;
}
+1
View File
@@ -4,3 +4,4 @@ export * from "./schema/index.js";
export * from "./analytics-gdpr.js";
export * from "./release-gates.js";
export * from "./activation.js";
export * from "./cooking.js";
@@ -25,6 +25,7 @@ import { households, storageLocations } from "./households.js";
import { canonicalIngredients } from "./ingredients.js";
import { products } from "./products.js";
import { users } from "./users.js";
import { cookingSessions } from "./recipes.js";
/**
* Food Twin (spec §8): lagerposter med fullständig spårbarhet.
@@ -96,6 +97,9 @@ export const inventoryTransactions = pgTable(
unit: unitEnum("unit").notNull(),
refType: text("ref_type"),
refId: uuid("ref_id"),
cookingSessionId: uuid("cooking_session_id").references(() => cookingSessions.id, {
onDelete: "set null",
}),
actorUserId: uuid("actor_user_id").references(() => users.id, { onDelete: "set null" }),
note: text("note"),
/** Värde i SEK för matsvinnsberäkning vid discard (spec §12, §26). */
+7 -1
View File
@@ -13,7 +13,7 @@ import {
import type { NutritionValues } from "@app/shared-types";
import { createdAt, mealLogSourceEnum, mealTypeEnum } from "./_shared.js";
import { households, storageLocations } from "./households.js";
import { recipes } from "./recipes.js";
import { recipes, cookingSessions } from "./recipes.js";
import { users } from "./users.js";
/**
@@ -32,6 +32,9 @@ export const meals = pgTable(
mealType: mealTypeEnum("meal_type").notNull(),
source: mealLogSourceEnum("source").notNull(),
recipeId: uuid("recipe_id").references(() => recipes.id, { onDelete: "set null" }),
cookingSessionId: uuid("cooking_session_id").references(() => cookingSessions.id, {
onDelete: "set null",
}),
titleSv: text("title_sv").notNull(),
portionFraction: doublePrecision("portion_fraction").notNull().default(1),
nutrition: jsonb("nutrition").$type<NutritionValues>().notNull(),
@@ -58,6 +61,9 @@ export const mealBoxes = pgTable(
.notNull()
.references(() => households.id, { onDelete: "cascade" }),
recipeId: uuid("recipe_id").references(() => recipes.id, { onDelete: "set null" }),
cookingSessionId: uuid("cooking_session_id").references(() => cookingSessions.id, {
onDelete: "set null",
}),
titleSv: text("title_sv").notNull(),
portions: integer("portions").notNull(),
portionsRemaining: integer("portions_remaining").notNull(),
+45
View File
@@ -28,6 +28,7 @@ import {
} from "./_shared.js";
import { canonicalIngredients } from "./ingredients.js";
import { users } from "./users.js";
import { households } from "./households.js";
/** Juridiskt source registry (spec §15). */
export const recipeSourceRegistry = pgTable("recipe_source_registry", {
@@ -190,6 +191,46 @@ export const recipeFavorites = pgTable(
],
);
/** Logg över lagningar grund för betyg, ranking, Food Memory (spec §3739). */
/** Cooking session lifecycle (Fas 3 §6). PLANNED reserverar aldrig lager; STARTED har 24 h på sig att complete/cancel. */
export const cookingSessions = pgTable(
"cooking_sessions",
{
id: uuid("id").primaryKey().defaultRandom(),
recipeId: uuid("recipe_id")
.notNull()
.references(() => recipes.id, { onDelete: "cascade" }),
householdId: uuid("household_id")
.notNull()
.references(() => households.id, { onDelete: "cascade" }),
startedByUserId: uuid("started_by_user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
status: text("status").notNull().default("planned"),
plannedPortions: integer("planned_portions").notNull(),
plannedMealType: mealTypeEnum("planned_meal_type").notNull().default("dinner"),
/** Antal portioner som faktiskt åts (steg 3c). */
actualPortionsEaten: integer("actual_portions_eaten"),
/** Förberedda rester efter sessionen (steg 3d). */
leftoverEstimatePortions: integer("leftover_estimate_portions"),
/** Ursprungligt FEFO-förslag, sparas för ev. undo/omräkning. */
plannedDeductions: jsonb("planned_deductions").$type<
Array<{ itemId: string; quantity: number; unit: string; name: string }>
>(),
startedAt: timestamp("started_at", { withTimezone: true }),
completedAt: timestamp("completed_at", { withTimezone: true }),
cancelledAt: timestamp("cancelled_at", { withTimezone: true }),
cancelReason: text("cancel_reason"),
createdAt: createdAt(),
updatedAt: updatedAt(),
},
(t) => [
index("cooking_sessions_household_status_idx").on(t.householdId, t.status),
index("cooking_sessions_recipe_idx").on(t.recipeId),
index("cooking_sessions_timeout_idx").on(t.status, t.startedAt),
],
);
/** Logg över lagningar grund för betyg, ranking, Food Memory (spec §3739). */
export const recipeCooks = pgTable(
"recipe_cooks",
@@ -202,12 +243,16 @@ export const recipeCooks = pgTable(
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
householdId: uuid("household_id"),
cookingSessionId: uuid("cooking_session_id").references(() => cookingSessions.id, {
onDelete: "set null",
}),
portionsCooked: integer("portions_cooked").notNull(),
cookedAt: timestamp("cooked_at", { withTimezone: true }).notNull().defaultNow(),
},
(t) => [
index("recipe_cooks_recipe_idx").on(t.recipeId),
index("recipe_cooks_user_idx").on(t.userId),
index("recipe_cooks_session_idx").on(t.cookingSessionId),
],
);