74f95daab2
- 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
315 lines
12 KiB
TypeScript
315 lines
12 KiB
TypeScript
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 §37–39). */
|
||
/** 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 §37–39). */
|
||
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)],
|
||
);
|