f6975e98a9
Recipe count and storage usage shared the monthly user_usage bucket with AI calls, so both incorrectly reset every month even though nothing was deleted. Only AI calls should be monthly. Recipe count and storage are now derived live from real data (recipes, recipe/review photos, avatar) instead of a counter — deleting a photo or recipe is itself the "decrement", no extra wiring needed. Storage size is tracked per-row (recipePhotos.sizeMb, ratings.photoSizeMb, users.avatarSizeMb) and threaded through presign -> upload -> save. Also fixes avatar removal silently no-oping (client sent a field the PATCH schema didn't recognize). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
212 lines
9.0 KiB
TypeScript
212 lines
9.0 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),
|
|
sizeMb: integer("size_mb").notNull().default(0),
|
|
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] }),
|
|
}));
|