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().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().notNull().default([]), holidayTags: text("holiday_tags").array().notNull().default([]), dna: jsonb("dna").$type().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)], );