feat(db): Drizzle ORM schema and migrations

Schema domains: users/auth, recipes, social, meal-planning, tiers/usage, webhooks.
Includes Better Auth tables, audit_logs, site_settings, push_subscriptions,
user_model_prefs, user_nutrition_goals. Migrations 0000–0008 applied.
This commit is contained in:
Arnaud
2026-07-01 08:08:44 +02:00
parent ae7c5d943e
commit add9365250
39 changed files with 25552 additions and 0 deletions
+170
View File
@@ -0,0 +1,170 @@
import {
pgTable,
text,
timestamp,
boolean,
integer,
decimal,
jsonb,
pgEnum,
index,
} from "drizzle-orm/pg-core";
import { relations } from "drizzle-orm";
import { users } from "./users";
export const visibilityEnum = pgEnum("visibility", ["private", "unlisted", "public"]);
export const difficultyEnum = pgEnum("difficulty", ["easy", "medium", "hard"]);
export type DietaryTags = {
vegan?: boolean;
vegetarian?: boolean;
glutenFree?: boolean;
dairyFree?: boolean;
nutFree?: boolean;
halal?: boolean;
kosher?: boolean;
};
export const recipes = pgTable("recipes", {
id: text("id").primaryKey(),
authorId: text("author_id").notNull().references(() => users.id, { onDelete: "cascade" }),
title: text("title").notNull(),
description: text("description"),
baseServings: integer("base_servings").notNull().default(4),
visibility: visibilityEnum("visibility").notNull().default("private"),
sourceUrl: text("source_url"),
aiGenerated: boolean("ai_generated").notNull().default(false),
aiModel: text("ai_model"),
aiPrompt: text("ai_prompt"),
dietaryTags: jsonb("dietary_tags").$type<DietaryTags>().default({}),
dietaryVerified: boolean("dietary_verified").notNull().default(false),
nutritionData: jsonb("nutrition_data").$type<{ perServing: { calories: number; proteinG: number; carbsG: number; fatG: number; fiberG: number; sodiumMg: number } }>(),
difficulty: difficultyEnum("difficulty"),
prepMins: integer("prep_mins"),
cookMins: integer("cook_mins"),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
}, (t) => [
index("recipes_author_idx").on(t.authorId),
index("recipes_visibility_idx").on(t.visibility),
]);
export const ingredients = pgTable("ingredients", {
id: text("id").primaryKey(),
name: text("name").notNull().unique(),
aliases: text("aliases").array().notNull().default([]),
category: text("category"),
knownAllergens: text("known_allergens").array().notNull().default([]),
createdAt: timestamp("created_at").notNull().defaultNow(),
});
export const recipeIngredients = pgTable("recipe_ingredients", {
id: text("id").primaryKey(),
recipeId: text("recipe_id").notNull().references(() => recipes.id, { onDelete: "cascade" }),
ingredientId: text("ingredient_id").references(() => ingredients.id, { onDelete: "set null" }),
rawName: text("raw_name").notNull(),
quantity: decimal("quantity", { precision: 10, scale: 4 }),
unit: text("unit"),
note: text("note"),
order: integer("order").notNull().default(0),
});
export const recipeSteps = pgTable("recipe_steps", {
id: text("id").primaryKey(),
recipeId: text("recipe_id").notNull().references(() => recipes.id, { onDelete: "cascade" }),
order: integer("order").notNull(),
instruction: text("instruction").notNull(),
timerSeconds: integer("timer_seconds"),
photoUrl: text("photo_url"),
});
export const recipePhotos = pgTable("recipe_photos", {
id: text("id").primaryKey(),
recipeId: text("recipe_id").notNull().references(() => recipes.id, { onDelete: "cascade" }),
storageKey: text("storage_key").notNull(),
order: integer("order").notNull().default(0),
isCover: boolean("is_cover").notNull().default(false),
createdAt: timestamp("created_at").notNull().defaultNow(),
});
export const recipeNotes = pgTable("recipe_notes", {
id: text("id").primaryKey(),
recipeId: text("recipe_id").notNull().references(() => recipes.id, { onDelete: "cascade" }),
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
content: text("content").notNull(),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});
export const recipeVariations = pgTable("recipe_variations", {
id: text("id").primaryKey(),
parentRecipeId: text("parent_recipe_id").notNull().references(() => recipes.id, { onDelete: "cascade" }),
childRecipeId: text("child_recipe_id").notNull().references(() => recipes.id, { onDelete: "cascade" }),
description: text("description"),
aiGenerated: boolean("ai_generated").notNull().default(false),
createdAt: timestamp("created_at").notNull().defaultNow(),
});
export const userAllergens = pgTable("user_allergens", {
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
allergenTag: text("allergen_tag").notNull(),
});
export const recipesRelations = relations(recipes, ({ one, many }) => ({
author: one(users, { fields: [recipes.authorId], references: [users.id] }),
ingredients: many(recipeIngredients),
steps: many(recipeSteps),
photos: many(recipePhotos),
notes: many(recipeNotes),
childVariations: many(recipeVariations, { relationName: "parent" }),
parentVariations: many(recipeVariations, { relationName: "child" }),
}));
export const recipeIngredientsRelations = relations(recipeIngredients, ({ one }) => ({
recipe: one(recipes, { fields: [recipeIngredients.recipeId], references: [recipes.id] }),
ingredient: one(ingredients, { fields: [recipeIngredients.ingredientId], references: [ingredients.id] }),
}));
export const recipePhotosRelations = relations(recipePhotos, ({ one }) => ({
recipe: one(recipes, { fields: [recipePhotos.recipeId], references: [recipes.id] }),
}));
export const recipeStepsRelations = relations(recipeSteps, ({ one }) => ({
recipe: one(recipes, { fields: [recipeSteps.recipeId], references: [recipes.id] }),
}));
export const recipeNotesRelations = relations(recipeNotes, ({ one }) => ({
recipe: one(recipes, { fields: [recipeNotes.recipeId], references: [recipes.id] }),
}));
export const recipeVariationsRelations = relations(recipeVariations, ({ one }) => ({
parent: one(recipes, { fields: [recipeVariations.parentRecipeId], references: [recipes.id], relationName: "parent" }),
child: one(recipes, { fields: [recipeVariations.childRecipeId], references: [recipes.id], relationName: "child" }),
}));
export const recipeSnapshots = pgTable("recipe_snapshots", {
id: text("id").primaryKey(),
recipeId: text("recipe_id").notNull().references(() => recipes.id, { onDelete: "cascade" }),
authorId: text("author_id").notNull().references(() => users.id, { onDelete: "cascade" }),
version: integer("version").notNull(),
title: text("title").notNull(),
snapshotData: jsonb("snapshot_data").notNull().$type<{
title: string;
description: string | null;
baseServings: number;
difficulty: string | null;
prepMins: number | null;
cookMins: number | null;
dietaryTags: Record<string, boolean>;
ingredients: Array<{ rawName: string; quantity: string | null; unit: string | null; note: string | null; order: number }>;
steps: Array<{ instruction: string; timerSeconds: number | null; order: number }>;
}>(),
createdAt: timestamp("created_at").notNull().defaultNow(),
}, (t) => [
index("recipe_snapshots_recipe_idx").on(t.recipeId, t.version),
]);
export const recipeSnapshotsRelations = relations(recipeSnapshots, ({ one }) => ({
recipe: one(recipes, { fields: [recipeSnapshots.recipeId], references: [recipes.id] }),
author: one(users, { fields: [recipeSnapshots.authorId], references: [users.id] }),
}));