Files
mealprep/specs/backend/api-design.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

1456 lines
76 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 — API Design v1.2</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;
}
*,*::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;}
.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.purple{background:var(--purple-tint);border-color:#CECBF6;}
.callout.purple h4{color:var(--purple-dark);}.callout.purple p{color:var(--purple-dark);}
.callout.red{background:var(--red-tint);border-color:#F4ACA4;}
.callout.red h4{color:var(--red-dark);}.callout.red p{color:var(--red-dark);}
.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;}
.summary-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(155px,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;}
.stack-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin-bottom:24px;}
.stack-card{background:var(--color-surface);border:1px solid var(--color-border);border-radius:var(--radius-lg);padding:16px;}
.stack-card h4{font-size:12px;font-weight:500;margin-bottom:4px;}
.stack-card .tool{font-family:var(--font-mono);font-size:12px;color:var(--purple);font-weight:500;margin-bottom:6px;}
.stack-card p{font-size:11px;color:var(--color-text-muted);line-height:1.5;}
.endpoint-group{background:var(--color-surface);border:1px solid var(--color-border);border-radius:var(--radius-xl);overflow:hidden;margin-bottom:24px;}
.endpoint-group-head{padding:14px 20px;border-bottom:1px solid var(--color-border);display:flex;justify-content:space-between;align-items:center;}
.endpoint-group-head h3{font-family:var(--font-mono);font-size:14px;font-weight:500;}
/* Endpoint row */
.ep{border-bottom:1px solid var(--color-subtle);}
.ep:last-child{border-bottom:none;}
.ep-row{display:grid;grid-template-columns:62px 1fr 60px 52px;gap:8px;padding:9px 12px;align-items:start;font-size:11px;cursor:default;}
.ep-row.has-detail{cursor:pointer;}
.ep-row.has-detail:hover{background:rgba(0,0,0,.015);}
.ep-head{display:grid;grid-template-columns:62px 1fr 60px 52px;gap:8px;padding:8px 12px;font-size:9px;font-weight:500;letter-spacing:.08em;text-transform:uppercase;color:var(--color-text-muted);background:var(--color-subtle);border-bottom:1px solid var(--color-border);}
.ep-desc{font-size:11px;color:var(--color-text-muted);line-height:1.5;}
.ep-path{font-family:var(--font-mono);font-size:10px;color:var(--color-text);}
.method{font-family:var(--font-mono);font-size:10px;font-weight:500;padding:2px 6px;border-radius:3px;display:inline-block;min-width:50px;text-align:center;}
.method-get{background:var(--green-tint);color:var(--green-dark);}
.method-post{background:var(--blue-tint);color:var(--blue-dark);}
.method-patch{background:var(--yellow-tint);color:var(--yellow-text);}
.method-put{background:var(--orange-tint);color:var(--orange-dark);}
.method-delete{background:var(--red-tint);color:var(--red-dark);}
.param{color:var(--purple);}
.auth-badge{font-size:9px;padding:1px 5px;border-radius:2px;font-weight:500;white-space:nowrap;}
.auth-required{background:var(--purple-tint);color:var(--purple-dark);}
.auth-public{background:var(--color-subtle);color:var(--color-text-muted);}
.auth-admin{background:var(--red-tint);color:var(--red-dark);}
.auth-planner{background:var(--green-tint);color:var(--green-dark);}
.journey-ref{font-family:var(--font-mono);font-size:9px;color:var(--color-text-muted);background:var(--color-subtle);padding:1px 4px;border-radius:2px;margin-right:2px;}
/* Collapsible detail */
.ep-detail{display:none;border-top:1px dashed var(--color-border);padding:12px 16px;background:var(--color-page);}
.ep.open .ep-detail{display:block;}
.ep-detail-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px;}
.ep-detail-col h5{font-size:9px;font-weight:500;letter-spacing:.1em;text-transform:uppercase;color:var(--color-text-muted);margin-bottom:8px;}
.ep-detail-col pre{font-family:var(--font-mono);font-size:10px;line-height:1.6;color:var(--color-text);background:var(--color-surface);border:1px solid var(--color-border);border-radius:var(--radius-md);padding:10px 12px;overflow-x:auto;white-space:pre;}
.ep-detail-col .cmt{color:var(--color-text-muted);}
.ep-detail-col .req{color:var(--blue-dark);}
.ep-detail-col .opt{color:var(--color-text-muted);}
.ep-detail-col .tp{color:var(--purple);}
.ep-detail-note{font-size:10px;color:var(--color-text-muted);margin-top:8px;line-height:1.5;font-style:italic;}
.ep-toggle{font-size:9px;color:var(--color-text-muted);font-family:var(--font-mono);user-select:none;transition:transform .15s;}
.ep.open .ep-toggle{transform:rotate(90deg);}
.ep-row-top{display:flex;justify-content:space-between;align-items:start;gap:8px;}
/* Single column fallback for narrow detail */
@media(max-width:700px){.ep-detail-grid{grid-template-columns:1fr;}}
/* Code blocks */
.code-block{background:var(--color-text);border-radius:var(--radius-lg);overflow:hidden;margin-bottom:20px;}
.code-block-head{padding:8px 16px;background:rgba(255,255,255,.06);}
.code-block-head span{font-family:var(--font-mono);font-size:10px;color:#6B6A63;}
.code-block pre{padding:16px;font-family:var(--font-mono);font-size:11px;color:#AEDCB0;overflow-x:auto;line-height:1.65;white-space:pre;}
.key{color:#A4CFF4;}.str{color:#AEDCB0;}.cmt{color:#6B6A63;}.kw{color:#CECBF6;}.nm{color:#F9E08A;}
.journey-map{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:24px;}
.journey-card{background:var(--color-surface);border:1px solid var(--color-border);border-radius:var(--radius-lg);padding:16px 20px;}
.journey-card h4{font-size:13px;font-weight:500;margin-bottom:8px;}
.journey-card .jc-id{font-family:var(--font-mono);font-size:10px;color:var(--color-text-muted);margin-bottom:4px;}
.journey-card ul{list-style:none;padding:0;}
.journey-card li{font-size:11px;color:var(--color-text-muted);padding:3px 0;font-family:var(--font-mono);}
.journey-card li::before{content:'→ ';color:var(--color-border);}
.pkg{font-family:var(--font-mono);font-size:10px;color:var(--color-text-muted);background:var(--color-subtle);padding:2px 6px;border-radius:3px;}
.toc{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:32px;}
.toc a{font-size:11px;font-family:var(--font-mono);color:var(--blue-dark);text-decoration:none;padding:4px 10px;background:var(--blue-tint);border-radius:var(--radius-sm);transition:background .15s;}
.toc a:hover{background:var(--blue-light);}
@media(max-width:768px){
.doc{padding:24px 16px 80px;}
.journey-map,.stack-grid{grid-template-columns:1fr;}
.doc-header{flex-direction:column;align-items:flex-start;gap:12px;}
.doc-meta{text-align:left;}
.ep-row,.ep-head{grid-template-columns:54px 1fr 50px 40px;gap:4px;padding:8px 8px;}
}
</style>
</head>
<body>
<div class="doc">
<div class="doc-header">
<div>
<h1>API design</h1>
<p>Recipe app · Spring Boot 4 · PostgreSQL · Self-hosted · Single machine</p>
</div>
<div class="doc-meta">
<span class="pill pill-v">v1.3</span><br>
Style: REST + JSON<br>
Framework: Spring Boot 4.0<br>
Auth: Spring Security 7 (session)<br>
Endpoints: 33<br>
Designed by: Nexus
</div>
</div>
<div class="changelog">
<h3>v1.3 changes from v1.2</h3>
<ul>
<li><strong>Ingredient category is now a reference table.</strong> <code>ingredient.category varchar(30)</code> replaced by <code>ingredient.category_id FK → ingredient_category</code>. New <code>ingredient_category</code> table (id, household_id, name). API responses now return <code>"category": { "id": "...", "name": "meat" }</code> instead of <code>"category": "meat"</code>. Two new endpoints: <code>GET /v1/ingredient-categories</code> and <code>POST /v1/ingredient-categories</code>.</li>
<li>Endpoint count: 31 → 33.</li>
</ul>
</div>
<div class="toc">
<a href="#stack">Stack</a>
<a href="#conventions">Conventions</a>
<a href="#project">Project structure</a>
<a href="#auth-endpoints">Auth &amp; households</a>
<a href="#recipe-endpoints">Recipes</a>
<a href="#planning-endpoints">Planning</a>
<a href="#shopping-endpoints">Shopping</a>
<a href="#pantry-endpoints">Pantry</a>
<a href="#admin-endpoints">Admin</a>
<a href="#journey-mapping">Journey → API map</a>
<a href="#security">Security</a>
<a href="#phases">Build phases</a>
</div>
<!-- ═══ STACK ═══ -->
<div class="section" id="stack">
<div class="section-title">Stack — boring, predictable, yours</div>
<div class="prose">Everything runs on one machine. No managed services, no per-request pricing, no vendor lock-in. A single JAR, a single database, a single deployment target.</div>
<div class="stack-grid">
<div class="stack-card">
<h4>API framework</h4>
<div class="tool">Spring Boot 4.0</div>
<p>Spring Framework 7 underneath. Modular starters (smaller JARs). JSpecify null-safety. Built-in API versioning. Java 17+ baseline, first-class Java 25 support. Embedded Tomcat — one executable JAR.</p>
</div>
<div class="stack-card">
<h4>Database</h4>
<div class="tool">PostgreSQL 16</div>
<p>Same machine. 1:1 mapping to JPA entities. Flyway for migrations (Atlas's SQL directly). HikariCP connection pool built in.</p>
</div>
<div class="stack-card">
<h4>Auth</h4>
<div class="tool">Spring Security 7 (sessions)</div>
<p>Default session-based auth. HttpOnly + Secure + SameSite=Lax cookie. No tokens, no refresh flow, no extra libraries. bcrypt password hashing built in.</p>
</div>
</div>
<div class="summary-grid">
<div class="summary-card"><div class="num" style="color:var(--green);">33</div><div class="label">REST endpoints</div></div>
<div class="summary-card"><div class="num" style="color:var(--purple);">18</div><div class="label">JPA entities</div></div>
<div class="summary-card"><div class="num" style="color:var(--yellow-text);">6</div><div class="label">Domains</div></div>
<div class="summary-card"><div class="num" style="color:var(--orange);">1</div><div class="label">Deployable JAR</div></div>
<div class="summary-card"><div class="num" style="color:var(--red);">0</div><div class="label">External services</div></div>
</div>
<div class="callout">
<h4>Key dependencies</h4>
<p>
<span class="pkg">spring-boot-starter-web</span> REST + Jackson ·
<span class="pkg">spring-boot-starter-data-jpa</span> Hibernate + repos ·
<span class="pkg">spring-boot-starter-security</span> session auth + CSRF ·
<span class="pkg">spring-boot-starter-validation</span> Bean Validation ·
<span class="pkg">flyway-core</span> DB migrations ·
<span class="pkg">postgresql</span> JDBC driver ·
<span class="pkg">springdoc-openapi</span> Swagger UI
</p>
</div>
</div>
<!-- ═══ CONVENTIONS ═══ -->
<div class="section" id="conventions">
<div class="section-title">API conventions</div>
<div class="code-block">
<div class="code-block-head"><span>Base URL · Headers · Naming</span></div>
<pre><span class="str">https://yourapp.com/v1</span> <span class="cmt">— frontend + API on same domain</span>
<span class="key">Cookie</span>: JSESSIONID=... <span class="cmt">— automatic (session auth)</span>
<span class="key">X-XSRF-TOKEN</span>: ... <span class="cmt">— CSRF (POST/PUT/PATCH/DELETE)</span>
<span class="key">Content-Type</span>: application/json <span class="cmt">— request bodies</span>
<span class="cmt">URLs: kebab-case /v1/week-plans/{id}/slots</span>
<span class="cmt">JSON: camelCase cookTimeMin, isChildFriendly</span>
<span class="cmt">Java: camelCase DB cols: snake_case</span></pre>
</div>
<div class="code-block">
<div class="code-block-head"><span>Response envelopes</span></div>
<pre><span class="cmt">// Success</span>
{ <span class="key">"status"</span>: <span class="str">"success"</span>, <span class="key">"data"</span>: { ... }, <span class="key">"meta"</span>: { <span class="key">"pagination"</span>: { ... } } }
<span class="cmt">// Error</span>
{ <span class="key">"status"</span>: <span class="str">"error"</span>, <span class="key">"error"</span>: { <span class="key">"code"</span>: <span class="str">"VALIDATION_ERROR"</span>, <span class="key">"message"</span>: <span class="str">"..."</span>, <span class="key">"details"</span>: [...] } }
<span class="cmt">// Pagination &amp; filtering</span>
?limit=20&offset=0&sort=-cookTimeMin&effort=easy&search=pasta</pre>
</div>
<div class="callout green">
<h4>Click any endpoint row to expand request/response bodies</h4>
<p>Every endpoint below has a collapsible detail panel showing the exact JSON shapes. Fields marked <strong>required</strong> are validated with Bean Validation annotations. Fields marked <em>optional</em> can be omitted. All responses are wrapped in the standard envelope above — the examples below show the <code>data</code> payload only.</p>
</div>
</div>
<!-- ═══ PROJECT STRUCTURE ═══ -->
<div class="section" id="project">
<div class="section-title">Project structure — package by domain</div>
<div class="code-block">
<div class="code-block-head"><span>src/main/java/com/recipeapp</span></div>
<pre><span class="str">com.recipeapp</span>
├── <span class="key">auth</span>/ AuthController · AuthService · SecurityConfig · CustomUserDetailsService
├── <span class="key">household</span>/ HouseholdController · InviteController · entities/ · dtos/ · repos/
├── <span class="key">recipe</span>/ RecipeController · IngredientController · TagController · RecipeService
├── <span class="key">planning</span>/ WeekPlanController · SuggestionService · VarietyService · CookingLogController
├── <span class="key">shopping</span>/ ShoppingListController · ShoppingListService
├── <span class="key">pantry</span>/ PantryController
├── <span class="key">admin</span>/ AdminController
└── <span class="key">common</span>/ ApiResponse · ApiError · GlobalExceptionHandler · HouseholdContext</pre>
</div>
</div>
<!-- ═══ AUTH ENDPOINTS ═══ -->
<div class="section" id="auth-endpoints">
<div class="section-title">Auth &amp; household endpoints</div>
<div class="endpoint-group">
<div class="endpoint-group-head"><h3>Authentication</h3><span class="pill" style="background:var(--purple-tint);color:var(--purple-dark);">Auth</span></div>
<div class="ep-head"><span>Method</span><span>Path / Description</span><span>Auth</span><span>Journey</span></div>
<div class="ep" onclick="this.classList.toggle('open')">
<div class="ep-row has-detail">
<span class="method method-post">POST</span>
<div><span class="ep-path">/v1/auth/signup</span><div class="ep-desc">Create account. Sets session cookie. Returns user object.</div></div>
<span class="auth-badge auth-public">public</span>
<span class="journey-ref">J6</span>
</div>
<div class="ep-detail"><div class="ep-detail-grid">
<div class="ep-detail-col"><h5>Request body</h5><pre>{
<span class="req">"email"</span>: "<a href="/cdn-cgi/l/email-protection" class="__cf_email__" data-cfemail="2d5d414c4343485f6d4b4c40444154034e4240">[email&#160;protected]</a>", <span class="cmt">// required, valid email</span>
<span class="req">"password"</span>: "s3cure!Pass", <span class="cmt">// required, min 8 chars</span>
<span class="req">"displayName"</span>: "Sarah" <span class="cmt">// required, 1100 chars</span>
}</pre></div>
<div class="ep-detail-col"><h5>Response · 201 Created</h5><pre>{
"id": "550e8400-...",
"email": "<a href="/cdn-cgi/l/email-protection" class="__cf_email__" data-cfemail="0c7c606d6262697e4c6a6d61656075226f6361">[email&#160;protected]</a>",
"displayName": "Sarah",
"householdId": <span class="cmt">null</span>, <span class="cmt">// no household yet</span>
"householdRole": <span class="cmt">null</span>
}
<span class="cmt">+ Set-Cookie: JSESSIONID=...; HttpOnly; Secure; SameSite=Lax</span></pre></div>
</div></div>
</div>
<div class="ep" onclick="this.classList.toggle('open')">
<div class="ep-row has-detail">
<span class="method method-post">POST</span>
<div><span class="ep-path">/v1/auth/login</span><div class="ep-desc">Login. Creates session. Returns Set-Cookie + user.</div></div>
<span class="auth-badge auth-public">public</span>
<span class="journey-ref">J6</span>
</div>
<div class="ep-detail"><div class="ep-detail-grid">
<div class="ep-detail-col"><h5>Request body</h5><pre>{
<span class="req">"email"</span>: "<a href="/cdn-cgi/l/email-protection" class="__cf_email__" data-cfemail="8fffe3eee1e1eafdcfe9eee2e6e3f6a1ece0e2">[email&#160;protected]</a>",
<span class="req">"password"</span>: "s3cure!Pass"
}</pre></div>
<div class="ep-detail-col"><h5>Response · 200 OK</h5><pre>{
"id": "550e8400-...",
"email": "<a href="/cdn-cgi/l/email-protection" class="__cf_email__" data-cfemail="c7b7aba6a9a9a2b587a1a6aaaeabbee9a4a8aa">[email&#160;protected]</a>",
"displayName": "Sarah",
"householdId": "7c9e6679-...",
"householdRole": "planner",
"systemRole": "user"
}
<span class="cmt">+ Set-Cookie: JSESSIONID=...; HttpOnly; Secure; SameSite=Lax</span></pre></div>
</div></div>
</div>
<div class="ep" onclick="this.classList.toggle('open')">
<div class="ep-row has-detail">
<span class="method method-post">POST</span>
<div><span class="ep-path">/v1/auth/logout</span><div class="ep-desc">Invalidate session. Clears cookie.</div></div>
<span class="auth-badge auth-required">auth</span>
<span></span>
</div>
<div class="ep-detail"><div class="ep-detail-grid">
<div class="ep-detail-col"><h5>Request body</h5><pre><span class="cmt">// empty — no body needed</span></pre></div>
<div class="ep-detail-col"><h5>Response · 204 No Content</h5><pre><span class="cmt">// empty body
+ Set-Cookie: JSESSIONID=; Max-Age=0</span></pre></div>
</div></div>
</div>
<div class="ep" onclick="this.classList.toggle('open')">
<div class="ep-row has-detail">
<span class="method method-get">GET</span>
<div><span class="ep-path">/v1/auth/me</span><div class="ep-desc">Current user + household + role. Every app launch.</div></div>
<span class="auth-badge auth-required">auth</span>
<span></span>
</div>
<div class="ep-detail"><div class="ep-detail-grid">
<div class="ep-detail-col"><h5>Request</h5><pre><span class="cmt">// no body — GET request
// session cookie sent automatically</span></pre></div>
<div class="ep-detail-col"><h5>Response · 200 OK</h5><pre>{
"id": "550e8400-...",
"email": "<a href="/cdn-cgi/l/email-protection" class="__cf_email__" data-cfemail="89f9e5e8e7e7ecfbc9efe8e4e0e5f0a7eae6e4">[email&#160;protected]</a>",
"displayName": "Sarah",
"householdId": "7c9e6679-...",
"householdName": "Smith family",
"householdRole": "planner",
"systemRole": "user"
}</pre></div>
</div></div>
</div>
<div class="ep" onclick="this.classList.toggle('open')">
<div class="ep-row has-detail">
<span class="method method-patch">PATCH</span>
<div><span class="ep-path">/v1/auth/me</span><div class="ep-desc">Update own displayName or password. Screen E1.</div></div>
<span class="auth-badge auth-required">auth</span>
<span></span>
</div>
<div class="ep-detail"><div class="ep-detail-grid">
<div class="ep-detail-col"><h5>Request body</h5><pre>{
<span class="opt">"displayName"</span>: "Sarah S.", <span class="cmt">// optional</span>
<span class="opt">"currentPassword"</span>: "old...", <span class="cmt">// required if changing pw</span>
<span class="opt">"newPassword"</span>: "new..." <span class="cmt">// optional, min 8 chars</span>
}</pre></div>
<div class="ep-detail-col"><h5>Response · 200 OK</h5><pre>{
"id": "550e8400-...",
"email": "<a href="/cdn-cgi/l/email-protection" class="__cf_email__" data-cfemail="cabaa6aba4a4afb88aacaba7a3a6b3e4a9a5a7">[email&#160;protected]</a>",
"displayName": "Sarah S."
}</pre></div>
</div></div>
</div>
</div>
<div class="endpoint-group">
<div class="endpoint-group-head"><h3>Households &amp; members</h3><span class="pill" style="background:var(--purple-tint);color:var(--purple-dark);">Auth</span></div>
<div class="ep-head"><span>Method</span><span>Path / Description</span><span>Auth</span><span>Journey</span></div>
<div class="ep" onclick="this.classList.toggle('open')">
<div class="ep-row has-detail">
<span class="method method-post">POST</span>
<div><span class="ep-path">/v1/households</span><div class="ep-desc">Create household + planner role + seed staples &amp; tags. @Transactional.</div></div>
<span class="auth-badge auth-required">auth</span>
<span class="journey-ref">J6</span>
</div>
<div class="ep-detail"><div class="ep-detail-grid">
<div class="ep-detail-col"><h5>Request body</h5><pre>{
<span class="req">"name"</span>: "Smith family" <span class="cmt">// required, 1100 chars</span>
}
<span class="cmt">// Seeds ~20 default staple ingredients
// Seeds default tags (protein types, dietary)
// Adds current user as planner</span></pre></div>
<div class="ep-detail-col"><h5>Response · 201 Created</h5><pre>{
"id": "7c9e6679-...",
"name": "Smith family",
"members": [
{
"userId": "550e8400-...",
"displayName": "Sarah",
"role": "planner",
"joinedAt": "2026-04-01T10:00:00Z"
}
]
}</pre></div>
</div></div>
</div>
<div class="ep" onclick="this.classList.toggle('open')">
<div class="ep-row has-detail">
<span class="method method-get">GET</span>
<div><span class="ep-path">/v1/households/mine</span><div class="ep-desc">Current user's household with members. Screen E2.</div></div>
<span class="auth-badge auth-required">auth</span>
<span class="journey-ref">J6</span>
</div>
<div class="ep-detail"><div class="ep-detail-grid">
<div class="ep-detail-col"><h5>Request</h5><pre><span class="cmt">// no body — GET</span></pre></div>
<div class="ep-detail-col"><h5>Response · 200 OK</h5><pre>{
"id": "7c9e6679-...",
"name": "Smith family",
"members": [
{ "userId": "550e...", "displayName": "Sarah",
"role": "planner", "joinedAt": "..." },
{ "userId": "661f...", "displayName": "Tom",
"role": "member", "joinedAt": "..." }
]
}</pre></div>
</div></div>
</div>
<div class="ep" onclick="this.classList.toggle('open')">
<div class="ep-row has-detail">
<span class="method method-post">POST</span>
<div><span class="ep-path">/v1/households/mine/invites</span><div class="ep-desc">Generate invite code. Expires 48h.</div></div>
<span class="auth-badge auth-planner">planner</span>
<span class="journey-ref">J6</span>
</div>
<div class="ep-detail"><div class="ep-detail-grid">
<div class="ep-detail-col"><h5>Request body</h5><pre><span class="cmt">// empty — server generates the code</span></pre></div>
<div class="ep-detail-col"><h5>Response · 201 Created</h5><pre>{
"inviteCode": "ABC12XYZ",
"shareUrl": "https://yourapp.com/join/ABC12XYZ",
"expiresAt": "2026-04-03T10:00:00Z"
}</pre></div>
</div></div>
</div>
<div class="ep" onclick="this.classList.toggle('open')">
<div class="ep-row has-detail">
<span class="method method-post">POST</span>
<div><span class="ep-path">/v1/invites/<span class="param">{code}</span>/accept</span><div class="ep-desc">Accept invite → join as member.</div></div>
<span class="auth-badge auth-required">auth</span>
<span class="journey-ref">J6</span>
</div>
<div class="ep-detail"><div class="ep-detail-grid">
<div class="ep-detail-col"><h5>Request</h5><pre><span class="cmt">// code is in the URL path
// no request body needed</span></pre></div>
<div class="ep-detail-col"><h5>Response · 200 OK</h5><pre>{
"householdId": "7c9e6679-...",
"householdName": "Smith family",
"role": "member"
}
<span class="cmt">// 409 if code already used
// 422 if code expired
// 409 if user already in a household</span></pre></div>
</div></div>
</div>
<div class="ep" onclick="this.classList.toggle('open')">
<div class="ep-row has-detail">
<span class="method method-get">GET</span>
<div><span class="ep-path">/v1/households/mine/members</span><div class="ep-desc">List members with names and roles.</div></div>
<span class="auth-badge auth-required">auth</span>
<span class="journey-ref">J6</span>
</div>
<div class="ep-detail"><div class="ep-detail-grid">
<div class="ep-detail-col"><h5>Request</h5><pre><span class="cmt">// no body — GET</span></pre></div>
<div class="ep-detail-col"><h5>Response · 200 OK</h5><pre>[
{ "userId": "550e...", "displayName": "Sarah",
"role": "planner", "joinedAt": "..." },
{ "userId": "661f...", "displayName": "Tom",
"role": "member", "joinedAt": "..." }
]</pre></div>
</div></div>
</div>
</div>
</div>
<!-- ═══ RECIPE ENDPOINTS ═══ -->
<div class="section" id="recipe-endpoints">
<div class="section-title">Recipe endpoints</div>
<div class="endpoint-group">
<div class="endpoint-group-head"><h3>Recipes</h3><span class="pill" style="background:var(--green-tint);color:var(--green-dark);">Recipe</span></div>
<div class="ep-head"><span>Method</span><span>Path / Description</span><span>Auth</span><span>Journey</span></div>
<div class="ep" onclick="this.classList.toggle('open')">
<div class="ep-row has-detail">
<span class="method method-get">GET</span>
<div><span class="ep-path">/v1/recipes</span><div class="ep-desc">List recipes (B1). Summary fields. ?search, ?effort, ?isChildFriendly, ?sort, ?limit, ?offset.</div></div>
<span class="auth-badge auth-planner">planner</span>
<span class="journey-ref">J1</span><span class="journey-ref">J2</span>
</div>
<div class="ep-detail"><div class="ep-detail-grid">
<div class="ep-detail-col"><h5>Query parameters</h5><pre>?search=pasta <span class="cmt">// ILIKE on name</span>
?effort=easy <span class="cmt">// exact match</span>
?isChildFriendly=true <span class="cmt">// boolean</span>
?cookTimeMin.lte=30 <span class="cmt">// ≤ 30 minutes</span>
?sort=-cookTimeMin <span class="cmt">// descending</span>
?limit=20&offset=0 <span class="cmt">// pagination</span></pre></div>
<div class="ep-detail-col"><h5>Response · 200 OK</h5><pre>{
"data": [
{
"id": "a1b2c3d4-...",
"name": "Spaghetti Bolognese",
"serves": 4,
"cookTimeMin": 45,
"effort": "medium",
"isChildFriendly": true,
"heroImageUrl": "/uploads/recipes/a1b2.jpg"
}
],
"meta": {
"pagination": {
"total": 47, "limit": 20,
"offset": 0, "hasMore": true
}
}
}</pre></div>
</div></div>
</div>
<div class="ep" onclick="this.classList.toggle('open')">
<div class="ep-row has-detail">
<span class="method method-get">GET</span>
<div><span class="ep-path">/v1/recipes/<span class="param">{id}</span></span><div class="ep-desc">Full detail (B2). Ingredients, steps, tags. @EntityGraph — one query.</div></div>
<span class="auth-badge auth-planner">planner</span>
<span class="journey-ref">J3</span>
</div>
<div class="ep-detail"><div class="ep-detail-grid">
<div class="ep-detail-col"><h5>Request</h5><pre><span class="cmt">// no body — GET
// {id} = recipe UUID</span></pre></div>
<div class="ep-detail-col"><h5>Response · 200 OK</h5><pre>{
"id": "a1b2c3d4-...",
"name": "Spaghetti Bolognese",
"serves": 4,
"cookTimeMin": 45,
"effort": "medium",
"isChildFriendly": true,
"heroImageUrl": "/uploads/recipes/a1b2.jpg",
"ingredients": [
{ "ingredientId": "f1e2-...",
"name": "spaghetti",
"category": { "id": "cat-01-...", "name": "pasta" },
"quantity": 400, "unit": "g", "sortOrder": 1 },
{ "ingredientId": "d3c4-...",
"name": "ground beef",
"category": { "id": "cat-02-...", "name": "meat" },
"quantity": 500, "unit": "g", "sortOrder": 2 }
],
"steps": [
{ "stepNumber": 1,
"instruction": "Boil water and cook pasta." },
{ "stepNumber": 2,
"instruction": "Brown the beef in a pan." }
],
"tags": [
{ "id": "t1-...", "name": "beef", "tagType": "protein" },
{ "id": "t2-...", "name": "Italian", "tagType": "cuisine" }
]
}</pre></div>
</div></div>
</div>
<div class="ep" onclick="this.classList.toggle('open')">
<div class="ep-row has-detail">
<span class="method method-post">POST</span>
<div><span class="ep-path">/v1/recipes</span><div class="ep-desc">Create with nested ingredients, steps, tag IDs. @Transactional.</div></div>
<span class="auth-badge auth-planner">planner</span>
<span class="journey-ref">J1</span>
</div>
<div class="ep-detail"><div class="ep-detail-grid">
<div class="ep-detail-col"><h5>Request body</h5><pre>{
<span class="req">"name"</span>: "Spaghetti Bolognese",
<span class="req">"serves"</span>: 4, <span class="cmt">// 120</span>
<span class="req">"cookTimeMin"</span>: 45, <span class="cmt">// ≥ 0</span>
<span class="req">"effort"</span>: "medium", <span class="cmt">// easy|medium|hard</span>
<span class="opt">"isChildFriendly"</span>: true, <span class="cmt">// default false</span>
<span class="opt">"heroImageUrl"</span>: null,
<span class="req">"ingredients"</span>: [
{ <span class="req">"ingredientId"</span>: "f1e2-...", <span class="cmt">// existing id</span>
<span class="req">"quantity"</span>: 400,
<span class="req">"unit"</span>: "g",
<span class="opt">"sortOrder"</span>: 1 },
{ <span class="opt">"newIngredientName"</span>: "pancetta",<span class="cmt">// OR create new</span>
<span class="req">"quantity"</span>: 100,
<span class="req">"unit"</span>: "g",
<span class="opt">"sortOrder"</span>: 2 }
],
<span class="opt">"steps"</span>: [
{ <span class="req">"stepNumber"</span>: 1,
<span class="req">"instruction"</span>: "Boil water..." }
],
<span class="req">"tagIds"</span>: ["t1-...", "t2-..."] <span class="cmt">// ≥ 2 (effort + 1 cat)</span>
}</pre></div>
<div class="ep-detail-col"><h5>Response · 201 Created</h5><pre><span class="cmt">// full RecipeDetail (same shape as GET /recipes/{id})</span>
{
"id": "a1b2c3d4-...",
"name": "Spaghetti Bolognese",
...
}
<span class="cmt">+ Location: /v1/recipes/a1b2c3d4-...</span></pre></div>
</div><div class="ep-detail-note">Ingredients can reference an existing ingredientId or create a new ingredient inline via newIngredientName. Tags must include at least the effort level tag plus one category tag.</div></div>
</div>
<div class="ep" onclick="this.classList.toggle('open')">
<div class="ep-row has-detail">
<span class="method method-put">PUT</span>
<div><span class="ep-path">/v1/recipes/<span class="param">{id}</span></span><div class="ep-desc">Full replace. Same shape as POST body. Replaces all children.</div></div>
<span class="auth-badge auth-planner">planner</span>
<span class="journey-ref">J1</span>
</div>
<div class="ep-detail"><div class="ep-detail-grid">
<div class="ep-detail-col"><h5>Request body</h5><pre><span class="cmt">// identical shape to POST /v1/recipes
// sends the complete new state
// server deletes old children, inserts new</span>
{
"name": "Spaghetti Bolognese (updated)",
"serves": 4,
"cookTimeMin": 40,
"effort": "medium",
"ingredients": [ ... ],
"steps": [ ... ],
"tagIds": [ ... ]
}</pre></div>
<div class="ep-detail-col"><h5>Response · 200 OK</h5><pre><span class="cmt">// full RecipeDetail with updated data</span>
{
"id": "a1b2c3d4-...",
"name": "Spaghetti Bolognese (updated)",
...
}</pre></div>
</div></div>
</div>
<div class="ep" onclick="this.classList.toggle('open')">
<div class="ep-row has-detail">
<span class="method method-delete">DELETE</span>
<div><span class="ep-path">/v1/recipes/<span class="param">{id}</span></span><div class="ep-desc">Soft delete (sets deletedAt).</div></div>
<span class="auth-badge auth-planner">planner</span>
<span></span>
</div>
<div class="ep-detail"><div class="ep-detail-grid">
<div class="ep-detail-col"><h5>Request</h5><pre><span class="cmt">// no body — DELETE</span></pre></div>
<div class="ep-detail-col"><h5>Response · 204 No Content</h5><pre><span class="cmt">// empty body</span></pre></div>
</div></div>
</div>
</div>
<div class="endpoint-group">
<div class="endpoint-group-head"><h3>Ingredients &amp; tags</h3><span class="pill" style="background:var(--green-tint);color:var(--green-dark);">Recipe</span></div>
<div class="ep-head"><span>Method</span><span>Path / Description</span><span>Auth</span><span>Journey</span></div>
<div class="ep" onclick="this.classList.toggle('open')">
<div class="ep-row has-detail">
<span class="method method-get">GET</span>
<div><span class="ep-path">/v1/ingredients?search=<span class="param">{q}</span></span><div class="ep-desc">Autocomplete by name. Limit 10.</div></div>
<span class="auth-badge auth-required">auth</span>
<span class="journey-ref">J1</span>
</div>
<div class="ep-detail"><div class="ep-detail-grid">
<div class="ep-detail-col"><h5>Query params</h5><pre>?search=chick <span class="cmt">// ILIKE '%chick%'</span>
?isStaple=true <span class="cmt">// filter staples (A3/D3)</span></pre></div>
<div class="ep-detail-col"><h5>Response · 200 OK</h5><pre>[
{ "id": "f1e2-...", "name": "chicken breast",
"category": { "id": "cat-02-...", "name": "meat" },
"isStaple": false },
{ "id": "g3h4-...", "name": "chickpeas",
"category": { "id": "cat-05-...", "name": "legumes" },
"isStaple": false }
]</pre></div>
</div></div>
</div>
<div class="ep" onclick="this.classList.toggle('open')">
<div class="ep-row has-detail">
<span class="method method-patch">PATCH</span>
<div><span class="ep-path">/v1/ingredients/<span class="param">{id}</span></span><div class="ep-desc">Toggle isStaple. Update name or categoryId.</div></div>
<span class="auth-badge auth-planner">planner</span>
<span class="journey-ref">J6</span>
</div>
<div class="ep-detail"><div class="ep-detail-grid">
<div class="ep-detail-col"><h5>Request body</h5><pre>{
<span class="opt">"isStaple"</span>: true,
<span class="opt">"name"</span>: "olive oil",
<span class="opt">"categoryId"</span>: "cat-03-..." <span class="cmt">// FK → ingredient_category</span>
}</pre></div>
<div class="ep-detail-col"><h5>Response · 200 OK</h5><pre>{ "id": "f1e2-...", "name": "olive oil",
"category": { "id": "cat-03-...", "name": "oil" },
"isStaple": true }</pre></div>
</div></div>
</div>
<div class="ep" onclick="this.classList.toggle('open')">
<div class="ep-row has-detail">
<span class="method method-get">GET</span>
<div><span class="ep-path">/v1/tags</span><div class="ep-desc">All tags grouped by tagType. For B3 picker.</div></div>
<span class="auth-badge auth-required">auth</span>
<span class="journey-ref">J1</span>
</div>
<div class="ep-detail"><div class="ep-detail-grid">
<div class="ep-detail-col"><h5>Request</h5><pre><span class="cmt">// no body — GET</span></pre></div>
<div class="ep-detail-col"><h5>Response · 200 OK</h5><pre>[
{ "id": "t1-...", "name": "chicken", "tagType": "protein" },
{ "id": "t2-...", "name": "beef", "tagType": "protein" },
{ "id": "t3-...", "name": "vegetarian", "tagType": "dietary" },
{ "id": "t4-...", "name": "Italian", "tagType": "cuisine" }
]</pre></div>
</div></div>
</div>
<div class="ep" onclick="this.classList.toggle('open')">
<div class="ep-row has-detail">
<span class="method method-post">POST</span>
<div><span class="ep-path">/v1/tags</span><div class="ep-desc">Create custom tag.</div></div>
<span class="auth-badge auth-planner">planner</span>
<span class="journey-ref">J1</span>
</div>
<div class="ep-detail"><div class="ep-detail-grid">
<div class="ep-detail-col"><h5>Request body</h5><pre>{
<span class="req">"name"</span>: "Thai",
<span class="req">"tagType"</span>: "cuisine" <span class="cmt">// protein|dietary|cuisine</span>
}</pre></div>
<div class="ep-detail-col"><h5>Response · 201 Created</h5><pre>{ "id": "t9-...", "name": "Thai",
"tagType": "cuisine" }</pre></div>
</div></div>
</div>
</div>
<div class="endpoint-group">
<div class="endpoint-group-head"><h3>Ingredient categories</h3><span class="pill" style="background:var(--green-tint);color:var(--green-dark);">Recipe</span></div>
<div class="ep-head"><span>Method</span><span>Path / Description</span><span>Auth</span><span>Journey</span></div>
<div class="ep" onclick="this.classList.toggle('open')">
<div class="ep-row has-detail">
<span class="method method-get">GET</span>
<div><span class="ep-path">/v1/ingredient-categories</span><div class="ep-desc">List all ingredient categories. Used in B3 recipe form and for shopping list grouping (D1).</div></div>
<span class="auth-badge auth-required">auth</span>
<span class="journey-ref">J1</span><span class="journey-ref">J5</span>
</div>
<div class="ep-detail"><div class="ep-detail-grid">
<div class="ep-detail-col"><h5>Request</h5><pre><span class="cmt">// no body — GET
// scoped to user's household automatically</span></pre></div>
<div class="ep-detail-col"><h5>Response · 200 OK</h5><pre>[
{ "id": "cat-01-...", "name": "pasta" },
{ "id": "cat-02-...", "name": "meat" },
{ "id": "cat-03-...", "name": "oil" },
{ "id": "cat-04-...", "name": "dairy" },
{ "id": "cat-05-...", "name": "legumes" },
{ "id": "cat-06-...", "name": "vegetable" },
{ "id": "cat-07-...", "name": "spice" }
]
<span class="cmt">// seeded on household creation
// ordered alphabetically by name</span></pre></div>
</div></div>
</div>
<div class="ep" onclick="this.classList.toggle('open')">
<div class="ep-row has-detail">
<span class="method method-post">POST</span>
<div><span class="ep-path">/v1/ingredient-categories</span><div class="ep-desc">Create custom category. Planner can extend the default list.</div></div>
<span class="auth-badge auth-planner">planner</span>
<span class="journey-ref">J1</span>
</div>
<div class="ep-detail"><div class="ep-detail-grid">
<div class="ep-detail-col"><h5>Request body</h5><pre>{
<span class="req">"name"</span>: "frozen" <span class="cmt">// required, 150 chars</span>
}
<span class="cmt">// 409 if name already exists in household</span></pre></div>
<div class="ep-detail-col"><h5>Response · 201 Created</h5><pre>{
"id": "cat-08-...",
"name": "frozen"
}</pre></div>
</div></div>
</div>
</div>
</div>
<!-- ═══ PLANNING ENDPOINTS ═══ -->
<div class="section" id="planning-endpoints">
<div class="section-title">Planning endpoints</div>
<div class="endpoint-group">
<div class="endpoint-group-head"><h3>Week plans &amp; slots</h3><span class="pill" style="background:var(--yellow-tint);color:var(--yellow-text);">Planning</span></div>
<div class="ep-head"><span>Method</span><span>Path / Description</span><span>Auth</span><span>Journey</span></div>
<div class="ep" onclick="this.classList.toggle('open')">
<div class="ep-row has-detail">
<span class="method method-get">GET</span>
<div><span class="ep-path">/v1/week-plans?weekStart=<span class="param">{date}</span></span><div class="ep-desc">Week plan + slots + recipe summaries. C1 home screen.</div></div>
<span class="auth-badge auth-required">auth</span>
<span class="journey-ref">J2</span><span class="journey-ref">J3</span>
</div>
<div class="ep-detail"><div class="ep-detail-grid">
<div class="ep-detail-col"><h5>Query params</h5><pre>?weekStart=2026-04-06 <span class="cmt">// ISO date, must be Monday</span></pre></div>
<div class="ep-detail-col"><h5>Response · 200 OK</h5><pre>{
"id": "wp-1234-...",
"weekStart": "2026-04-06",
"status": "draft",
"confirmedAt": null,
"slots": [
{ "id": "sl-01-...",
"slotDate": "2026-04-06",
"recipe": {
"id": "a1b2-...",
"name": "Spaghetti Bolognese",
"effort": "medium",
"cookTimeMin": 45,
"heroImageUrl": "/uploads/recipes/a1b2.jpg"
}
},
{ "id": "sl-02-...",
"slotDate": "2026-04-07",
"recipe": null <span class="cmt">// empty day</span>
}
]
}
<span class="cmt">// 404 if no plan exists for that week yet</span></pre></div>
</div></div>
</div>
<div class="ep" onclick="this.classList.toggle('open')">
<div class="ep-row has-detail">
<span class="method method-post">POST</span>
<div><span class="ep-path">/v1/week-plans</span><div class="ep-desc">Create week plan (draft).</div></div>
<span class="auth-badge auth-planner">planner</span>
<span class="journey-ref">J2</span>
</div>
<div class="ep-detail"><div class="ep-detail-grid">
<div class="ep-detail-col"><h5>Request body</h5><pre>{
<span class="req">"weekStart"</span>: "2026-04-06" <span class="cmt">// must be a Monday</span>
}</pre></div>
<div class="ep-detail-col"><h5>Response · 201 Created</h5><pre>{
"id": "wp-1234-...",
"weekStart": "2026-04-06",
"status": "draft",
"slots": []
}
<span class="cmt">// 409 if plan already exists for that week</span></pre></div>
</div></div>
</div>
<div class="ep" onclick="this.classList.toggle('open')">
<div class="ep-row has-detail">
<span class="method method-post">POST</span>
<div><span class="ep-path">/v1/week-plans/<span class="param">{id}</span>/slots</span><div class="ep-desc">Assign recipe to a day.</div></div>
<span class="auth-badge auth-planner">planner</span>
<span class="journey-ref">J2</span>
</div>
<div class="ep-detail"><div class="ep-detail-grid">
<div class="ep-detail-col"><h5>Request body</h5><pre>{
<span class="req">"slotDate"</span>: "2026-04-07", <span class="cmt">// within plan week</span>
<span class="req">"recipeId"</span>: "a1b2c3d4-..."
}</pre></div>
<div class="ep-detail-col"><h5>Response · 201 Created</h5><pre>{
"id": "sl-03-...",
"slotDate": "2026-04-07",
"recipe": {
"id": "a1b2c3d4-...",
"name": "Spaghetti Bolognese",
"effort": "medium",
"cookTimeMin": 45,
"heroImageUrl": "..."
}
}</pre></div>
</div></div>
</div>
<div class="ep" onclick="this.classList.toggle('open')">
<div class="ep-row has-detail">
<span class="method method-patch">PATCH</span>
<div><span class="ep-path">/v1/week-plans/<span class="param">{planId}</span>/slots/<span class="param">{slotId}</span></span><div class="ep-desc">Swap recipe. The ≤ 3-tap mid-week swap.</div></div>
<span class="auth-badge auth-planner">planner</span>
<span class="journey-ref">J4</span>
</div>
<div class="ep-detail"><div class="ep-detail-grid">
<div class="ep-detail-col"><h5>Request body</h5><pre>{
<span class="req">"recipeId"</span>: "x9y8z7-..." <span class="cmt">// new recipe</span>
}</pre></div>
<div class="ep-detail-col"><h5>Response · 200 OK</h5><pre>{
"id": "sl-03-...",
"slotDate": "2026-04-07",
"recipe": {
"id": "x9y8z7-...",
"name": "Quick Stir Fry",
"effort": "easy", "cookTimeMin": 15, ...
}
}</pre></div>
</div></div>
</div>
<div class="ep" onclick="this.classList.toggle('open')">
<div class="ep-row has-detail">
<span class="method method-delete">DELETE</span>
<div><span class="ep-path">/v1/week-plans/<span class="param">{planId}</span>/slots/<span class="param">{slotId}</span></span><div class="ep-desc">Clear a day slot.</div></div>
<span class="auth-badge auth-planner">planner</span>
<span class="journey-ref">J4</span>
</div>
<div class="ep-detail"><div class="ep-detail-grid">
<div class="ep-detail-col"><h5>Request</h5><pre><span class="cmt">// no body — DELETE</span></pre></div>
<div class="ep-detail-col"><h5>Response · 204 No Content</h5><pre><span class="cmt">// empty body</span></pre></div>
</div></div>
</div>
<div class="ep" onclick="this.classList.toggle('open')">
<div class="ep-row has-detail">
<span class="method method-post">POST</span>
<div><span class="ep-path">/v1/week-plans/<span class="param">{id}</span>/confirm</span><div class="ep-desc">Confirm plan. Validates ≥1 slot. 422 if already confirmed.</div></div>
<span class="auth-badge auth-planner">planner</span>
<span class="journey-ref">J2</span>
</div>
<div class="ep-detail"><div class="ep-detail-grid">
<div class="ep-detail-col"><h5>Request body</h5><pre><span class="cmt">// empty — action endpoint</span></pre></div>
<div class="ep-detail-col"><h5>Response · 200 OK</h5><pre>{
"id": "wp-1234-...",
"status": "confirmed",
"confirmedAt": "2026-04-05T18:30:00Z"
}
<span class="cmt">// 422 if no slots filled
// 422 if already confirmed</span></pre></div>
</div></div>
</div>
</div>
<div class="endpoint-group">
<div class="endpoint-group-head"><h3>Suggestions &amp; variety</h3><span class="pill" style="background:var(--yellow-tint);color:var(--yellow-text);">Planning</span></div>
<div class="ep-head"><span>Method</span><span>Path / Description</span><span>Auth</span><span>Journey</span></div>
<div class="ep" onclick="this.classList.toggle('open')">
<div class="ep-row has-detail">
<span class="method method-get">GET</span>
<div><span class="ep-path">/v1/week-plans/<span class="param">{id}</span>/suggestions?slotDate=<span class="param">{date}</span></span><div class="ep-desc">35 suggestions. Filters: ingredients (3d), protein, effort.</div></div>
<span class="auth-badge auth-planner">planner</span>
<span class="journey-ref">J2</span><span class="journey-ref">J4</span>
</div>
<div class="ep-detail"><div class="ep-detail-grid">
<div class="ep-detail-col"><h5>Query params</h5><pre>?slotDate=2026-04-08 <span class="cmt">// target day</span></pre></div>
<div class="ep-detail-col"><h5>Response · 200 OK</h5><pre>{
"suggestions": [
{
"recipe": {
"id": "r1-...", "name": "Quick Stir Fry",
"effort": "easy", "cookTimeMin": 15,
"heroImageUrl": "..."
},
"fitReasons": [
"not_cooked_recently",
"effort_balance",
"no_protein_repeat"
],
"warnings": []
},
{
"recipe": { "id": "r2-...", "name": "Fish Tacos", ... },
"fitReasons": ["effort_balance"],
"warnings": ["shares_ingredient_with_yesterday"]
}
]
}</pre></div>
</div></div>
</div>
<div class="ep" onclick="this.classList.toggle('open')">
<div class="ep-row has-detail">
<span class="method method-get">GET</span>
<div><span class="ep-path">/v1/week-plans/<span class="param">{id}</span>/variety-score</span><div class="ep-desc">Computed score (010) + breakdown.</div></div>
<span class="auth-badge auth-required">auth</span>
<span class="journey-ref">J2</span>
</div>
<div class="ep-detail"><div class="ep-detail-grid">
<div class="ep-detail-col"><h5>Request</h5><pre><span class="cmt">// no body — GET</span></pre></div>
<div class="ep-detail-col"><h5>Response · 200 OK</h5><pre>{
"score": 7.5,
"ingredientOverlaps": [
{ "ingredientName": "onion",
"days": ["2026-04-06", "2026-04-07"] }
],
"proteinRepeats": [],
"effortBalance": {
"easy": 2, "medium": 3, "hard": 2
}
}</pre></div>
</div></div>
</div>
<div class="ep" onclick="this.classList.toggle('open')">
<div class="ep-row has-detail">
<span class="method method-post">POST</span>
<div><span class="ep-path">/v1/cooking-logs</span><div class="ep-desc">Mark meal cooked. Immutable INSERT.</div></div>
<span class="auth-badge auth-planner">planner</span>
<span class="journey-ref">J3</span>
</div>
<div class="ep-detail"><div class="ep-detail-grid">
<div class="ep-detail-col"><h5>Request body</h5><pre>{
<span class="req">"recipeId"</span>: "a1b2c3d4-...",
<span class="opt">"cookedOn"</span>: "2026-04-07" <span class="cmt">// default: today</span>
}</pre></div>
<div class="ep-detail-col"><h5>Response · 201 Created</h5><pre>{
"id": "cl-01-...",
"recipeId": "a1b2c3d4-...",
"cookedOn": "2026-04-07",
"cookedBy": "550e8400-..."
}</pre></div>
</div></div>
</div>
<div class="ep" onclick="this.classList.toggle('open')">
<div class="ep-row has-detail">
<span class="method method-get">GET</span>
<div><span class="ep-path">/v1/cooking-logs?limit=30</span><div class="ep-desc">Recent history (desc by cookedOn).</div></div>
<span class="auth-badge auth-required">auth</span>
<span class="journey-ref">J2</span>
</div>
<div class="ep-detail"><div class="ep-detail-grid">
<div class="ep-detail-col"><h5>Query params</h5><pre>?limit=30 <span class="cmt">// default 30</span>
?offset=0</pre></div>
<div class="ep-detail-col"><h5>Response · 200 OK</h5><pre>[
{ "id": "cl-01-...", "recipeId": "a1b2-...",
"recipeName": "Spaghetti Bolognese",
"cookedOn": "2026-04-07",
"cookedBy": "550e8400-..." }
]</pre></div>
</div></div>
</div>
</div>
</div>
<!-- ═══ SHOPPING ENDPOINTS ═══ -->
<div class="section" id="shopping-endpoints">
<div class="section-title">Shopping endpoints</div>
<div class="endpoint-group">
<div class="endpoint-group-head"><h3>Shopping list</h3><span class="pill" style="background:var(--blue-tint);color:var(--blue-dark);">Shopping</span></div>
<div class="ep-head"><span>Method</span><span>Path / Description</span><span>Auth</span><span>Journey</span></div>
<div class="ep" onclick="this.classList.toggle('open')">
<div class="ep-row has-detail">
<span class="method method-post">POST</span>
<div><span class="ep-path">/v1/week-plans/<span class="param">{id}</span>/shopping-list</span><div class="ep-desc">Generate from plan. Merge, sum, filter staples. Draft for preview.</div></div>
<span class="auth-badge auth-planner">planner</span>
<span class="journey-ref">J5</span>
</div>
<div class="ep-detail"><div class="ep-detail-grid">
<div class="ep-detail-col"><h5>Request body</h5><pre><span class="cmt">// empty — generated from the week plan
// server merges ingredients across meals,
// sums quantities, filters staples</span></pre></div>
<div class="ep-detail-col"><h5>Response · 201 Created</h5><pre>{
"id": "shl-01-...",
"weekPlanId": "wp-1234-...",
"status": "draft",
"items": [
{ "id": "si-01-...",
"ingredientId": "f1e2-...",
"name": "spaghetti",
"category": { "id": "cat-01-...", "name": "pasta" },
"quantity": 800, "unit": "g",
"isChecked": false,
"sourceRecipes": ["a1b2-...", "c3d4-..."] },
{ "id": "si-02-...",
"ingredientId": "d3c4-...",
"name": "ground beef",
"category": { "id": "cat-02-...", "name": "meat" },
"quantity": 500, "unit": "g",
"isChecked": false,
"sourceRecipes": ["a1b2-..."] }
]
}</pre></div>
</div></div>
</div>
<div class="ep" onclick="this.classList.toggle('open')">
<div class="ep-row has-detail">
<span class="method method-get">GET</span>
<div><span class="ep-path">/v1/shopping-lists/<span class="param">{id}</span></span><div class="ep-desc">Full list with items. Both roles. Pull-to-refresh target.</div></div>
<span class="auth-badge auth-required">auth</span>
<span class="journey-ref">J5</span>
</div>
<div class="ep-detail"><div class="ep-detail-grid">
<div class="ep-detail-col"><h5>Request</h5><pre><span class="cmt">// no body — GET
// this is the refresh action:
// pull-to-refresh calls this endpoint</span></pre></div>
<div class="ep-detail-col"><h5>Response · 200 OK</h5><pre><span class="cmt">// same shape as POST response above</span>
{
"id": "shl-01-...",
"status": "published",
"items": [ ... ]
}</pre></div>
</div></div>
</div>
<div class="ep" onclick="this.classList.toggle('open')">
<div class="ep-row has-detail">
<span class="method method-post">POST</span>
<div><span class="ep-path">/v1/shopping-lists/<span class="param">{id}</span>/publish</span><div class="ep-desc">Publish (draft → published). Live for members.</div></div>
<span class="auth-badge auth-planner">planner</span>
<span class="journey-ref">J5</span>
</div>
<div class="ep-detail"><div class="ep-detail-grid">
<div class="ep-detail-col"><h5>Request body</h5><pre><span class="cmt">// empty — action endpoint</span></pre></div>
<div class="ep-detail-col"><h5>Response · 200 OK</h5><pre>{
"id": "shl-01-...",
"status": "published",
"publishedAt": "2026-04-06T09:00:00Z"
}
<span class="cmt">// 422 if already published</span></pre></div>
</div></div>
</div>
<div class="ep" onclick="this.classList.toggle('open')">
<div class="ep-row has-detail">
<span class="method method-patch">PATCH</span>
<div><span class="ep-path">/v1/shopping-lists/<span class="param">{listId}</span>/items/<span class="param">{itemId}</span></span><div class="ep-desc">Check/uncheck item. Both roles.</div></div>
<span class="auth-badge auth-required">auth</span>
<span class="journey-ref">J5</span>
</div>
<div class="ep-detail"><div class="ep-detail-grid">
<div class="ep-detail-col"><h5>Request body</h5><pre>{
<span class="req">"isChecked"</span>: true
}</pre></div>
<div class="ep-detail-col"><h5>Response · 200 OK</h5><pre>{
"id": "si-01-...",
"name": "spaghetti",
"isChecked": true,
"checkedBy": "661f-..." <span class="cmt">// who checked it</span>
}
<span class="cmt">// other members see this on next refresh</span></pre></div>
</div></div>
</div>
<div class="ep" onclick="this.classList.toggle('open')">
<div class="ep-row has-detail">
<span class="method method-post">POST</span>
<div><span class="ep-path">/v1/shopping-lists/<span class="param">{id}</span>/items</span><div class="ep-desc">Add custom item. Both roles.</div></div>
<span class="auth-badge auth-required">auth</span>
<span class="journey-ref">J5</span>
</div>
<div class="ep-detail"><div class="ep-detail-grid">
<div class="ep-detail-col"><h5>Request body</h5><pre>{
<span class="opt">"ingredientId"</span>: null, <span class="cmt">// or existing id</span>
<span class="req">"customName"</span>: "Paper towels", <span class="cmt">// if no ingredientId</span>
<span class="opt">"quantity"</span>: 1,
<span class="opt">"unit"</span>: "" <span class="cmt">// blank for countable</span>
}</pre></div>
<div class="ep-detail-col"><h5>Response · 201 Created</h5><pre>{
"id": "si-10-...",
"ingredientId": null,
"name": "Paper towels",
"quantity": 1, "unit": "",
"isChecked": false,
"sourceRecipes": []
}</pre></div>
</div></div>
</div>
<div class="ep" onclick="this.classList.toggle('open')">
<div class="ep-row has-detail">
<span class="method method-delete">DELETE</span>
<div><span class="ep-path">/v1/shopping-lists/<span class="param">{listId}</span>/items/<span class="param">{itemId}</span></span><div class="ep-desc">Remove item. Planner only, pre-publish.</div></div>
<span class="auth-badge auth-planner">planner</span>
<span class="journey-ref">J5</span>
</div>
<div class="ep-detail"><div class="ep-detail-grid">
<div class="ep-detail-col"><h5>Request</h5><pre><span class="cmt">// no body — DELETE</span></pre></div>
<div class="ep-detail-col"><h5>Response · 204 No Content</h5><pre><span class="cmt">// 422 if list is already published</span></pre></div>
</div></div>
</div>
</div>
</div>
<!-- ═══ PANTRY ENDPOINTS ═══ -->
<div class="section" id="pantry-endpoints">
<div class="section-title">Pantry endpoints</div>
<div class="endpoint-group">
<div class="endpoint-group-head"><h3>Pantry items</h3><span class="pill" style="background:var(--orange-tint);color:var(--orange-dark);">Pantry</span></div>
<div class="ep-head"><span>Method</span><span>Path / Description</span><span>Auth</span><span></span></div>
<div class="ep" onclick="this.classList.toggle('open')">
<div class="ep-row has-detail">
<span class="method method-get">GET</span>
<div><span class="ep-path">/v1/pantry-items</span><div class="ep-desc">List items, expiring soonest first.</div></div>
<span class="auth-badge auth-required">auth</span><span></span>
</div>
<div class="ep-detail"><div class="ep-detail-grid">
<div class="ep-detail-col"><h5>Request</h5><pre><span class="cmt">// no body — GET</span></pre></div>
<div class="ep-detail-col"><h5>Response · 200 OK</h5><pre>[
{ "id": "pi-01-...",
"ingredientId": "f1e2-...",
"name": "chicken breast",
"category": { "id": "cat-02-...", "name": "meat" },
"quantity": 500, "unit": "g",
"bestBefore": "2026-04-10",
"openedOn": null }
]</pre></div>
</div></div>
</div>
<div class="ep" onclick="this.classList.toggle('open')">
<div class="ep-row has-detail">
<span class="method method-post">POST</span>
<div><span class="ep-path">/v1/pantry-items</span><div class="ep-desc">Add item.</div></div>
<span class="auth-badge auth-planner">planner</span><span></span>
</div>
<div class="ep-detail"><div class="ep-detail-grid">
<div class="ep-detail-col"><h5>Request body</h5><pre>{
<span class="opt">"ingredientId"</span>: "f1e2-...", <span class="cmt">// or null</span>
<span class="opt">"customName"</span>: null, <span class="cmt">// if no ingredientId</span>
<span class="req">"quantity"</span>: 500,
<span class="req">"unit"</span>: "g",
<span class="opt">"bestBefore"</span>: "2026-04-10",
<span class="opt">"openedOn"</span>: null
}</pre></div>
<div class="ep-detail-col"><h5>Response · 201 Created</h5><pre>{ "id": "pi-02-...",
"ingredientId": "f1e2-...",
"name": "chicken breast",
"quantity": 500, "unit": "g",
"bestBefore": "2026-04-10",
"openedOn": null }</pre></div>
</div></div>
</div>
<div class="ep" onclick="this.classList.toggle('open')">
<div class="ep-row has-detail">
<span class="method method-patch">PATCH</span>
<div><span class="ep-path">/v1/pantry-items/<span class="param">{id}</span></span><div class="ep-desc">Update quantity, bestBefore, openedOn.</div></div>
<span class="auth-badge auth-planner">planner</span><span></span>
</div>
<div class="ep-detail"><div class="ep-detail-grid">
<div class="ep-detail-col"><h5>Request body</h5><pre>{
<span class="opt">"quantity"</span>: 250,
<span class="opt">"openedOn"</span>: "2026-04-07"
}</pre></div>
<div class="ep-detail-col"><h5>Response · 200 OK</h5><pre>{ "id": "pi-02-...", "quantity": 250,
"openedOn": "2026-04-07", ... }</pre></div>
</div></div>
</div>
<div class="ep" onclick="this.classList.toggle('open')">
<div class="ep-row has-detail">
<span class="method method-delete">DELETE</span>
<div><span class="ep-path">/v1/pantry-items/<span class="param">{id}</span></span><div class="ep-desc">Remove consumed/expired item.</div></div>
<span class="auth-badge auth-planner">planner</span><span></span>
</div>
<div class="ep-detail"><div class="ep-detail-grid">
<div class="ep-detail-col"><h5>Request</h5><pre><span class="cmt">// no body</span></pre></div>
<div class="ep-detail-col"><h5>Response · 204 No Content</h5><pre><span class="cmt">// empty</span></pre></div>
</div></div>
</div>
</div>
</div>
<!-- ═══ ADMIN ENDPOINTS ═══ -->
<div class="section" id="admin-endpoints">
<div class="section-title">Admin endpoints</div>
<div class="endpoint-group">
<div class="endpoint-group-head"><h3>Admin user management</h3><span class="pill" style="background:var(--red-tint);color:var(--red-dark);">Admin</span></div>
<div class="ep-head"><span>Method</span><span>Path / Description</span><span>Auth</span><span></span></div>
<div class="ep" onclick="this.classList.toggle('open')">
<div class="ep-row has-detail">
<span class="method method-get">GET</span>
<div><span class="ep-path">/v1/admin/users</span><div class="ep-desc">List all users. Paginated.</div></div>
<span class="auth-badge auth-admin">admin</span><span></span>
</div>
<div class="ep-detail"><div class="ep-detail-grid">
<div class="ep-detail-col"><h5>Query params</h5><pre>?limit=50&offset=0
?search=jane <span class="cmt">// by email or name</span>
?isActive=true</pre></div>
<div class="ep-detail-col"><h5>Response · 200 OK</h5><pre>{
"data": [
{ "id": "550e-...", "email": "<a href="/cdn-cgi/l/email-protection" class="__cf_email__" data-cfemail="21514d404f4f44536147404c484d580f424e4c">[email&#160;protected]</a>",
"displayName": "Sarah", "systemRole": "user",
"isActive": true, "createdAt": "..." }
],
"meta": { "pagination": { "total": 24, ... } }
}</pre></div>
</div></div>
</div>
<div class="ep" onclick="this.classList.toggle('open')">
<div class="ep-row has-detail">
<span class="method method-post">POST</span>
<div><span class="ep-path">/v1/admin/users</span><div class="ep-desc">Create user with temp password + audit log.</div></div>
<span class="auth-badge auth-admin">admin</span><span></span>
</div>
<div class="ep-detail"><div class="ep-detail-grid">
<div class="ep-detail-col"><h5>Request body</h5><pre>{
<span class="req">"email"</span>: "<a href="/cdn-cgi/l/email-protection" class="__cf_email__" data-cfemail="5f313a281f2a2c3a2d713c3032">[email&#160;protected]</a>",
<span class="req">"displayName"</span>: "New User",
<span class="req">"tempPassword"</span>: "Change1Me!",
<span class="opt">"systemRole"</span>: "user" <span class="cmt">// default "user"</span>
}</pre></div>
<div class="ep-detail-col"><h5>Response · 201 Created</h5><pre>{
"id": "new-uuid-...",
"email": "<a href="/cdn-cgi/l/email-protection" class="__cf_email__" data-cfemail="117f746651646274633f727e7c">[email&#160;protected]</a>",
"displayName": "New User",
"systemRole": "user",
"isActive": true,
"mustChangePassword": true
}</pre></div>
</div></div>
</div>
<div class="ep" onclick="this.classList.toggle('open')">
<div class="ep-row has-detail">
<span class="method method-patch">PATCH</span>
<div><span class="ep-path">/v1/admin/users/<span class="param">{id}</span></span><div class="ep-desc">Update user. Audit logged.</div></div>
<span class="auth-badge auth-admin">admin</span><span></span>
</div>
<div class="ep-detail"><div class="ep-detail-grid">
<div class="ep-detail-col"><h5>Request body</h5><pre>{
<span class="opt">"displayName"</span>: "Jane Smith",
<span class="opt">"email"</span>: "<a href="/cdn-cgi/l/email-protection" class="__cf_email__" data-cfemail="3a505b545f7a545f4d14595557">[email&#160;protected]</a>",
<span class="opt">"systemRole"</span>: "admin",
<span class="opt">"isActive"</span>: false <span class="cmt">// deactivate</span>
}</pre></div>
<div class="ep-detail-col"><h5>Response · 200 OK</h5><pre>{ "id": "...", "email": "<a href="/cdn-cgi/l/email-protection" class="__cf_email__" data-cfemail="83e9e2ede6c3ede6f4ade0ecee">[email&#160;protected]</a>",
"displayName": "Jane Smith",
"systemRole": "admin", "isActive": false }</pre></div>
</div></div>
</div>
<div class="ep" onclick="this.classList.toggle('open')">
<div class="ep-row has-detail">
<span class="method method-post">POST</span>
<div><span class="ep-path">/v1/admin/users/<span class="param">{id}</span>/reset-password</span><div class="ep-desc">Reset to temp password. Audit logged.</div></div>
<span class="auth-badge auth-admin">admin</span><span></span>
</div>
<div class="ep-detail"><div class="ep-detail-grid">
<div class="ep-detail-col"><h5>Request body</h5><pre>{
<span class="req">"tempPassword"</span>: "Reset1Me!",
<span class="opt">"reason"</span>: "user requested via support"
}</pre></div>
<div class="ep-detail-col"><h5>Response · 200 OK</h5><pre>{
"message": "Password reset successfully",
"mustChangePassword": true
}</pre></div>
</div></div>
</div>
<div class="ep" onclick="this.classList.toggle('open')">
<div class="ep-row has-detail">
<span class="method method-get">GET</span>
<div><span class="ep-path">/v1/admin/audit-log</span><div class="ep-desc">View audit trail. Read-only.</div></div>
<span class="auth-badge auth-admin">admin</span><span></span>
</div>
<div class="ep-detail"><div class="ep-detail-grid">
<div class="ep-detail-col"><h5>Query params</h5><pre>?limit=50&offset=0
?targetUserId=550e-... <span class="cmt">// filter by user</span></pre></div>
<div class="ep-detail-col"><h5>Response · 200 OK</h5><pre>[
{ "id": "al-01-...",
"adminId": "adm-...",
"adminEmail": "<a href="/cdn-cgi/l/email-protection" class="__cf_email__" data-cfemail="19787d74707759786969377a7674">[email&#160;protected]</a>",
"targetUserId": "550e-...",
"targetEmail": "<a href="/cdn-cgi/l/email-protection" class="__cf_email__" data-cfemail="8efee2efe0e0ebfccee8efe3e7e2f7a0ede1e3">[email&#160;protected]</a>",
"action": "reset_password",
"detail": { "reason": "user requested" },
"performedAt": "2026-04-01T10:05:00Z" }
]</pre></div>
</div></div>
</div>
</div>
</div>
<!-- ═══ JOURNEY MAPPING ═══ -->
<div class="section" id="journey-mapping">
<div class="section-title">Journey → API mapping</div>
<div class="journey-map">
<div class="journey-card" style="border-left:3px solid var(--green-light);">
<div class="jc-id">J1 — Add a recipe</div><h4>3 requests</h4>
<ul><li>GET /v1/ingredients?search=...</li><li>GET /v1/tags</li><li>POST /v1/recipes</li></ul>
</div>
<div class="journey-card" style="border-left:3px solid var(--yellow-light);">
<div class="jc-id">J2 — Plan the week</div><h4>410 requests</h4>
<ul><li>GET /v1/week-plans?weekStart=...</li><li>GET .../suggestions?slotDate=...</li><li>POST .../slots</li><li>GET .../variety-score</li><li>POST .../confirm</li></ul>
</div>
<div class="journey-card" style="border-left:3px solid var(--green-light);">
<div class="jc-id">J3 — Cook tonight</div><h4>2 requests</h4>
<ul><li>GET /v1/recipes/{id}</li><li>POST /v1/cooking-logs</li></ul>
</div>
<div class="journey-card" style="border-left:3px solid #CECBF6;">
<div class="jc-id">J4 — Adapt on the fly</div><h4>2 requests (≤ 3 taps)</h4>
<ul><li>GET .../suggestions?slotDate=...</li><li>PATCH .../slots/{slotId}</li></ul>
</div>
<div class="journey-card" style="border-left:3px solid var(--blue-light);">
<div class="jc-id">J5 — Shopping list</div><h4>35 requests</h4>
<ul><li>POST .../shopping-list</li><li>GET /v1/shopping-lists/{id}</li><li>POST .../publish</li><li>PATCH .../items/{id}</li></ul>
</div>
<div class="journey-card" style="border-left:3px solid #CECBF6;">
<div class="jc-id">J6 — Household setup</div><h4>4 requests</h4>
<ul><li>POST /v1/auth/signup</li><li>POST /v1/households</li><li>POST .../invites</li><li>POST /v1/invites/{code}/accept</li></ul>
</div>
</div>
</div>
<!-- ═══ SECURITY ═══ -->
<div class="section" id="security">
<div class="section-title">Security architecture</div>
<div class="callout purple">
<h4>Three layers</h4>
<p><strong>1. Authentication:</strong> Spring Security 7 session. HttpOnly + Secure + SameSite=Lax cookie. 24h expiry.<br>
<strong>2. Role authorization:</strong> @PreAuthorize on systemRole (admin) and householdRole (planner vs member). 403 on mismatch.<br>
<strong>3. Household isolation:</strong> HouseholdContext resolves householdId from session. Every query includes <code>AND household_id = ?</code>. Wrong household → 404.</p>
</div>
<div class="code-block">
<div class="code-block-head"><span>Authorization matrix</span></div>
<pre><span class="cmt">Role │ Recipes │ Plan │ Shopping list │ Pantry │ Admin</span>
<span class="cmt">────────────┼──────────┼──────────┼───────────────────┼──────────┼──────</span>
<span class="str">Planner │ CRUD │ CRUD │ generate,publish │ CRUD │ —</span>
<span class="str">Member │ — │ READ │ read,check,add │ — │ —</span>
<span class="str">Admin │ — │ — │ — │ — │ CRUD + audit</span>
<span class="str">Unauth │ — │ — │ — │ — │ —</span></pre>
</div>
<div class="callout red">
<h4>Never exposed</h4>
<p>password_hash (@JsonIgnore) · sequential IDs (UUIDs only) · JSESSIONID (HttpOnly) · cross-household data (404, not 403) · audit_log.detail (admin-only)</p>
</div>
</div>
<!-- ═══ BUILD PHASES ═══ -->
<div class="section" id="phases">
<div class="section-title">Implementation phases</div>
<div class="callout green">
<h4>Phase 1 — Skeleton + Auth + CRUD (days 13)</h4>
<p>Spring Initializr (Boot 4.0, Java 21). Flyway migrations. JPA entities. SecurityConfig with session auth + CSRF. Auth endpoints. Recipe CRUD + autocomplete + tags. Heavily AI-generatable.</p>
</div>
<div class="callout">
<h4>Phase 2 — Household + Planning CRUD (days 45)</h4>
<p>Household creation + invite flow (J6). Week plan + slot CRUD (J2). Cooking log (J3). Household scoping verified.</p>
</div>
<div class="callout blue">
<h4>Phase 3 — Business logic (days 610)</h4>
<p>SuggestionService · VarietyService · ShoppingListService. The 3 services that need real thinking.</p>
</div>
<div class="callout purple">
<h4>Phase 4 — Admin + Polish (days 1114)</h4>