Files
mealprep/specs/backend/data-model.html
Marcel Raddatz 520dae5adf feat(recipes): add image upload, fix save 500, seed HelloFresh data
- Store hero image as base64 data URI in text column (V023 migration)
- Add file upload UI to RecipeForm with FileReader preview
- Remove isChildFriendly from RecipeCreateRequest (no form field)
- Fix 500 on save: effort values now lowercase, serves/cookTimeMin changed
  from primitive short to nullable Integer to survive omitted fields
- Fix empty categories panel: removed stale tagType=category filter
- Group category tags by type with German headings in recipe form
- Split SuggestionResponse.SuggestionRecipe (no image) from SlotRecipe
- Seed 11 HelloFresh recipes with ingredients, steps and tags (V101)
- Add frontend e2e scaffold, specs and dev yml

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 20:23:28 +02:00

897 lines
60 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Recipe App — Data Model v1.1</title>
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,300;9..144,400;9..144,500&family=DM+Sans:wght@300;400;500;600&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet"/>
<style>
:root {
--color-page:#FAFAF7;--color-surface:#F5F4EE;--color-subtle:#EDECEA;
--color-border:#D8D7D0;--color-text-muted:#6B6A63;--color-text:#1C1C18;
--green-tint:#E8F5EA;--green-light:#AEDCB0;--green:#3D8C4A;
--green-dark:#2E6E39;--green-deeper:#1E4A26;
--yellow-tint:#FDF6D8;--yellow-light:#F9E08A;--yellow:#F2C12E;
--yellow-dark:#C49610;--yellow-text:#8A6800;
--blue-tint:#E6F1FB;--blue-light:#A4CFF4;--blue:#2D7DD2;--blue-dark:#185FA5;
--purple-tint:#EEEDFE;--purple:#534AB7;--purple-dark:#3C3489;
--orange-tint:#FEF0E6;--orange:#E8862A;--orange-dark:#B46820;
--red-tint:#FDEAEA;--red:#DC4C3E;--red-dark:#B03020;
--font-display:'Fraunces',Georgia,serif;
--font-sans:'DM Sans',system-ui,sans-serif;
--font-mono:'DM Mono',monospace;
--radius-sm:4px;--radius-md:6px;--radius-lg:10px;--radius-xl:16px;
--shadow-card:0 1px 3px rgba(28,28,24,.06),0 1px 2px rgba(28,28,24,.04);
}
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0;}
body{font-family:var(--font-sans);background:var(--color-page);color:var(--color-text);font-size:14px;line-height:1.6;}
.doc{max-width:1100px;margin:0 auto;padding:48px 40px 120px;}
.doc-header{display:flex;justify-content:space-between;align-items:flex-end;padding-bottom:28px;border-bottom:1px solid var(--color-border);margin-bottom:48px;}
.doc-header h1{font-family:var(--font-display);font-size:28px;font-weight:500;letter-spacing:-.02em;margin-bottom:4px;}
.doc-header p{font-size:13px;color:var(--color-text-muted);}
.doc-meta{font-family:var(--font-mono);font-size:11px;color:var(--color-text-muted);text-align:right;line-height:1.9;}
.pill{display:inline-block;padding:2px 8px;border-radius:var(--radius-sm);font-size:10px;font-weight:500;letter-spacing:.05em;}
.pill-v{background:var(--green-tint);color:var(--green-dark);}
.section{margin-bottom:56px;}
.section-title{font-size:10px;font-weight:500;letter-spacing:.12em;text-transform:uppercase;color:var(--color-text-muted);padding-bottom:10px;border-bottom:1px solid var(--color-border);margin-bottom:24px;}
.prose{font-size:13px;color:var(--color-text-muted);line-height:1.65;max-width:720px;margin-bottom:20px;}
/* Changelog */
.changelog{background:var(--red-tint);border:1px solid #F4ACA4;border-radius:var(--radius-xl);padding:20px 24px;margin-bottom:32px;}
.changelog h3{font-size:12px;font-weight:500;color:var(--red-dark);margin-bottom:8px;}
.changelog ul{list-style:none;padding:0;}
.changelog li{font-size:12px;color:var(--red-dark);padding:3px 0;line-height:1.5;}
.changelog li::before{content:'△ ';font-weight:700;}
/* ERD */
.erd-canvas{background:var(--color-surface);border:1px solid var(--color-border);border-radius:var(--radius-xl);padding:32px;overflow-x:auto;margin-bottom:24px;}
.erd-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:16px;min-width:880px;}
.erd-entity{background:var(--color-page);border:1px solid var(--color-border);border-radius:var(--radius-lg);overflow:hidden;font-size:11px;margin-bottom:12px;}
.erd-entity:last-child{margin-bottom:0;}
.erd-entity-head{padding:8px 12px;font-weight:500;font-size:11px;letter-spacing:.04em;border-bottom:1px solid var(--color-border);}
.erd-entity-head.domain-auth{background:var(--purple-tint);color:var(--purple-dark);}
.erd-entity-head.domain-recipe{background:var(--green-tint);color:var(--green-dark);}
.erd-entity-head.domain-plan{background:var(--yellow-tint);color:var(--yellow-text);}
.erd-entity-head.domain-shop{background:var(--blue-tint);color:var(--blue-dark);}
.erd-entity-head.domain-pantry{background:var(--orange-tint);color:var(--orange-dark);}
.erd-entity-head.domain-admin{background:var(--red-tint);color:var(--red-dark);}
.erd-cols{padding:6px 12px;}
.erd-col{display:flex;justify-content:space-between;align-items:center;padding:2px 0;font-family:var(--font-mono);font-size:10px;color:var(--color-text-muted);}
.erd-col .n{color:var(--color-text);}
.erd-col .pk{color:var(--purple);font-weight:500;}
.erd-col .fk{color:var(--blue);font-weight:500;}
.erd-col .t{color:var(--color-text-muted);font-size:9px;}
.erd-col .new{background:var(--red-tint);color:var(--red-dark);font-size:8px;padding:0 4px;border-radius:2px;margin-left:4px;}
/* Table spec */
.table-spec{background:var(--color-surface);border:1px solid var(--color-border);border-radius:var(--radius-xl);overflow:hidden;margin-bottom:24px;}
.table-spec-head{padding:14px 20px;border-bottom:1px solid var(--color-border);display:flex;justify-content:space-between;align-items:center;}
.table-spec-head h3{font-family:var(--font-mono);font-size:14px;font-weight:500;color:var(--color-text);}
.table-spec-head .domain{font-size:10px;font-weight:500;padding:2px 8px;border-radius:var(--radius-sm);}
.table-spec-purpose{padding:12px 20px;font-size:12px;color:var(--color-text-muted);border-bottom:1px solid var(--color-border);background:var(--color-page);}
.col-table{width:100%;border-collapse:collapse;font-size:11px;}
.col-table thead{background:var(--color-subtle);}
.col-table th{text-align:left;padding:8px 12px;font-size:9px;font-weight:500;letter-spacing:.08em;text-transform:uppercase;color:var(--color-text-muted);border-bottom:1px solid var(--color-border);}
.col-table td{padding:7px 12px;border-bottom:1px solid var(--color-subtle);vertical-align:top;font-family:var(--font-mono);font-size:10px;}
.col-table td:first-child{color:var(--color-text);font-weight:500;}
.col-table td:nth-child(2){color:var(--purple);}
.col-table td:nth-child(3){color:var(--color-text-muted);font-family:var(--font-sans);}
.col-table td:nth-child(4){color:var(--color-text-muted);font-family:var(--font-sans);font-size:10px;}
.col-table tr:last-child td{border-bottom:none;}
.col-table .pk-row td{background:var(--purple-tint);}
.col-table .fk-row td:first-child{color:var(--blue);}
.spec-footer{padding:12px 20px;border-top:1px solid var(--color-border);background:var(--color-page);}
.spec-footer h4{font-size:9px;font-weight:500;letter-spacing:.1em;text-transform:uppercase;color:var(--color-text-muted);margin-bottom:6px;}
.spec-footer p,.spec-footer li{font-size:11px;color:var(--color-text-muted);line-height:1.55;}
.spec-footer ul{list-style:none;padding:0;}
.spec-footer li{padding:2px 0;}
.spec-footer li::before{content:'→ ';color:var(--color-border);}
.spec-footer + .spec-footer{border-top:1px dashed var(--color-border);}
.example-table{width:100%;border-collapse:collapse;font-size:10px;font-family:var(--font-mono);margin-top:8px;}
.example-table th{text-align:left;padding:5px 8px;font-size:9px;font-weight:500;letter-spacing:.06em;text-transform:uppercase;color:var(--color-text-muted);background:var(--color-subtle);border-bottom:1px solid var(--color-border);}
.example-table td{padding:4px 8px;border-bottom:1px solid var(--color-subtle);color:var(--color-text-muted);}
.callout{background:var(--yellow-tint);border:1px solid var(--yellow-light);border-radius:var(--radius-lg);padding:14px 18px;margin-bottom:20px;}
.callout h4{font-size:11px;font-weight:500;color:var(--yellow-text);margin-bottom:4px;}
.callout p{font-size:12px;color:var(--yellow-text);line-height:1.5;}
.callout.green{background:var(--green-tint);border-color:var(--green-light);}
.callout.green h4{color:var(--green-dark);}
.callout.green p{color:var(--green-deeper);}
.callout.blue{background:var(--blue-tint);border-color:var(--blue-light);}
.callout.blue h4{color:var(--blue-dark);}
.callout.blue p{color:var(--blue-dark);}
.callout.red{background:var(--red-tint);border-color:#F4ACA4;}
.callout.red h4{color:var(--red-dark);}
.callout.red p{color:var(--red-dark);}
.query-card{background:var(--color-surface);border:1px solid var(--color-border);border-radius:var(--radius-lg);padding:16px 20px;margin-bottom:10px;}
.query-card h4{font-size:12px;font-weight:500;color:var(--color-text);margin-bottom:4px;}
.query-card .meta{font-size:10px;color:var(--color-text-muted);margin-bottom:8px;}
.query-card pre{font-family:var(--font-mono);font-size:10px;background:var(--color-text);color:#AEDCB0;padding:12px 16px;border-radius:var(--radius-md);overflow-x:auto;line-height:1.6;white-space:pre;}
.d-auth{background:var(--purple-tint);color:var(--purple-dark);}
.d-recipe{background:var(--green-tint);color:var(--green-dark);}
.d-plan{background:var(--yellow-tint);color:var(--yellow-text);}
.d-shop{background:var(--blue-tint);color:var(--blue-dark);}
.d-pantry{background:var(--orange-tint);color:var(--orange-dark);}
.d-admin{background:var(--red-tint);color:var(--red-dark);}
.summary-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(170px,1fr));gap:10px;margin-bottom:24px;}
.summary-card{background:var(--color-surface);border:1px solid var(--color-border);border-radius:var(--radius-lg);padding:14px 16px;}
.summary-card .num{font-family:var(--font-display);font-size:28px;font-weight:300;line-height:1;}
.summary-card .label{font-size:10px;color:var(--color-text-muted);margin-top:4px;}
@media(max-width:900px){
.erd-grid{grid-template-columns:1fr 1fr;}
.doc{padding:24px 16px 80px;}
}
</style>
</head>
<body>
<div class="doc">
<!-- ═══ HEADER ═══ -->
<div class="doc-header">
<div>
<h1>Data model</h1>
<p>Recipe app · PostgreSQL 16 · Normalized schema with audit trails</p>
</div>
<div class="doc-meta">
<span class="pill pill-v">v1.1</span><br>
Engine: PostgreSQL 16<br>
Tables: 18<br>
Domains: 6<br>
Designed by: Atlas
</div>
</div>
<!-- ═══ CHANGELOG ═══ -->
<div class="changelog">
<h3>v1.1 changes from v1.0</h3>
<ul>
<li><strong>Tag model fixed → proper M:N.</strong> Added <code>tag</code> reference table. <code>recipe_tag</code> is now a pure junction table with recipe_id FK + tag_id FK. Tags are reusable, renameable, and queryable from both directions.</li>
<li><strong>Ingredient category normalized → 1:N.</strong> Added <code>ingredient_category</code> reference table. The <code>category</code> string on <code>ingredient</code> is replaced with <code>category_id FK</code>. Rename once, applies to all ingredients. Canonical list of aisle categories for shopping grouping.</li>
<li><strong>Admin user management added.</strong> New <code>system_role</code> column on <code>user_account</code> (admin vs user). New <code>admin_audit_log</code> table tracks admin actions: account creation, updates, password resets.</li>
<li>Table count: 16 → 18. Domain count: 5 → 6 (added Admin domain).</li>
</ul>
</div>
<!-- ═══ OVERVIEW ═══ -->
<div class="section">
<div class="section-title">Overview</div>
<div class="prose">This schema covers all six user journeys (J1J6), the suggestion/variety engine, the lightweight pantry tracker, recipe hero images, and platform-level admin user management. It is normalized by default, with computed fields (variety score) calculated at query time rather than stored. Every mutable table carries audit timestamps. Tags use a proper M:N relationship via a reference table + junction table.</div>
<div class="summary-grid">
<div class="summary-card"><div class="num" style="color:var(--purple);">4</div><div class="label">Auth &amp; household</div></div>
<div class="summary-card"><div class="num" style="color:var(--green);">7</div><div class="label">Recipe domain</div></div>
<div class="summary-card"><div class="num" style="color:var(--yellow-text);">3</div><div class="label">Planning domain</div></div>
<div class="summary-card"><div class="num" style="color:var(--blue);">2</div><div class="label">Shopping domain</div></div>
<div class="summary-card"><div class="num" style="color:var(--orange);">1</div><div class="label">Pantry domain</div></div>
<div class="summary-card"><div class="num" style="color:var(--red);">1</div><div class="label">Admin domain</div></div>
</div>
<div class="callout green">
<h4>Design decisions</h4>
<p><strong>Variety score</strong> is computed, not stored — it's derived from cooking_log + recipe_ingredient + week_plan_slot.<br>
<strong>Ingredients</strong> are a normalized reference table — enables merging, repetition tracking, and staple filtering.<br>
<strong>Tags</strong> are a proper M:N: a <code>tag</code> reference table + <code>recipe_tag</code> junction. One recipe → many tags, one tag → many recipes. Rename once, applies everywhere.<br>
<strong>Ingredient categories</strong> are a normalized 1:N reference table — one ingredient belongs to one category (e.g. "Produce", "Fish &amp; Meat"). Rename a category once, applies to all ingredients. Powers the aisle-grouped shopping list (J5 variant V2).<br>
<strong>Hero images</strong> store a URL/path reference to object storage (S3/R2).<br>
<strong>Admin</strong> uses a system_role on user_account (not the household role). Admin actions are audit-logged in a dedicated table.<br>
<strong>Pantry items</strong> link to the shared ingredient reference with best-before dates.</p>
</div>
</div>
<!-- ═══ ERD ═══ -->
<div class="section">
<div class="section-title">Entity-relationship diagram</div>
<div class="prose">Entities grouped by domain. Purple = auth, green = recipe, yellow = planning, blue = shopping, orange = pantry, red = admin. <span class="new" style="background:var(--red-tint);color:var(--red-dark);font-size:10px;padding:1px 6px;border-radius:3px;">NEW</span> marks v1.1 additions/changes.</div>
<div class="erd-canvas">
<div class="erd-grid">
<!-- COLUMN 1: AUTH -->
<div>
<div class="erd-entity">
<div class="erd-entity-head domain-auth">user_account <span class="new">CHANGED</span></div>
<div class="erd-cols">
<div class="erd-col"><span class="pk">PK id</span><span class="t">uuid</span></div>
<div class="erd-col"><span class="n">email</span><span class="t">citext UNIQUE</span></div>
<div class="erd-col"><span class="n">display_name</span><span class="t">varchar(100)</span></div>
<div class="erd-col"><span class="n">password_hash</span><span class="t">varchar(255)</span></div>
<div class="erd-col"><span class="n">system_role</span><span class="t">enum(admin,user) <span class="new">NEW</span></span></div>
<div class="erd-col"><span class="n">is_active</span><span class="t">boolean <span class="new">NEW</span></span></div>
<div class="erd-col"><span class="n">created_at</span><span class="t">timestamptz</span></div>
<div class="erd-col"><span class="n">updated_at</span><span class="t">timestamptz</span></div>
</div>
</div>
<div class="erd-entity">
<div class="erd-entity-head domain-auth">household</div>
<div class="erd-cols">
<div class="erd-col"><span class="pk">PK id</span><span class="t">uuid</span></div>
<div class="erd-col"><span class="n">name</span><span class="t">varchar(100)</span></div>
<div class="erd-col"><span class="fk">FK created_by</span><span class="t">→ user_account</span></div>
<div class="erd-col"><span class="n">created_at</span><span class="t">timestamptz</span></div>
</div>
</div>
<div class="erd-entity">
<div class="erd-entity-head domain-auth">household_member</div>
<div class="erd-cols">
<div class="erd-col"><span class="pk">PK id</span><span class="t">uuid</span></div>
<div class="erd-col"><span class="fk">FK household_id</span><span class="t">→ household</span></div>
<div class="erd-col"><span class="fk">FK user_id</span><span class="t">→ user_account UNIQUE</span></div>
<div class="erd-col"><span class="n">role</span><span class="t">enum(planner,member)</span></div>
<div class="erd-col"><span class="n">joined_at</span><span class="t">timestamptz</span></div>
</div>
</div>
<div class="erd-entity">
<div class="erd-entity-head domain-auth">household_invite</div>
<div class="erd-cols">
<div class="erd-col"><span class="pk">PK id</span><span class="t">uuid</span></div>
<div class="erd-col"><span class="fk">FK household_id</span><span class="t">→ household</span></div>
<div class="erd-col"><span class="n">invite_code</span><span class="t">varchar(20) UNIQUE</span></div>
<div class="erd-col"><span class="n">status</span><span class="t">enum(pending,used,expired)</span></div>
<div class="erd-col"><span class="n">expires_at</span><span class="t">timestamptz</span></div>
</div>
</div>
</div>
<!-- COLUMN 2: RECIPE -->
<div>
<div class="erd-entity">
<div class="erd-entity-head domain-recipe">recipe</div>
<div class="erd-cols">
<div class="erd-col"><span class="pk">PK id</span><span class="t">uuid</span></div>
<div class="erd-col"><span class="fk">FK household_id</span><span class="t">→ household</span></div>
<div class="erd-col"><span class="n">name</span><span class="t">varchar(200)</span></div>
<div class="erd-col"><span class="n">serves</span><span class="t">smallint</span></div>
<div class="erd-col"><span class="n">cook_time_min</span><span class="t">smallint</span></div>
<div class="erd-col"><span class="n">effort</span><span class="t">enum(easy,medium,hard)</span></div>
<div class="erd-col"><span class="n">is_child_friendly</span><span class="t">boolean</span></div>
<div class="erd-col"><span class="n">hero_image_url</span><span class="t">varchar(500) NULL</span></div>
<div class="erd-col"><span class="n">deleted_at</span><span class="t">timestamptz NULL</span></div>
</div>
</div>
<div class="erd-entity">
<div class="erd-entity-head domain-recipe">ingredient <span class="new">CHANGED</span></div>
<div class="erd-cols">
<div class="erd-col"><span class="pk">PK id</span><span class="t">uuid</span></div>
<div class="erd-col"><span class="fk">FK household_id</span><span class="t">→ household</span></div>
<div class="erd-col"><span class="n">name</span><span class="t">citext</span></div>
<div class="erd-col"><span class="n">is_staple</span><span class="t">boolean</span></div>
<div class="erd-col"><span class="fk">FK category_id</span><span class="t">→ ingredient_category NULL <span class="new">NEW</span></span></div>
</div>
</div>
<div class="erd-entity">
<div class="erd-entity-head domain-recipe">ingredient_category <span class="new">NEW</span></div>
<div class="erd-cols">
<div class="erd-col"><span class="pk">PK id</span><span class="t">uuid</span></div>
<div class="erd-col"><span class="fk">FK household_id</span><span class="t">→ household</span></div>
<div class="erd-col"><span class="n">name</span><span class="t">citext</span></div>
<div class="erd-col"><span class="n">sort_order</span><span class="t">smallint</span></div>
</div>
</div>
<div class="erd-entity">
<div class="erd-entity-head domain-recipe">recipe_ingredient</div>
<div class="erd-cols">
<div class="erd-col"><span class="pk">PK id</span><span class="t">uuid</span></div>
<div class="erd-col"><span class="fk">FK recipe_id</span><span class="t">→ recipe</span></div>
<div class="erd-col"><span class="fk">FK ingredient_id</span><span class="t">→ ingredient</span></div>
<div class="erd-col"><span class="n">quantity</span><span class="t">numeric(8,2)</span></div>
<div class="erd-col"><span class="n">unit</span><span class="t">varchar(20)</span></div>
<div class="erd-col"><span class="n">sort_order</span><span class="t">smallint</span></div>
</div>
</div>
<div class="erd-entity">
<div class="erd-entity-head domain-recipe">recipe_step</div>
<div class="erd-cols">
<div class="erd-col"><span class="pk">PK id</span><span class="t">uuid</span></div>
<div class="erd-col"><span class="fk">FK recipe_id</span><span class="t">→ recipe</span></div>
<div class="erd-col"><span class="n">step_number</span><span class="t">smallint</span></div>
<div class="erd-col"><span class="n">instruction</span><span class="t">text</span></div>
</div>
</div>
</div>
<!-- COLUMN 3: TAG (M:N) + PLAN -->
<div>
<div class="erd-entity">
<div class="erd-entity-head domain-recipe">tag <span class="new">NEW</span></div>
<div class="erd-cols">
<div class="erd-col"><span class="pk">PK id</span><span class="t">uuid</span></div>
<div class="erd-col"><span class="fk">FK household_id</span><span class="t">→ household</span></div>
<div class="erd-col"><span class="n">name</span><span class="t">citext</span></div>
<div class="erd-col"><span class="n">tag_type</span><span class="t">varchar(20)</span></div>
</div>
</div>
<div class="erd-entity">
<div class="erd-entity-head domain-recipe">recipe_tag <span class="new">CHANGED</span></div>
<div class="erd-cols">
<div class="erd-col"><span class="fk">FK recipe_id</span><span class="t">→ recipe</span></div>
<div class="erd-col"><span class="fk">FK tag_id</span><span class="t">→ tag <span class="new">NEW</span></span></div>
<div class="erd-col"><span class="pk">PK (recipe_id, tag_id)</span><span class="t">composite</span></div>
</div>
</div>
<div class="erd-entity">
<div class="erd-entity-head domain-plan">week_plan</div>
<div class="erd-cols">
<div class="erd-col"><span class="pk">PK id</span><span class="t">uuid</span></div>
<div class="erd-col"><span class="fk">FK household_id</span><span class="t">→ household</span></div>
<div class="erd-col"><span class="n">week_start</span><span class="t">date (Monday)</span></div>
<div class="erd-col"><span class="n">status</span><span class="t">enum(draft,confirmed)</span></div>
<div class="erd-col"><span class="n">confirmed_at</span><span class="t">timestamptz NULL</span></div>
</div>
</div>
<div class="erd-entity">
<div class="erd-entity-head domain-plan">week_plan_slot</div>
<div class="erd-cols">
<div class="erd-col"><span class="pk">PK id</span><span class="t">uuid</span></div>
<div class="erd-col"><span class="fk">FK week_plan_id</span><span class="t">→ week_plan</span></div>
<div class="erd-col"><span class="fk">FK recipe_id</span><span class="t">→ recipe</span></div>
<div class="erd-col"><span class="n">slot_date</span><span class="t">date</span></div>
</div>
</div>
<div class="erd-entity">
<div class="erd-entity-head domain-plan">cooking_log</div>
<div class="erd-cols">
<div class="erd-col"><span class="pk">PK id</span><span class="t">uuid</span></div>
<div class="erd-col"><span class="fk">FK recipe_id</span><span class="t">→ recipe</span></div>
<div class="erd-col"><span class="fk">FK household_id</span><span class="t">→ household</span></div>
<div class="erd-col"><span class="n">cooked_on</span><span class="t">date</span></div>
<div class="erd-col"><span class="fk">FK cooked_by</span><span class="t">→ user_account</span></div>
</div>
</div>
</div>
<!-- COLUMN 4: SHOPPING + PANTRY + ADMIN -->
<div>
<div class="erd-entity">
<div class="erd-entity-head domain-shop">shopping_list</div>
<div class="erd-cols">
<div class="erd-col"><span class="pk">PK id</span><span class="t">uuid</span></div>
<div class="erd-col"><span class="fk">FK household_id</span><span class="t">→ household</span></div>
<div class="erd-col"><span class="fk">FK week_plan_id</span><span class="t">→ week_plan</span></div>
<div class="erd-col"><span class="n">status</span><span class="t">enum(draft,published,done)</span></div>
<div class="erd-col"><span class="n">published_at</span><span class="t">timestamptz NULL</span></div>
</div>
</div>
<div class="erd-entity">
<div class="erd-entity-head domain-shop">shopping_list_item</div>
<div class="erd-cols">
<div class="erd-col"><span class="pk">PK id</span><span class="t">uuid</span></div>
<div class="erd-col"><span class="fk">FK shopping_list_id</span><span class="t">→ shopping_list</span></div>
<div class="erd-col"><span class="fk">FK ingredient_id</span><span class="t">→ ingredient NULL</span></div>
<div class="erd-col"><span class="n">custom_name</span><span class="t">varchar(200) NULL</span></div>
<div class="erd-col"><span class="n">quantity / unit</span><span class="t">numeric / varchar</span></div>
<div class="erd-col"><span class="n">is_checked</span><span class="t">boolean</span></div>
<div class="erd-col"><span class="n">source_recipes</span><span class="t">uuid[]</span></div>
</div>
</div>
<div class="erd-entity">
<div class="erd-entity-head domain-pantry">pantry_item</div>
<div class="erd-cols">
<div class="erd-col"><span class="pk">PK id</span><span class="t">uuid</span></div>
<div class="erd-col"><span class="fk">FK household_id</span><span class="t">→ household</span></div>
<div class="erd-col"><span class="fk">FK ingredient_id</span><span class="t">→ ingredient NULL</span></div>
<div class="erd-col"><span class="n">custom_name</span><span class="t">varchar(200) NULL</span></div>
<div class="erd-col"><span class="n">quantity / unit</span><span class="t">numeric / varchar</span></div>
<div class="erd-col"><span class="n">best_before</span><span class="t">date NULL</span></div>
<div class="erd-col"><span class="n">opened_on</span><span class="t">date NULL</span></div>
</div>
</div>
<div class="erd-entity">
<div class="erd-entity-head domain-admin">admin_audit_log <span class="new">NEW</span></div>
<div class="erd-cols">
<div class="erd-col"><span class="pk">PK id</span><span class="t">uuid</span></div>
<div class="erd-col"><span class="fk">FK admin_id</span><span class="t">→ user_account</span></div>
<div class="erd-col"><span class="fk">FK target_user_id</span><span class="t">→ user_account</span></div>
<div class="erd-col"><span class="n">action</span><span class="t">varchar(30)</span></div>
<div class="erd-col"><span class="n">detail</span><span class="t">jsonb NULL</span></div>
<div class="erd-col"><span class="n">performed_at</span><span class="t">timestamptz</span></div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- ═══ TAG MODEL EXPLANATION ═══ -->
<div class="section">
<div class="section-title">Tag model — M:N via reference table</div>
<div class="prose">v1.0 stored tags as raw strings in recipe_tag. v1.1 fixes this to a proper many-to-many relationship. One recipe can have many tags. One tag can appear on many recipes. Tags are owned by a household and typed (protein, dietary, cuisine) to enable structured filtering.</div>
<div class="callout green">
<h4>What changed and why</h4>
<p><strong>Before (v1.0):</strong> <code>recipe_tag(recipe_id, tag varchar(50))</code> — tag name stored as raw string per row. 30 recipes tagged "chicken" = 30 copies of the string "chicken". Renaming requires updating every row. No canonical list of available tags. No typed categorization.<br><br>
<strong>After (v1.1):</strong> <code>tag(id, household_id, name, tag_type)</code> + <code>recipe_tag(recipe_id, tag_id)</code> — pure M:N junction. Rename a tag in one UPDATE. List available tags with a simple SELECT. Filter by tag_type (protein, dietary, cuisine) for the J2 suggestion engine. The junction PK is composite <code>(recipe_id, tag_id)</code> — no surrogate key needed.</p>
</div>
<!-- tag table spec -->
<div class="table-spec">
<div class="table-spec-head">
<h3>tag <span style="font-size:10px;color:var(--red);margin-left:8px;">NEW in v1.1</span></h3>
<span class="domain pill d-recipe">Recipe</span>
</div>
<div class="table-spec-purpose">Reference table for category tags. Scoped per household — each household can have its own tag vocabulary. tag_type classifies tags for the suggestion engine: "protein" tags trigger consecutive-day avoidance, "dietary" tags are informational.</div>
<table class="col-table">
<thead><tr><th>Column</th><th>Type</th><th>Constraints</th><th>Purpose</th></tr></thead>
<tbody>
<tr class="pk-row"><td>id</td><td>uuid</td><td>PK, gen_random_uuid()</td><td>Surrogate PK</td></tr>
<tr class="fk-row"><td>household_id</td><td>uuid</td><td>NOT NULL, FK → household ON DELETE CASCADE</td><td>Tags belong to a household</td></tr>
<tr><td>name</td><td>citext</td><td>NOT NULL</td><td>"Chicken", "Fish", "Vegetarian", "Pasta"</td></tr>
<tr><td>tag_type</td><td>varchar(20)</td><td>NOT NULL, CHECK(tag_type IN ('protein','dietary','cuisine','other'))</td><td>Classification. "protein" powers J2 consecutive-day filter.</td></tr>
<tr><td>created_at</td><td>timestamptz</td><td>NOT NULL, DEFAULT now()</td><td>Creation time</td></tr>
</tbody>
</table>
<div class="spec-footer">
<h4>Indexes</h4>
<ul>
<li><code>UNIQUE(household_id, name)</code> — no duplicate tag names per household</li>
<li><code>idx_tag_type</code> on <code>(household_id, tag_type)</code> — filter by type (e.g. all protein tags for J2)</li>
</ul>
</div>
<div class="spec-footer">
<h4>Seed data (created on household setup)</h4>
<table class="example-table">
<thead><tr><th>name</th><th>tag_type</th></tr></thead>
<tbody>
<tr><td>Chicken</td><td>protein</td></tr>
<tr><td>Fish</td><td>protein</td></tr>
<tr><td>Beef</td><td>protein</td></tr>
<tr><td>Pork</td><td>protein</td></tr>
<tr><td>Vegetarian</td><td>dietary</td></tr>
<tr><td>Vegan</td><td>dietary</td></tr>
<tr><td>Pasta</td><td>cuisine</td></tr>
<tr><td>Quick meal</td><td>other</td></tr>
<tr><td>Child-friendly</td><td>other</td></tr>
</tbody>
</table>
</div>
</div>
<!-- recipe_tag junction spec -->
<div class="table-spec">
<div class="table-spec-head">
<h3>recipe_tag <span style="font-size:10px;color:var(--red);margin-left:8px;">CHANGED in v1.1</span></h3>
<span class="domain pill d-recipe">Recipe</span>
</div>
<div class="table-spec-purpose">Pure M:N junction table. No surrogate key — the composite PK (recipe_id, tag_id) is the natural key. A recipe can have many tags; a tag can appear on many recipes. Both directions are indexed.</div>
<table class="col-table">
<thead><tr><th>Column</th><th>Type</th><th>Constraints</th><th>Purpose</th></tr></thead>
<tbody>
<tr class="pk-row fk-row"><td>recipe_id</td><td>uuid</td><td>NOT NULL, FK → recipe ON DELETE CASCADE, part of composite PK</td><td>Which recipe</td></tr>
<tr class="pk-row fk-row"><td>tag_id</td><td>uuid</td><td>NOT NULL, FK → tag ON DELETE CASCADE, part of composite PK</td><td>Which tag</td></tr>
</tbody>
</table>
<div class="spec-footer">
<h4>Indexes</h4>
<ul>
<li><code>PRIMARY KEY (recipe_id, tag_id)</code> — composite PK enforces uniqueness + covers "tags for recipe X"</li>
<li><code>idx_rt_tag</code> on <code>(tag_id)</code> — covers the reverse: "recipes with tag Y"</li>
</ul>
</div>
<div class="spec-footer">
<h4>Why composite PK instead of surrogate?</h4>
<p>This is a pure junction table with no attributes of its own. A surrogate id column adds nothing — the composite (recipe_id, tag_id) is the natural key, guarantees uniqueness, and serves as the clustered index for the most common access pattern ("get all tags for this recipe"). Adding a uuid PK would waste 16 bytes per row and require an extra unique index anyway.</p>
</div>
</div>
<!-- Updated J2 query -->
<div class="query-card">
<h4>J2 — Same-protein consecutive day check (updated for M:N)</h4>
<div class="meta">Uses tag.tag_type = 'protein' to filter only protein tags from the M:N join</div>
<pre>-- What protein tags are on adjacent planned days?
SELECT wps.slot_date, t.name AS protein
FROM week_plan_slot wps
JOIN recipe_tag rt ON rt.recipe_id = wps.recipe_id
JOIN tag t ON t.id = rt.tag_id
WHERE wps.week_plan_id = $1
AND t.tag_type = 'protein'
ORDER BY wps.slot_date;</pre>
</div>
</div>
<!-- ═══ INGREDIENT CATEGORY ═══ -->
<div class="section">
<div class="section-title">Ingredient category — 1:N reference table</div>
<div class="prose">v1.0 stored category as a raw string on ingredient. v1.1 normalizes this to a proper 1:N relationship. One ingredient belongs to one category. One category contains many ingredients. Categories are owned per household and ordered for shopping list grouping.</div>
<div class="callout green">
<h4>What changed and why</h4>
<p><strong>Before:</strong> <code>ingredient.category varchar(30)</code> — raw string. 15 ingredients labelled "Produce" = 15 copies. Rename requires updating every row. No canonical list. No display ordering for the aisle-grouped shopping list.<br><br>
<strong>After:</strong> <code>ingredient_category(id, household_id, name, sort_order)</code> + <code>ingredient.category_id FK</code>. Rename once, applies everywhere. sort_order controls the display order on the aisle-grouped shopping list (J5 V2). Category is nullable on ingredient — uncategorized ingredients fall into an "Other" group in the UI.</p>
</div>
<div class="table-spec">
<div class="table-spec-head">
<h3>ingredient_category <span style="font-size:10px;color:var(--red);margin-left:8px;">NEW in v1.1</span></h3>
<span class="domain pill d-recipe">Recipe</span>
</div>
<div class="table-spec-purpose">Reference table for ingredient aisle categories. Scoped per household — each household can customize their store layout. sort_order controls the display sequence in the aisle-grouped shopping list view (J5 screen D1 variant V2).</div>
<table class="col-table">
<thead><tr><th>Column</th><th>Type</th><th>Constraints</th><th>Purpose</th></tr></thead>
<tbody>
<tr class="pk-row"><td>id</td><td>uuid</td><td>PK, gen_random_uuid()</td><td>Surrogate PK</td></tr>
<tr class="fk-row"><td>household_id</td><td>uuid</td><td>NOT NULL, FK → household ON DELETE CASCADE</td><td>Categories are per-household</td></tr>
<tr><td>name</td><td>citext</td><td>NOT NULL</td><td>"Produce", "Fish &amp; Meat", "Dry Goods", "Dairy", "Sauces &amp; Condiments"</td></tr>
<tr><td>sort_order</td><td>smallint</td><td>NOT NULL, DEFAULT 0</td><td>Display order — matches supermarket aisle flow</td></tr>
<tr><td>created_at</td><td>timestamptz</td><td>NOT NULL, DEFAULT now()</td><td>Creation time</td></tr>
</tbody>
</table>
<div class="spec-footer">
<h4>Indexes</h4>
<ul>
<li><code>UNIQUE(household_id, name)</code> — no duplicate category names per household</li>
<li><code>idx_ic_sort</code> on <code>(household_id, sort_order)</code> — ordered display in shopping list grouping</li>
</ul>
</div>
<div class="spec-footer">
<h4>Seed data (created on household setup)</h4>
<table class="example-table">
<thead><tr><th>name</th><th>sort_order</th></tr></thead>
<tbody>
<tr><td>Produce</td><td>1</td></tr>
<tr><td>Fish &amp; Meat</td><td>2</td></tr>
<tr><td>Dairy &amp; Eggs</td><td>3</td></tr>
<tr><td>Dry Goods &amp; Pasta</td><td>4</td></tr>
<tr><td>Canned &amp; Jarred</td><td>5</td></tr>
<tr><td>Sauces &amp; Condiments</td><td>6</td></tr>
<tr><td>Frozen</td><td>7</td></tr>
<tr><td>Bakery &amp; Bread</td><td>8</td></tr>
</tbody>
</table>
</div>
<div class="spec-footer">
<h4>Shopping list grouping query (J5 · D1 aisle view)</h4>
<pre style="font-family:var(--font-mono);font-size:10px;background:var(--color-text);color:#AEDCB0;padding:12px 16px;border-radius:var(--radius-md);overflow-x:auto;line-height:1.6;white-space:pre;">SELECT
COALESCE(ic.name, 'Other') AS aisle,
COALESCE(ic.sort_order, 999) AS aisle_order,
sli.id, COALESCE(i.name, sli.custom_name) AS item_name,
sli.quantity, sli.unit, sli.is_checked
FROM shopping_list_item sli
LEFT JOIN ingredient i ON i.id = sli.ingredient_id
LEFT JOIN ingredient_category ic ON ic.id = i.category_id
WHERE sli.shopping_list_id = $1
ORDER BY aisle_order, item_name;</pre>
</div>
</div>
</div>
<!-- ═══ ADMIN USER MANAGEMENT ═══ -->
<div class="section">
<div class="section-title">Admin user management — new in v1.1</div>
<div class="prose">The system needs a platform-level admin who can create user accounts, update them, and reset passwords. This is separate from the household "planner" role — a planner manages meal plans, an admin manages the platform. The two role systems are orthogonal: system_role (admin vs user) lives on user_account; household_role (planner vs member) lives on household_member.</div>
<div class="callout red">
<h4>Two role systems — don't confuse them</h4>
<p><strong>system_role</strong> on <code>user_account</code>: platform-level. "admin" can manage all user accounts. "user" is a normal user. This is about platform administration.<br><br>
<strong>household role</strong> on <code>household_member</code>: app-level. "planner" has full access to 18 screens. "member" sees C1 read-only + D1 collaborative. This is about what you can do within a household.<br><br>
An admin can also be a planner in their own household. The roles are independent.</p>
</div>
<!-- user_account updated spec -->
<div class="table-spec">
<div class="table-spec-head">
<h3>user_account <span style="font-size:10px;color:var(--red);margin-left:8px;">CHANGED in v1.1</span></h3>
<span class="domain pill d-auth">Auth</span>
</div>
<div class="table-spec-purpose">User identity for both app users and platform admins. system_role determines platform-level access. is_active allows admins to deactivate accounts without deleting them. Authentication handled here; household authorization in household_member.</div>
<table class="col-table">
<thead><tr><th>Column</th><th>Type</th><th>Constraints</th><th>Purpose</th></tr></thead>
<tbody>
<tr class="pk-row"><td>id</td><td>uuid</td><td>PK, gen_random_uuid()</td><td>Surrogate PK</td></tr>
<tr><td>email</td><td>citext</td><td>NOT NULL, UNIQUE</td><td>Login identifier, case-insensitive</td></tr>
<tr><td>display_name</td><td>varchar(100)</td><td>NOT NULL</td><td>Shown in UI (sidebar avatar initials)</td></tr>
<tr><td>password_hash</td><td>varchar(255)</td><td>NOT NULL</td><td>bcrypt/argon2 hash — never exposed via API</td></tr>
<tr><td>system_role</td><td>varchar(10)</td><td>NOT NULL, DEFAULT 'user', CHECK(system_role IN ('admin','user'))</td><td style="color:var(--red-dark);">NEW — platform role. Admin can manage all accounts.</td></tr>
<tr><td>is_active</td><td>boolean</td><td>NOT NULL, DEFAULT true</td><td style="color:var(--red-dark);">NEW — admin can deactivate accounts. Inactive users cannot log in.</td></tr>
<tr><td>created_at</td><td>timestamptz</td><td>NOT NULL, DEFAULT now()</td><td>Account creation time</td></tr>
<tr><td>updated_at</td><td>timestamptz</td><td>NOT NULL, DEFAULT now()</td><td>Last profile edit</td></tr>
</tbody>
</table>
<div class="spec-footer">
<h4>Indexes</h4>
<ul>
<li><code>UNIQUE(email)</code> — login lookup</li>
<li><code>idx_user_active</code> on <code>(is_active) WHERE is_active = true</code> — filter active users on login</li>
<li><code>idx_user_system_role</code> on <code>(system_role) WHERE system_role = 'admin'</code> — fast admin check (tiny result set)</li>
</ul>
</div>
<div class="spec-footer">
<h4>Admin workflows</h4>
<p><strong>Create account:</strong> Admin INSERTs into user_account with a temporary password_hash. Admin can set system_role to 'admin' or 'user'.<br>
<strong>Update account:</strong> Admin UPDATEs display_name, email, is_active, or system_role. All changes logged to admin_audit_log.<br>
<strong>Reset password:</strong> Admin UPDATEs password_hash to a new temporary hash. User must change on next login (enforced at app layer). Logged to admin_audit_log.</p>
</div>
</div>
<!-- admin_audit_log spec -->
<div class="table-spec">
<div class="table-spec-head">
<h3>admin_audit_log <span style="font-size:10px;color:var(--red);margin-left:8px;">NEW in v1.1</span></h3>
<span class="domain pill d-admin">Admin</span>
</div>
<div class="table-spec-purpose">Immutable audit trail for all admin actions on user accounts. Every account creation, update, or password reset by an admin is logged here. Never updated or deleted. Used for compliance, debugging, and accountability.</div>
<table class="col-table">
<thead><tr><th>Column</th><th>Type</th><th>Constraints</th><th>Purpose</th></tr></thead>
<tbody>
<tr class="pk-row"><td>id</td><td>uuid</td><td>PK, gen_random_uuid()</td><td>Surrogate PK</td></tr>
<tr class="fk-row"><td>admin_id</td><td>uuid</td><td>NOT NULL, FK → user_account ON DELETE RESTRICT</td><td>Which admin performed the action</td></tr>
<tr class="fk-row"><td>target_user_id</td><td>uuid</td><td>NOT NULL, FK → user_account ON DELETE RESTRICT</td><td>Which user was affected</td></tr>
<tr><td>action</td><td>varchar(30)</td><td>NOT NULL, CHECK(action IN ('create_account','update_account','reset_password','deactivate_account','reactivate_account','change_system_role'))</td><td>What happened</td></tr>
<tr><td>detail</td><td>jsonb</td><td>NULL</td><td>Changed fields snapshot: {"field":"email","old":"a@x.com","new":"b@x.com"}</td></tr>
<tr><td>ip_address</td><td>inet</td><td>NULL</td><td>Admin's IP for security audit</td></tr>
<tr><td>performed_at</td><td>timestamptz</td><td>NOT NULL, DEFAULT now()</td><td>When the action occurred</td></tr>
</tbody>
</table>
<div class="spec-footer">
<h4>Indexes</h4>
<ul>
<li><code>idx_aal_target</code> on <code>(target_user_id, performed_at DESC)</code> — "show me all admin actions on this user"</li>
<li><code>idx_aal_admin</code> on <code>(admin_id, performed_at DESC)</code> — "show me everything this admin did"</li>
<li><code>idx_aal_action</code> on <code>(action, performed_at DESC)</code> — "show me all password resets this month"</li>
</ul>
</div>
<div class="spec-footer">
<h4>Why JSONB for detail?</h4>
<p>This is justified JSONB usage. The detail column stores variable-shape change records — a password reset has no "old" value, an email change has old + new, a deactivation has a reason string. Normalizing this into typed columns would require a different schema per action type. The JSONB is write-once, rarely queried for filtering (only read for display), and genuinely schemaless data. This is the right use case for JSON.</p>
</div>
<div class="spec-footer">
<h4>Example rows</h4>
<table class="example-table">
<thead><tr><th>admin</th><th>target</th><th>action</th><th>detail</th><th>performed_at</th></tr></thead>
<tbody>
<tr><td>admin@app.com</td><td>jane@home.com</td><td>create_account</td><td>{"display_name":"Jane Smith","system_role":"user"}</td><td>2026-04-01 10:00</td></tr>
<tr><td>admin@app.com</td><td>jane@home.com</td><td>reset_password</td><td>{"reason":"user requested via support"}</td><td>2026-04-01 10:05</td></tr>
<tr><td>admin@app.com</td><td>bob@home.com</td><td>deactivate_account</td><td>{"reason":"inactive for 12 months"}</td><td>2026-04-01 11:00</td></tr>
</tbody>
</table>
</div>
<div class="spec-footer">
<h4>Immutability enforcement</h4>
<p>Application layer must never issue UPDATE or DELETE on this table. As a safety net, a RULE or trigger can reject UPDATEs:<br>
<code>CREATE RULE no_update_audit AS ON UPDATE TO admin_audit_log DO INSTEAD NOTHING;</code><br>
<code>CREATE RULE no_delete_audit AS ON DELETE TO admin_audit_log DO INSTEAD NOTHING;</code></p>
</div>
</div>
</div>
<!-- ═══ UPDATED FK MAP ═══ -->
<div class="section">
<div class="section-title">Foreign key map (updated v1.1)</div>
<div class="table-spec">
<table class="col-table">
<thead><tr><th>From table</th><th>Column</th><th>References</th><th>Cardinality</th><th>On delete</th></tr></thead>
<tbody>
<tr><td>household</td><td>created_by</td><td>user_account.id</td><td>N:1</td><td>RESTRICT</td></tr>
<tr><td>household_member</td><td>household_id</td><td>household.id</td><td>N:1</td><td>CASCADE</td></tr>
<tr><td>household_member</td><td>user_id</td><td>user_account.id</td><td>N:1</td><td>CASCADE</td></tr>
<tr><td>household_invite</td><td>household_id</td><td>household.id</td><td>N:1</td><td>CASCADE</td></tr>
<tr><td>recipe</td><td>household_id</td><td>household.id</td><td>N:1</td><td>CASCADE</td></tr>
<tr><td>ingredient</td><td>household_id</td><td>household.id</td><td>N:1</td><td>CASCADE</td></tr>
<tr style="background:var(--red-tint);"><td>ingredient_category</td><td>household_id</td><td>household.id</td><td>N:1</td><td>CASCADE</td></tr>
<tr style="background:var(--red-tint);"><td>ingredient</td><td>category_id</td><td>ingredient_category.id</td><td>N:1 (nullable)</td><td>SET NULL</td></tr>
<tr style="background:var(--red-tint);"><td>tag</td><td>household_id</td><td>household.id</td><td>N:1</td><td>CASCADE</td></tr>
<tr><td>recipe_ingredient</td><td>recipe_id</td><td>recipe.id</td><td>N:1</td><td>CASCADE</td></tr>
<tr><td>recipe_ingredient</td><td>ingredient_id</td><td>ingredient.id</td><td>N:1</td><td>RESTRICT</td></tr>
<tr><td>recipe_step</td><td>recipe_id</td><td>recipe.id</td><td>N:1</td><td>CASCADE</td></tr>
<tr style="background:var(--red-tint);"><td>recipe_tag</td><td>recipe_id</td><td>recipe.id</td><td>M:N junction</td><td>CASCADE</td></tr>
<tr style="background:var(--red-tint);"><td>recipe_tag</td><td>tag_id</td><td>tag.id</td><td>M:N junction</td><td>CASCADE</td></tr>
<tr><td>week_plan</td><td>household_id</td><td>household.id</td><td>N:1</td><td>CASCADE</td></tr>
<tr><td>week_plan_slot</td><td>week_plan_id</td><td>week_plan.id</td><td>N:1</td><td>CASCADE</td></tr>
<tr><td>week_plan_slot</td><td>recipe_id</td><td>recipe.id</td><td>N:1</td><td>RESTRICT</td></tr>
<tr><td>cooking_log</td><td>recipe_id</td><td>recipe.id</td><td>N:1</td><td>RESTRICT</td></tr>
<tr><td>cooking_log</td><td>week_plan_slot_id</td><td>week_plan_slot.id</td><td>N:1 (nullable)</td><td>SET NULL</td></tr>
<tr><td>shopping_list</td><td>week_plan_id</td><td>week_plan.id</td><td>N:1</td><td>RESTRICT</td></tr>
<tr><td>shopping_list_item</td><td>shopping_list_id</td><td>shopping_list.id</td><td>N:1</td><td>CASCADE</td></tr>
<tr><td>shopping_list_item</td><td>ingredient_id</td><td>ingredient.id</td><td>N:1 (nullable)</td><td>SET NULL</td></tr>
<tr><td>pantry_item</td><td>household_id</td><td>household.id</td><td>N:1</td><td>CASCADE</td></tr>
<tr><td>pantry_item</td><td>ingredient_id</td><td>ingredient.id</td><td>N:1 (nullable)</td><td>SET NULL</td></tr>
<tr style="background:var(--red-tint);"><td>admin_audit_log</td><td>admin_id</td><td>user_account.id</td><td>N:1</td><td>RESTRICT</td></tr>
<tr style="background:var(--red-tint);"><td>admin_audit_log</td><td>target_user_id</td><td>user_account.id</td><td>N:1</td><td>RESTRICT</td></tr>
</tbody>
</table>
</div>
<div class="prose" style="font-size:11px;"><span style="display:inline-block;width:12px;height:12px;background:var(--red-tint);border:1px solid #F4ACA4;border-radius:2px;vertical-align:middle;margin-right:4px;"></span> = new or changed in v1.1</div>
</div>
<!-- ═══ QUERY PATTERNS ═══ -->
<div class="section">
<div class="section-title">Key query patterns</div>
<div class="query-card">
<h4>J2 — Ingredient repetition check (last 3 days)</h4>
<div class="meta">Frequency: ~10×/week · Target: &lt;50ms</div>
<pre>WITH recent_meals AS (
SELECT recipe_id, cooked_on
FROM cooking_log
WHERE household_id = $1
AND cooked_on >= CURRENT_DATE - INTERVAL '3 days'
)
SELECT DISTINCT i.id, i.name
FROM recent_meals rm
JOIN recipe_ingredient ri ON ri.recipe_id = rm.recipe_id
JOIN ingredient i ON i.id = ri.ingredient_id;</pre>
</div>
<div class="query-card">
<h4>J2 — Protein tags on adjacent days (M:N join)</h4>
<div class="meta">Frequency: ~10×/week · Target: &lt;30ms</div>
<pre>SELECT wps.slot_date, t.name AS protein
FROM week_plan_slot wps
JOIN recipe_tag rt ON rt.recipe_id = wps.recipe_id
JOIN tag t ON t.id = rt.tag_id
WHERE wps.week_plan_id = $1
AND t.tag_type = 'protein'
ORDER BY wps.slot_date;</pre>
</div>
<div class="query-card">
<h4>J5 — Shopping list generation (merged + staples filtered)</h4>
<div class="meta">Frequency: 1×/week · Target: &lt;200ms</div>
<pre>SELECT i.id, i.name,
SUM(ri.quantity) AS total_qty, ri.unit,
ARRAY_AGG(DISTINCT r.id) AS source_recipe_ids
FROM week_plan_slot wps
JOIN recipe r ON r.id = wps.recipe_id
JOIN recipe_ingredient ri ON ri.recipe_id = r.id
JOIN ingredient i ON i.id = ri.ingredient_id
WHERE wps.week_plan_id = $1
AND i.is_staple = false
GROUP BY i.id, i.name, ri.unit
ORDER BY i.name;</pre>
</div>
<div class="query-card">
<h4>Pantry — Items expiring within 3 days</h4>
<div class="meta">Frequency: daily · Target: &lt;20ms</div>
<pre>SELECT pi.id, COALESCE(i.name, pi.custom_name) AS name,
pi.best_before, pi.quantity, pi.unit
FROM pantry_item pi
LEFT JOIN ingredient i ON i.id = pi.ingredient_id
WHERE pi.household_id = $1
AND pi.best_before IS NOT NULL
AND pi.best_before &lt;= CURRENT_DATE + INTERVAL '3 days'
ORDER BY pi.best_before;</pre>
</div>
<div class="query-card">
<h4>Admin — All actions on a user (audit trail)</h4>
<div class="meta">Frequency: on-demand · Target: &lt;50ms</div>
<pre>SELECT aal.action, aal.detail, aal.performed_at,
admin.display_name AS admin_name, admin.email AS admin_email
FROM admin_audit_log aal
JOIN user_account admin ON admin.id = aal.admin_id
WHERE aal.target_user_id = $1
ORDER BY aal.performed_at DESC;</pre>
</div>
<div class="query-card">
<h4>All tags for a recipe (M:N forward lookup)</h4>
<div class="meta">Frequency: every recipe detail load · Target: &lt;10ms</div>
<pre>SELECT t.id, t.name, t.tag_type
FROM recipe_tag rt
JOIN tag t ON t.id = rt.tag_id
WHERE rt.recipe_id = $1
ORDER BY t.tag_type, t.name;</pre>
</div>
<div class="query-card">
<h4>All recipes with a specific tag (M:N reverse lookup)</h4>
<div class="meta">Frequency: J2 suggestion filter, B1 filter chips · Target: &lt;30ms</div>
<pre>SELECT r.id, r.name, r.effort, r.cook_time_min
FROM recipe_tag rt
JOIN recipe r ON r.id = rt.recipe_id
WHERE rt.tag_id = $1
AND r.deleted_at IS NULL
ORDER BY r.name;</pre>
</div>
</div>
<!-- ═══ MIGRATION ORDER ═══ -->
<div class="section">
<div class="section-title">Migration order (v1.1)</div>
<div class="query-card">
<h4>Migration 001 — Extensions &amp; triggers</h4>
<div class="meta">Run once before any table creation</div>
<pre>CREATE EXTENSION IF NOT EXISTS "pgcrypto"; -- gen_random_uuid()
CREATE EXTENSION IF NOT EXISTS "citext"; -- case-insensitive text
CREATE OR REPLACE FUNCTION trigger_set_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;</pre>
</div>
<div class="callout green">
<h4>Table creation order (respects FK dependencies)</h4>
<p>1. user_account → 2. household → 3. household_member → 4. household_invite → 5. ingredient_category → 6. ingredient → 7. tag → 8. recipe → 9. recipe_ingredient → 10. recipe_step → 11. recipe_tag → 12. week_plan → 13. week_plan_slot → 14. cooking_log → 15. shopping_list → 16. shopping_list_item → 17. pantry_item → 18. admin_audit_log</p>
</div>
<div class="query-card">
<h4>Immutability rules for audit tables</h4>
<div class="meta">Apply after admin_audit_log creation</div>
<pre>-- Prevent accidental updates/deletes on audit log
CREATE RULE no_update_audit AS ON UPDATE TO admin_audit_log
DO INSTEAD NOTHING;
CREATE RULE no_delete_audit AS ON DELETE TO admin_audit_log
DO INSTEAD NOTHING;</pre>
</div>
</div>
<!-- ═══ JOURNEY COVERAGE ═══ -->
<div class="section">
<div class="section-title">Journey → table coverage matrix (v1.1)</div>
<div class="table-spec">
<table class="col-table">
<thead><tr><th>Journey</th><th>Reads</th><th>Writes</th><th>Critical path</th></tr></thead>
<tbody>
<tr><td style="font-family:var(--font-sans);font-weight:500;color:var(--green-dark);">J1 · Add recipe</td><td>ingredient, tag (autocomplete)</td><td>recipe, recipe_ingredient, recipe_step, recipe_tag, ingredient, tag</td><td>Recipe INSERT + child rows + tag associations in one transaction</td></tr>
<tr><td style="font-family:var(--font-sans);font-weight:500;color:var(--yellow-text);">J2 · Plan week</td><td>recipe, recipe_ingredient, recipe_tag, tag, cooking_log, ingredient</td><td>week_plan, week_plan_slot</td><td>Variety CTE joins tag (type=protein) for consecutive-day check</td></tr>
<tr><td style="font-family:var(--font-sans);font-weight:500;color:var(--green-dark);">J3 · Cook tonight</td><td>week_plan_slot, recipe, recipe_ingredient, recipe_step</td><td>cooking_log</td><td>cooking_log INSERT (immutable event)</td></tr>
<tr><td style="font-family:var(--font-sans);font-weight:500;color:var(--orange-dark);">J4 · Adapt on fly</td><td>recipe, recipe_tag, tag, cooking_log</td><td>week_plan_slot (UPDATE recipe_id)</td><td>Slot UPDATE + variety recompute ≤ 3 taps</td></tr>
<tr><td style="font-family:var(--font-sans);font-weight:500;color:var(--blue-dark);">J5 · Shopping list</td><td>week_plan_slot, recipe_ingredient, ingredient, ingredient_category</td><td>shopping_list, shopping_list_item</td><td>Merge query (GROUP BY ingredient, SUM quantity) + aisle grouping via category</td></tr>
<tr><td style="font-family:var(--font-sans);font-weight:500;color:var(--purple-dark);">J6 · Household setup</td><td></td><td>user_account, household, household_member, household_invite, ingredient (staples), tag (seed data), ingredient_category (seed data)</td><td>Household creation + seed data in one transaction</td></tr>
<tr><td style="font-family:var(--font-sans);font-weight:500;color:var(--orange-dark);">Pantry</td><td>pantry_item, ingredient</td><td>pantry_item</td><td>Expiry notification query (daily)</td></tr>
<tr style="background:var(--red-tint);"><td style="font-family:var(--font-sans);font-weight:500;color:var(--red-dark);">Admin</td><td>user_account, admin_audit_log</td><td>user_account, admin_audit_log</td><td>Every admin action → audit log INSERT in same transaction</td></tr>
</tbody>
</table>
</div>
</div>
<!-- ═══ PUSHBACK LOG ═══ -->
<div class="section">
<div class="section-title">Pushback &amp; trade-off log</div>
<div class="callout blue">
<h4>v1.0 bug: recipe_tag was 1:N, not M:N</h4>
<p><strong>Fixed in v1.1.</strong> v1.0 stored tags as raw strings, making recipe_tag structurally a 1:N (one recipe → many string rows) rather than a true M:N. The tag string had no identity — "chicken" on recipe A and "chicken" on recipe B were unrelated rows. This prevented tag renaming, tag listing, and structured filtering. v1.1 adds a <code>tag</code> reference table, making recipe_tag a proper M:N junction with FK integrity in both directions.</p>
</div>
<div class="callout blue">
<h4>v1.0 bug: ingredient.category was a raw string, not a FK</h4>
<p><strong>Fixed in v1.1.</strong> Same anti-pattern as the tag issue. 15 ingredients all storing the string "Produce" independently — no shared identity, no rename capability, no canonical list, no sort order. v1.1 extracts this to <code>ingredient_category</code> as a reference table with a 1:N FK from ingredient. The sort_order column enables the aisle-grouped shopping list (J5 D1 V2) to match supermarket layout. Category is nullable — uncategorized ingredients group into "Other."</p>
</div>
<div class="callout">
<h4>source_recipes as uuid[] — trade-off accepted</h4>
<p>Violates 1NF. But it's write-once display metadata, never joined against. A junction table would add ~60 rows/week for zero query benefit. Sunset plan: migrate to junction table if future requirements need "which list items came from recipe X?"</p>
</div>
<div class="callout">
<h4>Variety score is computed, not materialized</h4>
<p>At ~50 recipes × 7 slots, the CTE runs in &lt;100ms. Materialized view adds staleness risk. At 100× scale, we add materialized view with refresh-on-mutation triggers.</p>
</div>
<div class="callout">
<h4>admin_audit_log.detail uses JSONB — justified</h4>
<p>This is the right use case for schemaless data. Each action type has a different shape (password reset has a "reason", email change has "old"+"new", creation has full profile). The data is write-once, append-only, and queried for display only. Normalizing it into typed columns would require a different schema per action type for no benefit.</p>
</div>
<div class="callout blue">
<h4>Rejected: separate admin_user table</h4>
<p>Having a separate table for admins would split identity across two tables. Login would need to check both. An admin who is also a household planner would need two accounts. Instead, system_role on user_account keeps one identity per person. The role check is a single column read.</p>
</div>
</div>
</div>
</body>
</html>