Files
Cibello-app/packages/database/src/schema/recipes.ts
T
Sven (AAMOS AI) 74f95daab2 feat(3d): meal-box merge, stored mutations, undo, mobile undo UI + i18n ×12
- Merge leftovers into existing meal_box when same recipeId + cookedAt date + frozen + available.
- Store mealBoxMutations JSONB on cooking_sessions for deterministic undo.
- Undo decrements portions/remaining, discards box at zero, appends correction ledger rows.
- Mobile: undo button in cooking/[id].tsx after-flow and meal-boxes.tsx within 24h window.
- i18n undo strings across all 12 locales; parity test green.
- 6 new integration tests: merge, no cross-date merge, frozen split, undo restore, undo discard, ledger invariant.
- Update FAS3 audit doc with 3d semantics.

Closes Fas 3d
2026-08-07 17:04:42 +07:00

315 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
boolean,
date,
doublePrecision,
index,
integer,
jsonb,
pgTable,
primaryKey,
text,
timestamp,
uniqueIndex,
uuid,
} from "drizzle-orm/pg-core";
import type { NutritionValues, RecipeDNA, Season } from "@app/shared-types";
import {
allergenEnum,
createdAt,
cuisineEnum,
mealTypeEnum,
recipeDifficultyEnum,
recipeSimilarityClassEnum,
recipeSourceTypeEnum,
recipeStatusEnum,
recipeVariantTypeEnum,
recipeVerificationStatusEnum,
unitEnum,
updatedAt,
} 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", {
id: uuid("id").primaryKey().defaultRandom(),
sourceName: text("source_name").notNull(),
license: text("license").notNull(),
rightToStore: boolean("right_to_store").notNull(),
rightToModify: boolean("right_to_modify").notNull(),
rightToDisplay: boolean("right_to_display").notNull(),
attributionRequired: boolean("attribution_required").notNull().default(false),
attributionText: text("attribution_text"),
commercialUse: boolean("commercial_use").notNull(),
validFrom: timestamp("valid_from", { withTimezone: true }).notNull().defaultNow(),
validTo: timestamp("valid_to", { withTimezone: true }),
notes: text("notes"),
createdAt: createdAt(),
});
export const recipes = pgTable(
"recipes",
{
id: uuid("id").primaryKey().defaultRandom(),
slug: text("slug").notNull(),
titleSv: text("title_sv").notNull(),
descriptionSv: text("description_sv").notNull().default(""),
country: text("country"),
region: text("region"),
cuisine: cuisineEnum("cuisine").notNull(),
mealTypes: mealTypeEnum("meal_types").array().notNull().default([]),
tags: text("tags").array().notNull().default([]),
methods: text("methods").array().notNull().default([]),
equipment: text("equipment").array().notNull().default([]),
difficulty: recipeDifficultyEnum("difficulty").notNull().default("easy"),
prepTimeMinutes: integer("prep_time_minutes").notNull().default(0),
cookTimeMinutes: integer("cook_time_minutes").notNull().default(0),
totalTimeMinutes: integer("total_time_minutes").notNull().default(0),
portions: integer("portions").notNull().default(4),
/** Beräknas ALLTID deterministiskt av nutrition-engine (spec §61.1). */
nutritionPerPortion: jsonb("nutrition_per_portion").$type<NutritionValues>().notNull(),
/** Härledda ur ingrediensernas allergener deterministiskt (spec §61.2). */
allergens: allergenEnum("allergens").array().notNull().default([]),
spiceLevel: integer("spice_level").notNull().default(0),
estimatedCostMinorPerPortion: integer("estimated_cost_minor_per_portion"),
storageGuidanceSv: text("storage_guidance_sv"),
mealPrepFriendly: boolean("meal_prep_friendly").notNull().default(false),
freezerFriendly: boolean("freezer_friendly").notNull().default(false),
peakSeasons: text("peak_seasons").array().$type<Season[]>().notNull().default([]),
holidayTags: text("holiday_tags").array().notNull().default([]),
dna: jsonb("dna").$type<RecipeDNA>().notNull(),
variantType: recipeVariantTypeEnum("variant_type").notNull().default("standard"),
variantOfRecipeId: uuid("variant_of_recipe_id"),
forkedFromRecipeId: uuid("forked_from_recipe_id"),
status: recipeStatusEnum("status").notNull().default("draft"),
verificationStatus: recipeVerificationStatusEnum("verification_status")
.notNull()
.default("unverified"),
sourceType: recipeSourceTypeEnum("source_type").notNull(),
sourceRegistryId: uuid("source_registry_id").references(() => recipeSourceRegistry.id),
creatorUserId: uuid("creator_user_id").references(() => users.id, { onDelete: "set null" }),
creatorDisplayName: text("creator_display_name"),
imageUrls: text("image_urls").array().notNull().default([]),
version: integer("version").notNull().default(1),
ratingAverage: doublePrecision("rating_average"),
ratingCount: integer("rating_count").notNull().default(0),
cookCount: integer("cook_count").notNull().default(0),
favoriteCount: integer("favorite_count").notNull().default(0),
moderationNote: text("moderation_note"),
createdAt: createdAt(),
updatedAt: updatedAt(),
},
(t) => [
uniqueIndex("recipes_slug_unique").on(t.slug),
index("recipes_status_idx").on(t.status),
index("recipes_cuisine_idx").on(t.cuisine),
index("recipes_variant_of_idx").on(t.variantOfRecipeId),
index("recipes_creator_idx").on(t.creatorUserId),
index("recipes_total_time_idx").on(t.totalTimeMinutes),
],
);
export const recipeIngredients = pgTable(
"recipe_ingredients",
{
id: uuid("id").primaryKey().defaultRandom(),
recipeId: uuid("recipe_id")
.notNull()
.references(() => recipes.id, { onDelete: "cascade" }),
canonicalIngredientId: text("canonical_ingredient_id")
.notNull()
.references(() => canonicalIngredients.id),
displayNameSv: text("display_name_sv").notNull(),
quantity: doublePrecision("quantity").notNull(),
unit: unitEnum("unit").notNull(),
note: text("note"),
optional: boolean("optional").notNull().default(false),
groupName: text("group_name"),
sortOrder: integer("sort_order").notNull().default(0),
},
(t) => [
index("recipe_ingredients_recipe_idx").on(t.recipeId),
index("recipe_ingredients_canonical_idx").on(t.canonicalIngredientId),
],
);
export const recipeSteps = pgTable(
"recipe_steps",
{
id: uuid("id").primaryKey().defaultRandom(),
recipeId: uuid("recipe_id")
.notNull()
.references(() => recipes.id, { onDelete: "cascade" }),
stepNumber: integer("step_number").notNull(),
instructionSv: text("instruction_sv").notNull(),
timerSeconds: integer("timer_seconds"),
temperatureC: integer("temperature_c"),
tip: text("tip"),
},
(t) => [
index("recipe_steps_recipe_idx").on(t.recipeId),
uniqueIndex("recipe_steps_recipe_step_unique").on(t.recipeId, t.stepNumber),
],
);
export const recipeRatings = pgTable(
"recipe_ratings",
{
id: uuid("id").primaryKey().defaultRandom(),
recipeId: uuid("recipe_id")
.notNull()
.references(() => recipes.id, { onDelete: "cascade" }),
userId: uuid("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
stars: integer("stars").notNull(),
feedbackTags: text("feedback_tags").array().notNull().default([]),
comment: text("comment"),
createdAt: createdAt(),
updatedAt: updatedAt(),
},
(t) => [
uniqueIndex("recipe_ratings_user_recipe_unique").on(t.recipeId, t.userId),
index("recipe_ratings_recipe_idx").on(t.recipeId),
],
);
export const recipeFavorites = pgTable(
"recipe_favorites",
{
recipeId: uuid("recipe_id")
.notNull()
.references(() => recipes.id, { onDelete: "cascade" }),
userId: uuid("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
createdAt: createdAt(),
},
(t) => [
primaryKey({ columns: [t.recipeId, t.userId] }),
index("recipe_favorites_user_idx").on(t.userId),
],
);
/** 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"),
/** Måltidsdatum (UTC) som rester/matlådor knyts till. */
mealDate: date("meal_date"),
/** Vilka meal_boxes som påverkades av sessionen och med hur mycket. */
mealBoxMutations: jsonb("meal_box_mutations").$type<
Array<{ mealBoxId: string; deltaPortions: number; frozen: boolean }>
>(),
leftoverNote: text("leftover_note"),
/** 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",
{
id: uuid("id").primaryKey().defaultRandom(),
recipeId: uuid("recipe_id")
.notNull()
.references(() => recipes.id, { onDelete: "cascade" }),
userId: uuid("user_id")
.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),
],
);
/** Antagandeprofiler per hushåll och ingrediens (Fas 3 §6.2).
* Lär sig över tid hur stor andel av ett recept som brukar ätas resp. bli rester.
*/
export const cookingAssumptionProfiles = pgTable(
"cooking_assumption_profiles",
{
householdId: uuid("household_id")
.notNull()
.references(() => households.id, { onDelete: "cascade" }),
canonicalIngredientId: text("canonical_ingredient_id")
.notNull()
.references(() => canonicalIngredients.id, { onDelete: "cascade" }),
/** Medelförbrukning i portioner per tillfälle (null tills vi har data). */
averageEatenPortions: doublePrecision("average_eaten_portions"),
/** Medelantal restportioner per tillfälle. */
averageLeftoverPortions: doublePrecision("average_leftover_portions"),
/** Hur många observationer profilen bygger på. */
observationCount: integer("observation_count").notNull().default(0),
/** Senaste rådata för felsökning/återspelning. */
lastSessionAnswers: jsonb("last_session_answers").$type<
Array<{ sessionId: string; eaten: number; leftovers: number; date: string }>
>(),
updatedAt: updatedAt(),
},
(t) => [
primaryKey({ columns: [t.householdId, t.canonicalIngredientId] }),
index("cooking_assumption_profiles_household_idx").on(t.householdId),
],
);
/** Dubblett-/variantklassning mellan recept (spec §36). */
export const recipeSimilarities = pgTable(
"recipe_similarities",
{
id: uuid("id").primaryKey().defaultRandom(),
recipeAId: uuid("recipe_a_id")
.notNull()
.references(() => recipes.id, { onDelete: "cascade" }),
recipeBId: uuid("recipe_b_id")
.notNull()
.references(() => recipes.id, { onDelete: "cascade" }),
similarityScore: doublePrecision("similarity_score").notNull(),
classification: recipeSimilarityClassEnum("classification").notNull(),
details: jsonb("details"),
createdAt: createdAt(),
},
(t) => [uniqueIndex("recipe_similarities_pair_unique").on(t.recipeAId, t.recipeBId)],
);