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
+16
View File
@@ -0,0 +1,16 @@
{
"name": "@epicure/api-types",
"version": "0.0.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"exports": {
".": "./src/index.ts"
},
"dependencies": {
"zod": "^3.25.67"
},
"devDependencies": {
"typescript": "^5.8.3"
}
}
+43
View File
@@ -0,0 +1,43 @@
import { z } from "zod";
import { CreateRecipeSchema, DietaryTagsSchema } from "./recipes";
export const GenerateRecipeRequestSchema = z.object({
prompt: z.string().min(1).max(1000),
servings: z.number().int().positive().default(4),
dietary: DietaryTagsSchema.optional(),
difficulty: z.enum(["easy", "medium", "hard"]).optional(),
model: z.string().optional(),
});
export const SuggestVariationsRequestSchema = z.object({
recipeId: z.string(),
count: z.number().int().min(1).max(5).default(3),
model: z.string().optional(),
});
export const ImportFromUrlRequestSchema = z.object({
url: z.string().url(),
model: z.string().optional(),
});
export const AiGeneratedRecipeSchema = CreateRecipeSchema.extend({
nutrition: z.object({
calories: z.number(),
proteinG: z.number(),
carbsG: z.number(),
fatG: z.number(),
fiberG: z.number(),
}).optional(),
});
export const VariationSuggestionSchema = z.object({
title: z.string(),
description: z.string(),
changes: z.array(z.string()),
recipe: CreateRecipeSchema,
});
export type GenerateRecipeRequest = z.infer<typeof GenerateRecipeRequestSchema>;
export type ImportFromUrlRequest = z.infer<typeof ImportFromUrlRequestSchema>;
export type AiGeneratedRecipe = z.infer<typeof AiGeneratedRecipeSchema>;
export type VariationSuggestion = z.infer<typeof VariationSuggestionSchema>;
+26
View File
@@ -0,0 +1,26 @@
import { z } from "zod";
export const PaginationSchema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
});
export const PaginatedResponseSchema = <T extends z.ZodTypeAny>(itemSchema: T) =>
z.object({
data: z.array(itemSchema),
pagination: z.object({
page: z.number().int(),
limit: z.number().int(),
total: z.number().int(),
pages: z.number().int(),
}),
});
export const ApiErrorSchema = z.object({
error: z.string(),
code: z.string().optional(),
details: z.unknown().optional(),
});
export type Pagination = z.infer<typeof PaginationSchema>;
export type ApiError = z.infer<typeof ApiErrorSchema>;
+4
View File
@@ -0,0 +1,4 @@
export * from "./common";
export * from "./recipes";
export * from "./users";
export * from "./ai";
+88
View File
@@ -0,0 +1,88 @@
import { z } from "zod";
export const DietaryTagsSchema = z.object({
vegan: z.boolean().optional(),
vegetarian: z.boolean().optional(),
glutenFree: z.boolean().optional(),
dairyFree: z.boolean().optional(),
nutFree: z.boolean().optional(),
halal: z.boolean().optional(),
kosher: z.boolean().optional(),
});
export const RecipeIngredientSchema = z.object({
id: z.string(),
rawName: z.string().min(1),
quantity: z.number().positive().nullable(),
unit: z.string().nullable(),
note: z.string().nullable(),
order: z.number().int(),
});
export const RecipeStepSchema = z.object({
id: z.string(),
order: z.number().int(),
instruction: z.string().min(1),
timerSeconds: z.number().int().positive().nullable(),
photoUrl: z.string().url().nullable(),
});
export const RecipePhotoSchema = z.object({
id: z.string(),
storageKey: z.string(),
order: z.number().int(),
isCover: z.boolean(),
});
export const RecipeSchema = z.object({
id: z.string(),
authorId: z.string(),
title: z.string().min(1).max(200),
description: z.string().nullable(),
baseServings: z.number().int().positive(),
visibility: z.enum(["private", "unlisted", "public"]),
sourceUrl: z.string().url().nullable(),
aiGenerated: z.boolean(),
aiModel: z.string().nullable(),
dietaryTags: DietaryTagsSchema,
dietaryVerified: z.boolean(),
difficulty: z.enum(["easy", "medium", "hard"]).nullable(),
prepMins: z.number().int().positive().nullable(),
cookMins: z.number().int().positive().nullable(),
ingredients: z.array(RecipeIngredientSchema),
steps: z.array(RecipeStepSchema),
photos: z.array(RecipePhotoSchema),
createdAt: z.string().datetime(),
updatedAt: z.string().datetime(),
});
export const CreateRecipeSchema = RecipeSchema.omit({
id: true,
authorId: true,
aiGenerated: true,
aiModel: true,
createdAt: true,
updatedAt: true,
}).extend({
ingredients: z.array(RecipeIngredientSchema.omit({ id: true })),
steps: z.array(RecipeStepSchema.omit({ id: true })),
photos: z.array(RecipePhotoSchema.omit({ id: true })),
});
export const UpdateRecipeSchema = CreateRecipeSchema.partial();
export const RecipeListQuerySchema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
visibility: z.enum(["private", "unlisted", "public"]).optional(),
authorId: z.string().optional(),
q: z.string().optional(),
dietary: z.string().optional(),
difficulty: z.enum(["easy", "medium", "hard"]).optional(),
});
export type Recipe = z.infer<typeof RecipeSchema>;
export type CreateRecipe = z.infer<typeof CreateRecipeSchema>;
export type UpdateRecipe = z.infer<typeof UpdateRecipeSchema>;
export type RecipeListQuery = z.infer<typeof RecipeListQuerySchema>;
export type DietaryTags = z.infer<typeof DietaryTagsSchema>;
+40
View File
@@ -0,0 +1,40 @@
import { z } from "zod";
export const UserSchema = z.object({
id: z.string(),
email: z.string().email(),
name: z.string().min(1),
username: z.string().min(3).max(30).regex(/^[a-z0-9_-]+$/).nullable(),
avatarUrl: z.string().url().nullable(),
bio: z.string().max(500).nullable(),
role: z.enum(["user", "moderator", "admin"]),
tier: z.enum(["free", "pro"]),
unitPref: z.enum(["metric", "imperial"]),
createdAt: z.string().datetime(),
});
export const PublicUserSchema = UserSchema.pick({
id: true,
name: true,
username: true,
avatarUrl: true,
bio: true,
createdAt: true,
});
export const UpdateUserSchema = z.object({
name: z.string().min(1).max(100).optional(),
username: z.string().min(3).max(30).regex(/^[a-z0-9_-]+$/).optional(),
bio: z.string().max(500).optional(),
unitPref: z.enum(["metric", "imperial"]).optional(),
});
export const AdminUpdateUserSchema = UpdateUserSchema.extend({
role: z.enum(["user", "moderator", "admin"]).optional(),
tier: z.enum(["free", "pro"]).optional(),
});
export type User = z.infer<typeof UserSchema>;
export type PublicUser = z.infer<typeof PublicUserSchema>;
export type UpdateUser = z.infer<typeof UpdateUserSchema>;
export type AdminUpdateUser = z.infer<typeof AdminUpdateUserSchema>;
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "../../tsconfig.base.json",
"include": ["src"]
}
+12
View File
@@ -0,0 +1,12 @@
import { defineConfig } from "drizzle-kit";
export default defineConfig({
schema: "./src/schema/index.ts",
out: "./src/migrations",
dialect: "postgresql",
dbCredentials: {
url: process.env["DATABASE_URL"] ?? "postgresql://epicure:epicure@localhost:5432/epicure",
},
verbose: true,
strict: true,
});
+26
View File
@@ -0,0 +1,26 @@
{
"name": "@epicure/db",
"version": "0.0.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./schema": "./src/schema/index.ts"
},
"scripts": {
"generate": "drizzle-kit generate",
"migrate": "drizzle-kit migrate",
"studio": "drizzle-kit studio",
"seed": "tsx src/seed.ts"
},
"dependencies": {
"drizzle-orm": "^0.44.7",
"postgres": "^3.4.7"
},
"devDependencies": {
"drizzle-kit": "^0.31.1",
"tsx": "^4.20.3",
"typescript": "^5.8.3"
}
}
+11
View File
@@ -0,0 +1,11 @@
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "./schema";
const connectionString =
process.env["DATABASE_URL"] ?? "postgresql://epicure:epicure@localhost:5432/epicure";
const client = postgres(connectionString, { max: 10 });
export const db = drizzle(client, { schema });
export type Db = typeof db;
+5
View File
@@ -0,0 +1,5 @@
export { db } from "./client";
export type { Db } from "./client";
export * from "./schema";
// Re-export query helpers so all callers use the same drizzle-orm instance
export { eq, and, or, desc, asc, gt, lt, gte, lte, ne, inArray, isNull, isNotNull, sql, count, avg, sum, min, max, ilike, like } from "drizzle-orm";
@@ -0,0 +1,338 @@
CREATE TYPE "public"."tier" AS ENUM('free', 'pro');--> statement-breakpoint
CREATE TYPE "public"."unit_pref" AS ENUM('metric', 'imperial');--> statement-breakpoint
CREATE TYPE "public"."user_role" AS ENUM('user', 'moderator', 'admin');--> statement-breakpoint
CREATE TYPE "public"."difficulty" AS ENUM('easy', 'medium', 'hard');--> statement-breakpoint
CREATE TYPE "public"."visibility" AS ENUM('private', 'unlisted', 'public');--> statement-breakpoint
CREATE TYPE "public"."feed_item_type" AS ENUM('new_recipe', 'new_follow', 'recipe_rated');--> statement-breakpoint
CREATE TYPE "public"."meal_type" AS ENUM('breakfast', 'lunch', 'dinner', 'snack');--> statement-breakpoint
CREATE TYPE "public"."weekday" AS ENUM('mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun');--> statement-breakpoint
CREATE TABLE "accounts" (
"id" text PRIMARY KEY NOT NULL,
"account_id" text NOT NULL,
"provider_id" text NOT NULL,
"user_id" text NOT NULL,
"access_token" text,
"refresh_token" text,
"id_token" text,
"access_token_expires_at" timestamp,
"refresh_token_expires_at" timestamp,
"scope" text,
"password" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "api_keys" (
"id" text PRIMARY KEY NOT NULL,
"user_id" text NOT NULL,
"name" text NOT NULL,
"key_hash" text NOT NULL,
"last_used_at" timestamp,
"created_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "api_keys_key_hash_unique" UNIQUE("key_hash")
);
--> statement-breakpoint
CREATE TABLE "sessions" (
"id" text PRIMARY KEY NOT NULL,
"expires_at" timestamp NOT NULL,
"token" text NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
"ip_address" text,
"user_agent" text,
"user_id" text NOT NULL,
CONSTRAINT "sessions_token_unique" UNIQUE("token")
);
--> statement-breakpoint
CREATE TABLE "user_follows" (
"follower_id" text NOT NULL,
"following_id" text NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "users" (
"id" text PRIMARY KEY NOT NULL,
"email" text NOT NULL,
"email_verified" boolean DEFAULT false NOT NULL,
"name" text NOT NULL,
"avatar_url" text,
"bio" text,
"username" text,
"role" "user_role" DEFAULT 'user' NOT NULL,
"tier" "tier" DEFAULT 'free' NOT NULL,
"unit_pref" "unit_pref" DEFAULT 'metric' NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "users_email_unique" UNIQUE("email"),
CONSTRAINT "users_username_unique" UNIQUE("username")
);
--> statement-breakpoint
CREATE TABLE "verifications" (
"id" text PRIMARY KEY NOT NULL,
"identifier" text NOT NULL,
"value" text NOT NULL,
"expires_at" timestamp NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "ingredients" (
"id" text PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"aliases" text[] DEFAULT '{}' NOT NULL,
"category" text,
"known_allergens" text[] DEFAULT '{}' NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "ingredients_name_unique" UNIQUE("name")
);
--> statement-breakpoint
CREATE TABLE "recipe_ingredients" (
"id" text PRIMARY KEY NOT NULL,
"recipe_id" text NOT NULL,
"ingredient_id" text,
"raw_name" text NOT NULL,
"quantity" numeric(10, 4),
"unit" text,
"note" text,
"order" integer DEFAULT 0 NOT NULL
);
--> statement-breakpoint
CREATE TABLE "recipe_notes" (
"id" text PRIMARY KEY NOT NULL,
"recipe_id" text NOT NULL,
"user_id" text NOT NULL,
"content" text NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "recipe_photos" (
"id" text PRIMARY KEY NOT NULL,
"recipe_id" text NOT NULL,
"storage_key" text NOT NULL,
"order" integer DEFAULT 0 NOT NULL,
"is_cover" boolean DEFAULT false NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "recipe_steps" (
"id" text PRIMARY KEY NOT NULL,
"recipe_id" text NOT NULL,
"order" integer NOT NULL,
"instruction" text NOT NULL,
"timer_seconds" integer,
"photo_url" text
);
--> statement-breakpoint
CREATE TABLE "recipe_variations" (
"id" text PRIMARY KEY NOT NULL,
"parent_recipe_id" text NOT NULL,
"child_recipe_id" text NOT NULL,
"description" text,
"ai_generated" boolean DEFAULT false NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "recipes" (
"id" text PRIMARY KEY NOT NULL,
"author_id" text NOT NULL,
"title" text NOT NULL,
"description" text,
"base_servings" integer DEFAULT 4 NOT NULL,
"visibility" "visibility" DEFAULT 'private' NOT NULL,
"source_url" text,
"ai_generated" boolean DEFAULT false NOT NULL,
"ai_model" text,
"ai_prompt" text,
"dietary_tags" jsonb DEFAULT '{}'::jsonb,
"dietary_verified" boolean DEFAULT false NOT NULL,
"difficulty" "difficulty",
"prep_mins" integer,
"cook_mins" integer,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "user_allergens" (
"user_id" text NOT NULL,
"allergen_tag" text NOT NULL
);
--> statement-breakpoint
CREATE TABLE "collection_recipes" (
"collection_id" text NOT NULL,
"recipe_id" text NOT NULL,
"added_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "collections" (
"id" text PRIMARY KEY NOT NULL,
"user_id" text NOT NULL,
"name" text NOT NULL,
"description" text,
"is_public" boolean DEFAULT false NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "comments" (
"id" text PRIMARY KEY NOT NULL,
"recipe_id" text NOT NULL,
"user_id" text NOT NULL,
"parent_id" text,
"content" text NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "cooking_history" (
"id" text PRIMARY KEY NOT NULL,
"user_id" text NOT NULL,
"recipe_id" text NOT NULL,
"cooked_at" timestamp DEFAULT now() NOT NULL,
"servings" integer,
"notes" text
);
--> statement-breakpoint
CREATE TABLE "favorites" (
"user_id" text NOT NULL,
"recipe_id" text NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "feed_items" (
"id" text PRIMARY KEY NOT NULL,
"user_id" text NOT NULL,
"type" "feed_item_type" NOT NULL,
"actor_id" text NOT NULL,
"subject_id" text NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "ratings" (
"id" text PRIMARY KEY NOT NULL,
"recipe_id" text NOT NULL,
"user_id" text NOT NULL,
"score" integer NOT NULL,
"review_text" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "meal_plan_entries" (
"id" text PRIMARY KEY NOT NULL,
"meal_plan_id" text NOT NULL,
"day" "weekday" NOT NULL,
"meal_type" "meal_type" NOT NULL,
"recipe_id" text,
"servings" integer DEFAULT 2 NOT NULL,
"note" text
);
--> statement-breakpoint
CREATE TABLE "meal_plans" (
"id" text PRIMARY KEY NOT NULL,
"user_id" text NOT NULL,
"week_start" date NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "pantry_items" (
"id" text PRIMARY KEY NOT NULL,
"user_id" text NOT NULL,
"ingredient_id" text,
"raw_name" text NOT NULL,
"quantity" numeric(10, 4),
"unit" text,
"expires_at" timestamp,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "shopping_list_items" (
"id" text PRIMARY KEY NOT NULL,
"list_id" text NOT NULL,
"ingredient_id" text,
"raw_name" text NOT NULL,
"quantity" numeric(10, 4),
"unit" text,
"aisle" text,
"checked" boolean DEFAULT false NOT NULL
);
--> statement-breakpoint
CREATE TABLE "shopping_lists" (
"id" text PRIMARY KEY NOT NULL,
"user_id" text,
"name" text NOT NULL,
"generated_at" timestamp,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "audit_logs" (
"id" text PRIMARY KEY NOT NULL,
"user_id" text,
"action" text NOT NULL,
"target_type" text,
"target_id" text,
"metadata" text,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "tier_definitions" (
"tier" "tier" PRIMARY KEY NOT NULL,
"max_recipes" integer NOT NULL,
"ai_calls_per_month" integer NOT NULL,
"storage_mb" integer NOT NULL,
"max_public_recipes" integer NOT NULL
);
--> statement-breakpoint
CREATE TABLE "user_usage" (
"id" text PRIMARY KEY NOT NULL,
"user_id" text NOT NULL,
"month" text NOT NULL,
"ai_calls_used" integer DEFAULT 0 NOT NULL,
"recipe_count" integer DEFAULT 0 NOT NULL,
"storage_used_mb" integer DEFAULT 0 NOT NULL
);
--> statement-breakpoint
ALTER TABLE "accounts" ADD CONSTRAINT "accounts_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "api_keys" ADD CONSTRAINT "api_keys_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "user_follows" ADD CONSTRAINT "user_follows_follower_id_users_id_fk" FOREIGN KEY ("follower_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "user_follows" ADD CONSTRAINT "user_follows_following_id_users_id_fk" FOREIGN KEY ("following_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "recipe_ingredients" ADD CONSTRAINT "recipe_ingredients_recipe_id_recipes_id_fk" FOREIGN KEY ("recipe_id") REFERENCES "public"."recipes"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "recipe_ingredients" ADD CONSTRAINT "recipe_ingredients_ingredient_id_ingredients_id_fk" FOREIGN KEY ("ingredient_id") REFERENCES "public"."ingredients"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "recipe_notes" ADD CONSTRAINT "recipe_notes_recipe_id_recipes_id_fk" FOREIGN KEY ("recipe_id") REFERENCES "public"."recipes"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "recipe_notes" ADD CONSTRAINT "recipe_notes_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "recipe_photos" ADD CONSTRAINT "recipe_photos_recipe_id_recipes_id_fk" FOREIGN KEY ("recipe_id") REFERENCES "public"."recipes"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "recipe_steps" ADD CONSTRAINT "recipe_steps_recipe_id_recipes_id_fk" FOREIGN KEY ("recipe_id") REFERENCES "public"."recipes"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "recipe_variations" ADD CONSTRAINT "recipe_variations_parent_recipe_id_recipes_id_fk" FOREIGN KEY ("parent_recipe_id") REFERENCES "public"."recipes"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "recipe_variations" ADD CONSTRAINT "recipe_variations_child_recipe_id_recipes_id_fk" FOREIGN KEY ("child_recipe_id") REFERENCES "public"."recipes"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "recipes" ADD CONSTRAINT "recipes_author_id_users_id_fk" FOREIGN KEY ("author_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "user_allergens" ADD CONSTRAINT "user_allergens_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "collection_recipes" ADD CONSTRAINT "collection_recipes_collection_id_collections_id_fk" FOREIGN KEY ("collection_id") REFERENCES "public"."collections"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "collection_recipes" ADD CONSTRAINT "collection_recipes_recipe_id_recipes_id_fk" FOREIGN KEY ("recipe_id") REFERENCES "public"."recipes"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "collections" ADD CONSTRAINT "collections_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "comments" ADD CONSTRAINT "comments_recipe_id_recipes_id_fk" FOREIGN KEY ("recipe_id") REFERENCES "public"."recipes"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "comments" ADD CONSTRAINT "comments_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "cooking_history" ADD CONSTRAINT "cooking_history_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "cooking_history" ADD CONSTRAINT "cooking_history_recipe_id_recipes_id_fk" FOREIGN KEY ("recipe_id") REFERENCES "public"."recipes"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "favorites" ADD CONSTRAINT "favorites_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "favorites" ADD CONSTRAINT "favorites_recipe_id_recipes_id_fk" FOREIGN KEY ("recipe_id") REFERENCES "public"."recipes"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "feed_items" ADD CONSTRAINT "feed_items_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "feed_items" ADD CONSTRAINT "feed_items_actor_id_users_id_fk" FOREIGN KEY ("actor_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "ratings" ADD CONSTRAINT "ratings_recipe_id_recipes_id_fk" FOREIGN KEY ("recipe_id") REFERENCES "public"."recipes"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "ratings" ADD CONSTRAINT "ratings_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "meal_plan_entries" ADD CONSTRAINT "meal_plan_entries_meal_plan_id_meal_plans_id_fk" FOREIGN KEY ("meal_plan_id") REFERENCES "public"."meal_plans"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "meal_plan_entries" ADD CONSTRAINT "meal_plan_entries_recipe_id_recipes_id_fk" FOREIGN KEY ("recipe_id") REFERENCES "public"."recipes"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "meal_plans" ADD CONSTRAINT "meal_plans_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "pantry_items" ADD CONSTRAINT "pantry_items_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "pantry_items" ADD CONSTRAINT "pantry_items_ingredient_id_ingredients_id_fk" FOREIGN KEY ("ingredient_id") REFERENCES "public"."ingredients"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "shopping_list_items" ADD CONSTRAINT "shopping_list_items_list_id_shopping_lists_id_fk" FOREIGN KEY ("list_id") REFERENCES "public"."shopping_lists"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "shopping_list_items" ADD CONSTRAINT "shopping_list_items_ingredient_id_ingredients_id_fk" FOREIGN KEY ("ingredient_id") REFERENCES "public"."ingredients"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "shopping_lists" ADD CONSTRAINT "shopping_lists_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "audit_logs" ADD CONSTRAINT "audit_logs_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "user_usage" ADD CONSTRAINT "user_usage_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "recipes_author_idx" ON "recipes" USING btree ("author_id");--> statement-breakpoint
CREATE INDEX "recipes_visibility_idx" ON "recipes" USING btree ("visibility");--> statement-breakpoint
CREATE INDEX "comments_recipe_idx" ON "comments" USING btree ("recipe_id");--> statement-breakpoint
CREATE INDEX "feed_items_user_idx" ON "feed_items" USING btree ("user_id","created_at");--> statement-breakpoint
CREATE INDEX "audit_logs_created_idx" ON "audit_logs" USING btree ("created_at");--> statement-breakpoint
CREATE INDEX "user_usage_user_month_idx" ON "user_usage" USING btree ("user_id","month");
@@ -0,0 +1 @@
ALTER TABLE "user_usage" ADD CONSTRAINT "user_usage_user_month_uniq" UNIQUE("user_id","month");
@@ -0,0 +1 @@
ALTER TABLE "recipes" ADD COLUMN "nutrition_data" jsonb;
@@ -0,0 +1,23 @@
CREATE TABLE "webhook_deliveries" (
"id" text PRIMARY KEY NOT NULL,
"webhook_id" text NOT NULL,
"event" text NOT NULL,
"payload" jsonb,
"status_code" integer,
"success" boolean DEFAULT false NOT NULL,
"attempts" integer DEFAULT 0 NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "webhooks" (
"id" text PRIMARY KEY NOT NULL,
"user_id" text NOT NULL,
"url" text NOT NULL,
"events" text[] DEFAULT '{}' NOT NULL,
"secret" text NOT NULL,
"active" boolean DEFAULT true NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "webhook_deliveries" ADD CONSTRAINT "webhook_deliveries_webhook_id_webhooks_id_fk" FOREIGN KEY ("webhook_id") REFERENCES "public"."webhooks"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "webhooks" ADD CONSTRAINT "webhooks_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
@@ -0,0 +1 @@
ALTER TABLE "users" ADD COLUMN "locale" text DEFAULT 'en' NOT NULL;
@@ -0,0 +1,9 @@
CREATE TABLE "user_ai_keys" (
"id" text PRIMARY KEY NOT NULL,
"user_id" text NOT NULL,
"provider" text NOT NULL,
"encrypted_key" text NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "user_ai_keys" ADD CONSTRAINT "user_ai_keys_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
@@ -0,0 +1,59 @@
CREATE TYPE "public"."collection_member_role" AS ENUM('viewer', 'editor');--> statement-breakpoint
CREATE TYPE "public"."comment_reaction_type" AS ENUM('like', 'love', 'laugh', 'wow', 'sad', 'fire');--> statement-breakpoint
CREATE TABLE "push_subscriptions" (
"id" text PRIMARY KEY NOT NULL,
"user_id" text NOT NULL,
"endpoint" text NOT NULL,
"p256dh" text NOT NULL,
"auth" text NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "push_subscriptions_endpoint_unique" UNIQUE("endpoint")
);
--> statement-breakpoint
CREATE TABLE "user_nutrition_goals" (
"id" text PRIMARY KEY NOT NULL,
"user_id" text NOT NULL,
"calories_kcal" integer,
"protein_g" integer,
"carbs_g" integer,
"fat_g" integer,
"updated_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "user_nutrition_goals_user_id_unique" UNIQUE("user_id")
);
--> statement-breakpoint
CREATE TABLE "recipe_snapshots" (
"id" text PRIMARY KEY NOT NULL,
"recipe_id" text NOT NULL,
"author_id" text NOT NULL,
"version" integer NOT NULL,
"title" text NOT NULL,
"snapshot_data" jsonb NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "collection_members" (
"id" text PRIMARY KEY NOT NULL,
"collection_id" text NOT NULL,
"user_id" text NOT NULL,
"role" "collection_member_role" DEFAULT 'viewer' NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "comment_reactions" (
"id" text PRIMARY KEY NOT NULL,
"comment_id" text NOT NULL,
"user_id" text NOT NULL,
"type" "comment_reaction_type" NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "push_subscriptions" ADD CONSTRAINT "push_subscriptions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "user_nutrition_goals" ADD CONSTRAINT "user_nutrition_goals_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "recipe_snapshots" ADD CONSTRAINT "recipe_snapshots_recipe_id_recipes_id_fk" FOREIGN KEY ("recipe_id") REFERENCES "public"."recipes"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "recipe_snapshots" ADD CONSTRAINT "recipe_snapshots_author_id_users_id_fk" FOREIGN KEY ("author_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "collection_members" ADD CONSTRAINT "collection_members_collection_id_collections_id_fk" FOREIGN KEY ("collection_id") REFERENCES "public"."collections"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "collection_members" ADD CONSTRAINT "collection_members_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "comment_reactions" ADD CONSTRAINT "comment_reactions_comment_id_comments_id_fk" FOREIGN KEY ("comment_id") REFERENCES "public"."comments"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "comment_reactions" ADD CONSTRAINT "comment_reactions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "recipe_snapshots_recipe_idx" ON "recipe_snapshots" USING btree ("recipe_id","version");--> statement-breakpoint
CREATE INDEX "comment_reactions_comment_idx" ON "comment_reactions" USING btree ("comment_id");
@@ -0,0 +1,14 @@
CREATE TABLE "user_model_prefs" (
"id" text PRIMARY KEY NOT NULL,
"user_id" text NOT NULL,
"text_provider" text,
"text_model" text,
"vision_provider" text,
"vision_model" text,
"meal_plan_provider" text,
"meal_plan_model" text,
"updated_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "user_model_prefs_user_id_unique" UNIQUE("user_id")
);
--> statement-breakpoint
ALTER TABLE "user_model_prefs" ADD CONSTRAINT "user_model_prefs_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
@@ -0,0 +1,9 @@
CREATE TABLE "site_settings" (
"key" text PRIMARY KEY NOT NULL,
"value" text,
"is_secret" boolean DEFAULT false NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
"updated_by_id" text
);
--> statement-breakpoint
ALTER TABLE "site_settings" ADD CONSTRAINT "site_settings_updated_by_id_users_id_fk" FOREIGN KEY ("updated_by_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,69 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1782374484140,
"tag": "0000_motionless_cardiac",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1782394660764,
"tag": "0001_curvy_crystal",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1782408717265,
"tag": "0002_freezing_prowler",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1782408796095,
"tag": "0003_robust_bullseye",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1782854086888,
"tag": "0004_chemical_deadpool",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1782854378834,
"tag": "0005_massive_spot",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1782856370142,
"tag": "0006_worthless_roxanne_simpson",
"breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1782859664569,
"tag": "0007_majestic_retro_girl",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1782883184215,
"tag": "0008_violet_centennial",
"breakpoints": true
}
]
}
+6
View File
@@ -0,0 +1,6 @@
export * from "./users";
export * from "./recipes";
export * from "./social";
export * from "./meal-planning";
export * from "./tiers";
export * from "./webhooks";
+83
View File
@@ -0,0 +1,83 @@
import {
pgTable,
text,
timestamp,
integer,
boolean,
date,
decimal,
pgEnum,
} from "drizzle-orm/pg-core";
import { relations } from "drizzle-orm";
import { users } from "./users";
import { recipes } from "./recipes";
import { ingredients } from "./recipes";
export const mealTypeEnum = pgEnum("meal_type", ["breakfast", "lunch", "dinner", "snack"]);
export const weekdayEnum = pgEnum("weekday", ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]);
export const mealPlans = pgTable("meal_plans", {
id: text("id").primaryKey(),
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
weekStart: date("week_start").notNull(),
createdAt: timestamp("created_at").notNull().defaultNow(),
});
export const mealPlanEntries = pgTable("meal_plan_entries", {
id: text("id").primaryKey(),
mealPlanId: text("meal_plan_id").notNull().references(() => mealPlans.id, { onDelete: "cascade" }),
day: weekdayEnum("day").notNull(),
mealType: mealTypeEnum("meal_type").notNull(),
recipeId: text("recipe_id").references(() => recipes.id, { onDelete: "set null" }),
servings: integer("servings").notNull().default(2),
note: text("note"),
});
export const pantryItems = pgTable("pantry_items", {
id: text("id").primaryKey(),
userId: text("user_id").notNull().references(() => users.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"),
expiresAt: timestamp("expires_at"),
createdAt: timestamp("created_at").notNull().defaultNow(),
});
export const shoppingLists = pgTable("shopping_lists", {
id: text("id").primaryKey(),
userId: text("user_id").references(() => users.id, { onDelete: "cascade" }),
name: text("name").notNull(),
generatedAt: timestamp("generated_at"),
createdAt: timestamp("created_at").notNull().defaultNow(),
});
export const shoppingListItems = pgTable("shopping_list_items", {
id: text("id").primaryKey(),
listId: text("list_id").notNull().references(() => shoppingLists.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"),
aisle: text("aisle"),
checked: boolean("checked").notNull().default(false),
});
export const mealPlansRelations = relations(mealPlans, ({ one, many }) => ({
user: one(users, { fields: [mealPlans.userId], references: [users.id] }),
entries: many(mealPlanEntries),
}));
export const mealPlanEntriesRelations = relations(mealPlanEntries, ({ one }) => ({
mealPlan: one(mealPlans, { fields: [mealPlanEntries.mealPlanId], references: [mealPlans.id] }),
recipe: one(recipes, { fields: [mealPlanEntries.recipeId], references: [recipes.id] }),
}));
export const shoppingListsRelations = relations(shoppingLists, ({ one, many }) => ({
user: one(users, { fields: [shoppingLists.userId], references: [users.id] }),
items: many(shoppingListItems),
}));
export const shoppingListItemsRelations = relations(shoppingListItems, ({ one }) => ({
list: one(shoppingLists, { fields: [shoppingListItems.listId], references: [shoppingLists.id] }),
}));
+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] }),
}));
+129
View File
@@ -0,0 +1,129 @@
import {
pgTable,
text,
timestamp,
integer,
boolean,
pgEnum,
index,
} from "drizzle-orm/pg-core";
import { relations } from "drizzle-orm";
import { users } from "./users";
import { recipes } from "./recipes";
export const feedItemTypeEnum = pgEnum("feed_item_type", [
"new_recipe",
"new_follow",
"recipe_rated",
]);
export const ratings = pgTable("ratings", {
id: text("id").primaryKey(),
recipeId: text("recipe_id").notNull().references(() => recipes.id, { onDelete: "cascade" }),
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
score: integer("score").notNull(),
reviewText: text("review_text"),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});
export const favorites = pgTable("favorites", {
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
recipeId: text("recipe_id").notNull().references(() => recipes.id, { onDelete: "cascade" }),
createdAt: timestamp("created_at").notNull().defaultNow(),
});
export const comments = pgTable("comments", {
id: text("id").primaryKey(),
recipeId: text("recipe_id").notNull().references(() => recipes.id, { onDelete: "cascade" }),
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
parentId: text("parent_id"),
content: text("content").notNull(),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
}, (t) => [
index("comments_recipe_idx").on(t.recipeId),
]);
export const collections = pgTable("collections", {
id: text("id").primaryKey(),
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
name: text("name").notNull(),
description: text("description"),
isPublic: boolean("is_public").notNull().default(false),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});
export const collectionRecipes = pgTable("collection_recipes", {
collectionId: text("collection_id").notNull().references(() => collections.id, { onDelete: "cascade" }),
recipeId: text("recipe_id").notNull().references(() => recipes.id, { onDelete: "cascade" }),
addedAt: timestamp("added_at").notNull().defaultNow(),
});
export const cookingHistory = pgTable("cooking_history", {
id: text("id").primaryKey(),
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
recipeId: text("recipe_id").notNull().references(() => recipes.id, { onDelete: "cascade" }),
cookedAt: timestamp("cooked_at").notNull().defaultNow(),
servings: integer("servings"),
notes: text("notes"),
});
export const feedItems = pgTable("feed_items", {
id: text("id").primaryKey(),
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
type: feedItemTypeEnum("type").notNull(),
actorId: text("actor_id").notNull().references(() => users.id, { onDelete: "cascade" }),
subjectId: text("subject_id").notNull(),
createdAt: timestamp("created_at").notNull().defaultNow(),
}, (t) => [
index("feed_items_user_idx").on(t.userId, t.createdAt),
]);
export const ratingsRelations = relations(ratings, ({ one }) => ({
recipe: one(recipes, { fields: [ratings.recipeId], references: [recipes.id] }),
user: one(users, { fields: [ratings.userId], references: [users.id] }),
}));
export const collectionsRelations = relations(collections, ({ one, many }) => ({
user: one(users, { fields: [collections.userId], references: [users.id] }),
recipes: many(collectionRecipes),
}));
export const collectionRecipesRelations = relations(collectionRecipes, ({ one }) => ({
collection: one(collections, { fields: [collectionRecipes.collectionId], references: [collections.id] }),
recipe: one(recipes, { fields: [collectionRecipes.recipeId], references: [recipes.id] }),
}));
export const collectionMemberRoleEnum = pgEnum("collection_member_role", ["viewer", "editor"]);
export const collectionMembers = pgTable("collection_members", {
id: text("id").primaryKey(),
collectionId: text("collection_id").notNull().references(() => collections.id, { onDelete: "cascade" }),
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
role: collectionMemberRoleEnum("role").notNull().default("viewer"),
createdAt: timestamp("created_at").notNull().defaultNow(),
});
export const commentReactionTypeEnum = pgEnum("comment_reaction_type", ["like", "love", "laugh", "wow", "sad", "fire"]);
export const commentReactions = pgTable("comment_reactions", {
id: text("id").primaryKey(),
commentId: text("comment_id").notNull().references(() => comments.id, { onDelete: "cascade" }),
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
type: commentReactionTypeEnum("type").notNull(),
createdAt: timestamp("created_at").notNull().defaultNow(),
}, (t) => [
index("comment_reactions_comment_idx").on(t.commentId),
]);
export const collectionMembersRelations = relations(collectionMembers, ({ one }) => ({
collection: one(collections, { fields: [collectionMembers.collectionId], references: [collections.id] }),
user: one(users, { fields: [collectionMembers.userId], references: [users.id] }),
}));
export const commentReactionsRelations = relations(commentReactions, ({ one }) => ({
comment: one(comments, { fields: [commentReactions.commentId], references: [comments.id] }),
user: one(users, { fields: [commentReactions.userId], references: [users.id] }),
}));
+55
View File
@@ -0,0 +1,55 @@
import {
pgTable,
text,
timestamp,
integer,
boolean,
index,
unique,
} from "drizzle-orm/pg-core";
import { relations } from "drizzle-orm";
import { users, tierEnum } from "./users";
export const tierDefinitions = pgTable("tier_definitions", {
tier: tierEnum("tier").primaryKey(),
maxRecipes: integer("max_recipes").notNull(),
aiCallsPerMonth: integer("ai_calls_per_month").notNull(),
storageMb: integer("storage_mb").notNull(),
maxPublicRecipes: integer("max_public_recipes").notNull(),
});
export const userUsage = pgTable("user_usage", {
id: text("id").primaryKey(),
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
month: text("month").notNull(),
aiCallsUsed: integer("ai_calls_used").notNull().default(0),
recipeCount: integer("recipe_count").notNull().default(0),
storageUsedMb: integer("storage_used_mb").notNull().default(0),
}, (t) => [
index("user_usage_user_month_idx").on(t.userId, t.month),
unique("user_usage_user_month_uniq").on(t.userId, t.month),
]);
export const auditLogs = pgTable("audit_logs", {
id: text("id").primaryKey(),
userId: text("user_id").references(() => users.id, { onDelete: "set null" }),
action: text("action").notNull(),
targetType: text("target_type"),
targetId: text("target_id"),
metadata: text("metadata"),
createdAt: timestamp("created_at").notNull().defaultNow(),
}, (t) => [
index("audit_logs_created_idx").on(t.createdAt),
]);
export const siteSettings = pgTable("site_settings", {
key: text("key").primaryKey(),
value: text("value"),
isSecret: boolean("is_secret").notNull().default(false),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
updatedById: text("updated_by_id").references(() => users.id, { onDelete: "set null" }),
});
export const userUsageRelations = relations(userUsage, ({ one }) => ({
user: one(users, { fields: [userUsage.userId], references: [users.id] }),
}));
+150
View File
@@ -0,0 +1,150 @@
import {
pgTable,
text,
timestamp,
boolean,
integer,
pgEnum,
} from "drizzle-orm/pg-core";
import { relations } from "drizzle-orm";
export const userRoleEnum = pgEnum("user_role", ["user", "moderator", "admin"]);
export const tierEnum = pgEnum("tier", ["free", "pro"]);
export const unitPrefEnum = pgEnum("unit_pref", ["metric", "imperial"]);
export const users = pgTable("users", {
id: text("id").primaryKey(),
email: text("email").notNull().unique(),
emailVerified: boolean("email_verified").notNull().default(false),
name: text("name").notNull(),
avatarUrl: text("avatar_url"),
bio: text("bio"),
username: text("username").unique(),
role: userRoleEnum("role").notNull().default("user"),
tier: tierEnum("tier").notNull().default("free"),
unitPref: unitPrefEnum("unit_pref").notNull().default("metric"),
locale: text("locale").notNull().default("en"),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});
// Better Auth requires these tables
export const sessions = pgTable("sessions", {
id: text("id").primaryKey(),
expiresAt: timestamp("expires_at").notNull(),
token: text("token").notNull().unique(),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
ipAddress: text("ip_address"),
userAgent: text("user_agent"),
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
});
export const accounts = pgTable("accounts", {
id: text("id").primaryKey(),
accountId: text("account_id").notNull(),
providerId: text("provider_id").notNull(),
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
accessToken: text("access_token"),
refreshToken: text("refresh_token"),
idToken: text("id_token"),
accessTokenExpiresAt: timestamp("access_token_expires_at"),
refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
scope: text("scope"),
password: text("password"),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});
export const verifications = pgTable("verifications", {
id: text("id").primaryKey(),
identifier: text("identifier").notNull(),
value: text("value").notNull(),
expiresAt: timestamp("expires_at").notNull(),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});
export const userFollows = pgTable("user_follows", {
followerId: text("follower_id").notNull().references(() => users.id, { onDelete: "cascade" }),
followingId: text("following_id").notNull().references(() => users.id, { onDelete: "cascade" }),
createdAt: timestamp("created_at").notNull().defaultNow(),
});
export const apiKeys = pgTable("api_keys", {
id: text("id").primaryKey(),
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
name: text("name").notNull(),
keyHash: text("key_hash").notNull().unique(),
lastUsedAt: timestamp("last_used_at"),
createdAt: timestamp("created_at").notNull().defaultNow(),
});
export const userAiKeys = pgTable("user_ai_keys", {
id: text("id").primaryKey(),
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
provider: text("provider").notNull(),
encryptedKey: text("encrypted_key").notNull(),
createdAt: timestamp("created_at").notNull().defaultNow(),
});
export const usersRelations = relations(users, ({ many }) => ({
sessions: many(sessions),
accounts: many(accounts),
followers: many(userFollows, { relationName: "following" }),
following: many(userFollows, { relationName: "follower" }),
apiKeys: many(apiKeys),
aiKeys: many(userAiKeys),
}));
export const userAiKeysRelations = relations(userAiKeys, ({ one }) => ({
user: one(users, { fields: [userAiKeys.userId], references: [users.id] }),
}));
export const userFollowsRelations = relations(userFollows, ({ one }) => ({
follower: one(users, { fields: [userFollows.followerId], references: [users.id], relationName: "follower" }),
following: one(users, { fields: [userFollows.followingId], references: [users.id], relationName: "following" }),
}));
export const pushSubscriptions = pgTable("push_subscriptions", {
id: text("id").primaryKey(),
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
endpoint: text("endpoint").notNull().unique(),
p256dh: text("p256dh").notNull(),
auth: text("auth").notNull(),
createdAt: timestamp("created_at").notNull().defaultNow(),
});
export const userNutritionGoals = pgTable("user_nutrition_goals", {
id: text("id").primaryKey(),
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }).unique(),
caloriesKcal: integer("calories_kcal"),
proteinG: integer("protein_g"),
carbsG: integer("carbs_g"),
fatG: integer("fat_g"),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});
export const pushSubscriptionsRelations = relations(pushSubscriptions, ({ one }) => ({
user: one(users, { fields: [pushSubscriptions.userId], references: [users.id] }),
}));
export const userNutritionGoalsRelations = relations(userNutritionGoals, ({ one }) => ({
user: one(users, { fields: [userNutritionGoals.userId], references: [users.id] }),
}));
export const userModelPrefs = pgTable("user_model_prefs", {
id: text("id").primaryKey(),
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }).unique(),
textProvider: text("text_provider"),
textModel: text("text_model"),
visionProvider: text("vision_provider"),
visionModel: text("vision_model"),
mealPlanProvider: text("meal_plan_provider"),
mealPlanModel: text("meal_plan_model"),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});
export const userModelPrefsRelations = relations(userModelPrefs, ({ one }) => ({
user: one(users, { fields: [userModelPrefs.userId], references: [users.id] }),
}));
+33
View File
@@ -0,0 +1,33 @@
import { pgTable, text, boolean, timestamp, integer, jsonb } from "drizzle-orm/pg-core";
import { relations } from "drizzle-orm";
import { users } from "./users";
export const webhooks = pgTable("webhooks", {
id: text("id").primaryKey(),
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
url: text("url").notNull(),
events: text("events").array().notNull().default([]),
secret: text("secret").notNull(),
active: boolean("active").notNull().default(true),
createdAt: timestamp("created_at").notNull().defaultNow(),
});
export const webhookDeliveries = pgTable("webhook_deliveries", {
id: text("id").primaryKey(),
webhookId: text("webhook_id").notNull().references(() => webhooks.id, { onDelete: "cascade" }),
event: text("event").notNull(),
payload: jsonb("payload"),
statusCode: integer("status_code"),
success: boolean("success").notNull().default(false),
attempts: integer("attempts").notNull().default(0),
createdAt: timestamp("created_at").notNull().defaultNow(),
});
export const webhooksRelations = relations(webhooks, ({ one, many }) => ({
user: one(users, { fields: [webhooks.userId], references: [users.id] }),
deliveries: many(webhookDeliveries),
}));
export const webhookDeliveriesRelations = relations(webhookDeliveries, ({ one }) => ({
webhook: one(webhooks, { fields: [webhookDeliveries.webhookId], references: [webhooks.id] }),
}));
+33
View File
@@ -0,0 +1,33 @@
import { db } from "./client";
import { tierDefinitions } from "./schema";
async function seed() {
console.log("Seeding tier definitions...");
await db
.insert(tierDefinitions)
.values([
{
tier: "free",
maxRecipes: 50,
aiCallsPerMonth: 10,
storageMb: 500,
maxPublicRecipes: 5,
},
{
tier: "pro",
maxRecipes: 99999,
aiCallsPerMonth: 500,
storageMb: 10000,
maxPublicRecipes: 99999,
},
])
.onConflictDoNothing();
console.log("Seed complete.");
process.exit(0);
}
seed().catch((err) => {
console.error(err);
process.exit(1);
});
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src"]
}