- 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>
1456 lines
76 KiB
HTML
1456 lines
76 KiB
HTML
<!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 & 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 & 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 & 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 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, 1–100 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 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 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 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 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 protected]</a>",
|
||
"displayName": "Sarah S."
|
||
}</pre></div>
|
||
</div></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="endpoint-group">
|
||
<div class="endpoint-group-head"><h3>Households & 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 & 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, 1–100 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">// 1–20</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 & 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, 1–50 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 & 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 & 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">3–5 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 (0–10) + 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 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 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 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 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 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 protected]</a>",
|
||
"targetUserId": "550e-...",
|
||
"targetEmail": "<a href="/cdn-cgi/l/email-protection" class="__cf_email__" data-cfemail="8efee2efe0e0ebfccee8efe3e7e2f7a0ede1e3">[email 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>4–10 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>3–5 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 1–3)</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 4–5)</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 6–10)</h4>
|
||
<p>SuggestionService · VarietyService · ShoppingListService. The 3 services that need real thinking.</p>
|
||
</div>
|
||
<div class="callout purple">
|
||
<h4>Phase 4 — Admin + Polish (days 11–14)</h4>
|
||
|