# Full-plate — what the app is

A catalog of every entity (with its fields) and every action (one per row) the app exposes. No UI, no UX, no layout — just what the thing is.

Sourced from: `backend/prisma/schema.prisma`, `backend/src/routes/*`, `types/src/index.ts`, `docs/specs/2026-05-17-recipe-domain.md`.

---

## Part 1 — Entities and their fields

### User
The account record. Auth identity lives in Supabase; this row is the app-side mirror.

- `id` — UUID, primary key.
- `email` — string, unique, nullable (nulled on deletion).
- `supabase_auth_id` — string, unique. Link to Supabase auth identity.
- `display_name` — string, nullable. 1–80 chars when set.
- `avatar_asset_id` — UUID, nullable. FK to the Asset used as the user's avatar.
- `avatar_url` — string, derived (resolved public URL of avatar asset).
- `subscription_tier_id` — string. FK to SubscriptionTier. Defaults to `"free"`.
- `subscription_expires_at` — timestamp, nullable.
- `storage_bytes` — bigint. Running total of asset bytes the user has uploaded.
- `created_at` — timestamp.

### SubscriptionTier
Reference table of paid plans.

- `id` — string, primary key (e.g. `"free"`, `"pro"`).
- `display_name` — string.
- `sort_order` — int.
- `price_cents` — int.
- `stripe_price_id` — string, nullable.
- `created_at` — timestamp.

### TierCapability
A capability granted by a tier (e.g. URL import, OCR, storage cap). Composite key `(tier_id, capability)`.

- `tier_id` — string, FK to SubscriptionTier.
- `capability` — string (e.g. `"url_import"`, `"unlimited_ai"`, `"ocr_pdf_import"`, `"advanced_print"`).
- `limit_int` — bigint, nullable. `null` = unlimited.

### Asset
A file uploaded to storage. v1 is image-only.

- `id` — UUID, primary key.
- `kind` — enum: `image`. (Future: `embed`, `video`.)
- `owner_user_id` — UUID, nullable. Nulled when the owner deletes their account.
- `storage_path` — string. Path inside the `recipe-assets` Supabase bucket.
- `url` — string, derived (public URL).
- `mime` — string. One of `image/jpeg`, `image/png`, `image/webp`, `image/gif`.
- `width` — int, nullable.
- `height` — int, nullable.
- `bytes` — int.
- `thumbnail_path` — string, nullable.
- `alt` — string, nullable.
- `caption` — string, nullable.
- `duration_ms` — int, nullable. Reserved for future video.
- `created_at` — timestamp.

### MasterIngredient
Reference list of canonical ingredients (USDA FoodData Central). Used to make ingredients searchable across recipes.

- `id` — UUID, primary key.
- `fdc_id` — int, unique. USDA FoodData Central ID.
- `canonical_name` — string. Full USDA name.
- `display_name` — string. Short human-readable form.
- `category` — string. Short taxonomy (`"Meat"`, `"Produce"`, …).
- `aliases` — string array.
- `data_json` — JSON. Full USDA record (reserved for nutrition + unit conversion).
- `created_at` — timestamp.

### Unit
Reference list of measurement units used in ingredient rows.

- `id` — string, primary key (e.g. `"tsp"`, `"cup"`, `"g"`, `"to-taste"`).
- `name` — string.
- `name_plural` — string.
- `abbreviation` — string.
- `kind` — enum: `volume`, `weight`, `count`, `descriptive`.
- `system` — enum: `us`, `metric`, `universal`.
- `allow_fractions` — boolean.
- `sort_order` — int.

### RecipeCategory
Admin-curated taxonomy assigned to recipes.

- `id` — string, primary key (e.g. `"cuisine-italian"`, `"dietary-vegan"`).
- `kind` — enum: `cuisine`, `dietary`, `meal_type`, `technique`, `occasion`.
- `name` — string.
- `sort_order` — int.

### Recipe
The core editable object. Mutable current state — history lives in RecipeActivity.

- `id` — UUID, primary key.
- `owner_user_id` — UUID, nullable. Nulled if the owner deletes their account.
- `title` — string, nullable. (Nullable to support hard-delete tombstone.)
- `description` — text, nullable. Markdown.
- `hero_asset_id` — UUID, nullable. FK to Asset.
- `base_servings` — int. Defaults to 4. Positive, ≤1000.
- `prep_time_minutes` — int, nullable. Non-negative.
- `cook_time_minutes` — int, nullable. Non-negative.
- `equipment` — string array. Free-text equipment names (e.g. `"stand mixer"`).
- `ingredients` — JSON, nullable. Polymorphic array — see Ingredient Item below.
- `steps` — JSON, nullable. Polymorphic array — see Step Item below.
- `visibility` — enum: `private`, `public_link`. (Future: `kitchen`, `public`.)
- `share_slug` — string, unique, nullable. Unguessable slug; set iff visibility is `public_link`.
- `forked_from_recipe_id` — UUID, nullable. Parent recipe in a fork lineage.
- `source` — JSON, nullable. Attribution payload (see Source variants below).
- `category_ids` — string array, derived (from RecipeCategoryAssignment).
- `deleted_at` — timestamp, nullable. Soft delete; recoverable.
- `hard_deleted_at` — timestamp, nullable. Tombstone — content erased, row preserved for lineage children.
- `created_at` — timestamp.
- `updated_at` — timestamp.

#### Ingredient Item (one row inside Recipe.ingredients)
Polymorphic — either a section header or an ingredient row.

Section header variant:
- `type` — literal `"section"`.
- `label` — string. Group label (e.g. `"For the dough"`).

Ingredient row variant:
- `type` — literal `"ingredient"`.
- `quantity` — number, nullable. Decimal. Null for "to taste" / "as needed".
- `unit_id` — string. FK to Unit.
- `name` — string. Free-text display value (e.g. `"all-purpose flour"`).
- `master_ingredient_id` — UUID, nullable. Optional link to MasterIngredient.
- `prep_note` — string, nullable. Markdown (e.g. `"finely chopped"`).
- `is_optional` — boolean.

#### Step Item (one row inside Recipe.steps)
Polymorphic — either a section header or a step row.

Section header variant:
- `type` — literal `"section"`.
- `label` — string.

Step row variant:
- `type` — literal `"step"`.
- `text` — string. Markdown.
- `asset_ids` — UUID array. Inline images attached to the step.

#### Source variants (Recipe.source)
Discriminated by `type`.

- `manual` — `{ type: "manual" }`.
- `url` — `{ type: "url", url, site_name? }`.
- `person` — `{ type: "person", attribution_text }`.
- `book` — `{ type: "book", book_title, author?, page? }`.
- `fork` — `{ type: "fork", original_recipe_id, original_user_id, snapshot: { title, hero_asset_id, owner_display_name } }`.

### RecipeCategoryAssignment
Join row between Recipe and RecipeCategory. Composite key `(recipe_id, category_id)`.

- `recipe_id` — UUID, FK to Recipe.
- `category_id` — string, FK to RecipeCategory.

### RecipeIngredientUse
Denormalized index of "this recipe uses this master ingredient". Composite key `(recipe_id, master_ingredient_id)`. Rebuilt by app code on every recipe save.

- `recipe_id` — UUID, FK to Recipe.
- `master_ingredient_id` — UUID, FK to MasterIngredient.

### RecipeActivity
Append-only audit log of everything that happens to a Recipe.

- `id` — UUID, primary key.
- `recipe_id` — UUID, FK to Recipe.
- `actor_user_id` — UUID, nullable. FK to User. Nulled on account deletion.
- `actor_display_name` — string, derived from User. Falls back to email.
- `kind` — enum: `imported`, `edited`, `cooked`, `forked`, `shared`, `reverted`, `deleted`, `hard_deleted`.
- `message` — string, nullable. Optional commit message.
- `delta` — JSON, nullable. Present only on `edited` (and `reverted`) events: `{ <field>: { old, new }, ... }`.
- `child_recipe_id` — UUID, nullable. Set on `forked` events — points to the new fork.
- `cook_log_id` — UUID, nullable. Set on `cooked` events.
- `created_at` — timestamp.

### RecipeLibrary
A user's library entry for a recipe (their own or someone else's). Composite key `(user_id, recipe_id)`.

- `user_id` — UUID, FK to User.
- `recipe_id` — UUID, FK to Recipe.
- `added_at` — timestamp.
- `last_viewed_at` — timestamp, nullable.

### RecipeUserTag
A per-user free-text tag on a recipe. Composite key `(user_id, recipe_id, tag)`.

- `user_id` — UUID, FK to User.
- `recipe_id` — UUID, FK to Recipe.
- `tag` — string. Case-folded to lowercase on write. 1–64 chars.

### RecipeUserFavorite
A per-user favorite mark on a recipe. Composite key `(user_id, recipe_id)`.

- `user_id` — UUID, FK to User.
- `recipe_id` — UUID, FK to Recipe.
- `created_at` — timestamp.

### CookLog
A record of one cook of one recipe by one user. Always private to the user.

- `id` — UUID, primary key.
- `user_id` — UUID, FK to User.
- `recipe_id` — UUID, FK to Recipe.
- `recipe_snapshot` — JSON. Frozen at cook time. Sub-fields: `title`, `base_servings`, `ingredients`, `steps`.
- `rating` — int, nullable. 1–5.
- `notes` — text, nullable. Free-text, ≤5000 chars. Markdown.
- `cooked_at` — timestamp. Defaults to "now" if not provided on create.
- `created_at` — timestamp.
- `updated_at` — timestamp.
- `deleted_at` — timestamp, nullable. Soft delete.

### UsageAiCall
Metering record for AI calls. Designed in v1, not enforced.

- `id` — UUID, primary key.
- `user_id` — UUID, FK to User.
- `kind` — enum: `extraction`, `autocategorize`. (Future: `variation`, `substitution`, `improvement`, `scaling`, `nutrition`.)
- `called_at` — timestamp.
- `tokens_in` — int, nullable.
- `tokens_out` — int, nullable.
- `cost_cents` — int, nullable.

### UsageImport
Metering record for recipe imports. Designed in v1, not enforced.

- `id` — UUID, primary key.
- `user_id` — UUID, FK to User.
- `kind` — enum: `manual`, `paste`, `url`. (Future: `ocr`, `pdf`.)
- `imported_at` — timestamp.

### LineageParent (response shape on GET /recipes/:id)
Discriminated by `kind`, describes a fork's parent for display.

- `live` variant: `{ kind: "live", recipe_id, title, owner_display_name }`.
- `private` variant: `{ kind: "private", recipe_id }`. Caller can't access it.
- `snapshot` variant: `{ kind: "snapshot", title, owner_display_name }`. Parent is hard-deleted; frozen at fork time.

---

## Part 2 — Every action

Each action below is one operation a user (or anonymous visitor) can trigger. Auth required unless noted.

### Authentication

- **Request a magic-link sign-in email.** Body: `email`. Sends a Supabase OTP email; link lands on backend callback. No auth required.
- **Complete magic-link sign-in (callback).** Query: `token_hash`, `type` (`magiclink` | `email` | `signup` | `recovery` | `invite`), `next?`. Verifies the OTP, sets `sb-access` and `sb-refresh` httpOnly cookies, redirects to `next` (defaults to `/app`). No auth required.
- **Log out.** Clears auth cookies and revokes the Supabase session.

### Account / profile

- **Get my profile.**
- **Update my display name.** Field: `display_name` (1–80 chars, or null to clear).
- **Update my avatar.** Field: `avatar_asset_id` (must be an Asset I own, or null to clear).
- **Delete my account.** Tombstones every Recipe I own (preserves rows for lineage children), cascades-deletes my library / favorites / tags / cook-logs, removes my Supabase auth identity, clears auth cookies.

### Assets

- **Upload an asset (image).** Form-data: `file` (≤10MB; jpeg/png/webp/gif), `alt?`. Stores in `recipe-assets` bucket, creates Asset row, increments my `storage_bytes`.
- **Get an asset's public URL by ID.**

### Recipes — creation & import

- **Create a recipe.** Fields: `title` (1–300), `description?`, `hero_asset_id?`, `base_servings` (default 4), `prep_time_minutes?`, `cook_time_minutes?`, `equipment[]`, `ingredients[]` (polymorphic), `steps[]` (polymorphic), `visibility` (default `private`), `source?`, `category_ids[]`, `initial_commit_message?`. Auto-adds the recipe to my library; logs an `imported` activity event.
- **Import a recipe from a URL (returns a draft).** Field: `url`. Backend fetches the page, extracts a structured recipe via Claude (`extraction` AI call), downloads the OG image as a hero asset, returns a draft `RecipeCreateRequest` for the client to POST to "create a recipe."
- **Import a recipe from pasted text (returns a draft).** Field: `text` (20–50,000 chars). Same as URL import but no fetch and no hero image.

### Recipes — read

- **List my library.** Query: `q?`, `cursor?`, `limit` (default 50, max 100), `favorites_only?` (boolean), `category_ids?` (CSV), `tag?`, `sort` (`added` (default) | `viewed` | `alpha` | `cooked`). Returns RecipeListItem rows with per-recipe `is_owner`, `is_favorite`, `added_at`, `last_viewed_at`, `last_cooked_at`, `category_ids`, `tags`. Only `added` is cursor-paginated.
- **Get a single recipe (auth'd).** Allowed if I'm the owner or have a library row. Returns the full Recipe, my `is_favorite`, my `tags`, and `lineage_parent`. Side-effect: bumps my `last_viewed_at` for this recipe.
- **Get a public recipe by share slug.** Param: `slug`. Works anonymously (returns metadata only) or authed (returns full Recipe, `is_in_library`, `is_owner`). Returns 404 if the recipe is not `public_link` or is deleted.

### Recipes — edit

- **Update a recipe.** Any subset of: `title`, `description`, `hero_asset_id`, `base_servings`, `prep_time_minutes`, `cook_time_minutes`, `equipment`, `ingredients`, `steps`, `visibility`, `category_ids`, `commit_message?`. Owner-only. Generates an `edited` activity event with a field-level delta for the edit-tracked fields (everything except `visibility` and `category_ids`); manages `share_slug` automatically when visibility flips; rebuilds RecipeIngredientUse when ingredients change.
- **Revert one or more fields on a past `edited` event.** Param: recipe id + event id. Body: `fields?` (array of API field names; omit to revert all). Applies the `old` values from that event's delta and logs a `reverted` activity event with the inverse delta.

### Recipes — fork & save

- **Fork a recipe.** Creates a new private Recipe owned by me, copying title/description/hero/timings/equipment/ingredients/steps/categories, with `forked_from_recipe_id` set and a `source: { type: "fork", … snapshot }`. Auto-adds the new recipe to my library. Logs a `forked` event on both the source recipe (with `child_recipe_id`) and the new fork.
- **Save someone else's public recipe to my library.** Creates a RecipeLibrary row. Only meaningful for non-owners on `public_link` recipes. Idempotent.

### Recipes — annotate (per-user, private to me)

- **Favorite a recipe.** Requires library access. Idempotent.
- **Unfavorite a recipe.** Idempotent.
- **Add a tag to a recipe.** Body: `tag` (1–64 chars; trimmed and lowercased). Requires library access. Idempotent.
- **Remove a tag from a recipe.** Param: recipe id + tag.
- **List all my tags.** Returns my full distinct tag vocabulary, sorted.

### Recipes — delete

- **Soft-delete a recipe.** Owner-only. Sets `deleted_at`; recipe hides from library and reads. Idempotent. Logs a `deleted` activity event.
- **Hard-delete (tombstone) a recipe.** Owner-only. Query: `hard=true`. Sets `hard_deleted_at`, nulls `title`/`description`/`ingredients`/`steps`/`hero_asset_id`/`share_slug`, clears RecipeIngredientUse. Row remains for lineage children. Logs a `hard_deleted` activity event.

### Recipes — activity history

- **List activity events for a recipe.** Param: recipe id. Query: `cursor?`, `limit` (default 30, max 100). Owner-only. Returns RecipeActivity entries in reverse chronological order.

### Cook logs

- **Log a cook for a recipe.** Param: recipe id. Body: `rating?` (1–5), `notes?` (≤5000 chars), `cooked_at?` (defaults to now). Requires library access. Stores a snapshot of title/base_servings/ingredients/steps at cook time. Logs a `cooked` activity event with `cook_log_id`.
- **List my cook logs for a recipe.** Param: recipe id. Returns up to 50 of my own logs for that recipe, newest first.
- **Update a cook log.** Param: cook log id. Body: any of `rating`, `notes`, `cooked_at`. Owner-only.
- **Delete a cook log.** Param: cook log id. Owner-only. Soft delete (idempotent).

### Reference data (read-only lookups)

- **List recipe categories.** Returns the full admin-curated category taxonomy, ordered by `kind` then `sort_order`.
- **List units.** Returns the full controlled unit vocabulary, ordered by `sort_order`.
- **Search master ingredients.** Query: `q`, `limit`. Case-insensitive substring match on `display_name`.

### Health

- **Health check.** No auth.
