51e6722f4c
Two changes to the no-photo cover placeholder shipped last version: 1. Muted the gradient palette (100/40-opacity tints instead of solid -200/ -950 stops) — the original was too saturated next to real cover photos in the same grid, per feedback. 2. New coverIcon/coverColor columns on recipes (nullable text, migration 0048, additive-only) let the author pin a specific color+icon from the recipe editor instead of the automatic per-id pick. getRecipePlaceholder now checks these first, falling back to the deterministic hash pick when unset — existing recipes are unaffected until edited. Wired coverIcon/coverColor through every explicit-column recipe select that feeds a grid card (explore trending/recent, search, feed, for-you) — the relational-query call sites (recipes page, collections, profile pages) already return all columns and needed no changes. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
211 lines
8.9 KiB
TypeScript
211 lines
8.9 KiB
TypeScript
import {
|
|
pgTable,
|
|
text,
|
|
timestamp,
|
|
boolean,
|
|
integer,
|
|
decimal,
|
|
jsonb,
|
|
pgEnum,
|
|
index,
|
|
uniqueIndex,
|
|
} from "drizzle-orm/pg-core";
|
|
import { relations } from "drizzle-orm";
|
|
import { users } from "./users";
|
|
|
|
export const visibilityEnum = pgEnum("visibility", ["private", "unlisted", "public", "followers"]);
|
|
export const difficultyEnum = pgEnum("difficulty", ["easy", "medium", "hard"]);
|
|
export const recipeTypeEnum = pgEnum("recipe_type", ["dish", "drink"]);
|
|
|
|
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),
|
|
recipeType: recipeTypeEnum("recipe_type").notNull().default("dish"),
|
|
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"),
|
|
language: text("language"),
|
|
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 } }>(),
|
|
nutritionManual: boolean("nutrition_manual").notNull().default(false),
|
|
difficulty: difficultyEnum("difficulty"),
|
|
prepMins: integer("prep_mins"),
|
|
cookMins: integer("cook_mins"),
|
|
tags: text("tags").array().notNull().default([]),
|
|
isBatchCook: boolean("is_batch_cook").notNull().default(false),
|
|
// User-chosen cover for when there's no photo — null falls back to the
|
|
// deterministic hash-based placeholder (see lib/recipe-placeholder.ts).
|
|
coverIcon: text("cover_icon"),
|
|
coverColor: text("cover_color"),
|
|
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),
|
|
index("recipes_dietary_tags_gin").using("gin", t.dietaryTags),
|
|
index("recipes_batch_cook_idx").on(t.isBatchCook),
|
|
index("recipes_type_idx").on(t.recipeType),
|
|
]);
|
|
|
|
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),
|
|
}, (t) => [
|
|
index("recipe_ingredients_recipe_idx").on(t.recipeId),
|
|
]);
|
|
|
|
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"),
|
|
appliesTo: text("applies_to").array().notNull().default([]),
|
|
}, (t) => [
|
|
index("recipe_steps_recipe_idx").on(t.recipeId),
|
|
]);
|
|
|
|
export const recipeBatchDishes = pgTable("recipe_batch_dishes", {
|
|
id: text("id").primaryKey(),
|
|
recipeId: text("recipe_id").notNull().references(() => recipes.id, { onDelete: "cascade" }),
|
|
name: text("name").notNull(),
|
|
description: text("description"),
|
|
order: integer("order").notNull().default(0),
|
|
fridgeDays: integer("fridge_days").notNull(),
|
|
freezerFriendly: boolean("freezer_friendly").notNull().default(false),
|
|
freezerNote: text("freezer_note"),
|
|
dayOfInstructions: text("day_of_instructions").notNull(),
|
|
}, (t) => [
|
|
index("recipe_batch_dishes_recipe_idx").on(t.recipeId),
|
|
]);
|
|
|
|
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(),
|
|
}, (t) => [
|
|
uniqueIndex("recipe_notes_recipe_user_idx").on(t.recipeId, t.userId),
|
|
]);
|
|
|
|
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" }),
|
|
batchDishes: many(recipeBatchDishes),
|
|
}));
|
|
|
|
export const recipeBatchDishesRelations = relations(recipeBatchDishes, ({ one }) => ({
|
|
recipe: one(recipes, { fields: [recipeBatchDishes.recipeId], references: [recipes.id] }),
|
|
}));
|
|
|
|
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] }),
|
|
}));
|