API design

Recipe app · Spring Boot 4 · PostgreSQL · Self-hosted · Single machine

v1.3
Style: REST + JSON
Framework: Spring Boot 4.0
Auth: Spring Security 7 (session)
Endpoints: 33
Designed by: Nexus

v1.3 changes from v1.2

Stack Conventions Project structure Auth & households Recipes Planning Shopping Pantry Admin Journey → API map Security Build phases
Stack — boring, predictable, yours
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.

API framework

Spring Boot 4.0

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.

Database

PostgreSQL 16

Same machine. 1:1 mapping to JPA entities. Flyway for migrations (Atlas's SQL directly). HikariCP connection pool built in.

Auth

Spring Security 7 (sessions)

Default session-based auth. HttpOnly + Secure + SameSite=Lax cookie. No tokens, no refresh flow, no extra libraries. bcrypt password hashing built in.

33
REST endpoints
18
JPA entities
6
Domains
1
Deployable JAR
0
External services

Key dependencies

spring-boot-starter-web REST + Jackson · spring-boot-starter-data-jpa Hibernate + repos · spring-boot-starter-security session auth + CSRF · spring-boot-starter-validation Bean Validation · flyway-core DB migrations · postgresql JDBC driver · springdoc-openapi Swagger UI

API conventions
Base URL · Headers · Naming
https://yourapp.com/v1                — frontend + API on same domain
Cookie: JSESSIONID=...                 — automatic (session auth)
X-XSRF-TOKEN: ...                     — CSRF (POST/PUT/PATCH/DELETE)
Content-Type: application/json        — request bodies

URLs:  kebab-case    /v1/week-plans/{id}/slots
JSON:  camelCase     cookTimeMin, isChildFriendly
Java:  camelCase     DB cols: snake_case
Response envelopes
// Success
{ "status": "success", "data": { ... }, "meta": { "pagination": { ... } } }

// Error
{ "status": "error", "error": { "code": "VALIDATION_ERROR", "message": "...", "details": [...] } }

// Pagination & filtering
?limit=20&offset=0&sort=-cookTimeMin&effort=easy&search=pasta

Click any endpoint row to expand request/response bodies

Every endpoint below has a collapsible detail panel showing the exact JSON shapes. Fields marked required are validated with Bean Validation annotations. Fields marked optional can be omitted. All responses are wrapped in the standard envelope above — the examples below show the data payload only.

Project structure — package by domain
src/main/java/com/recipeapp
com.recipeapp
├── auth/            AuthController · AuthService · SecurityConfig · CustomUserDetailsService
├── household/       HouseholdController · InviteController · entities/ · dtos/ · repos/
├── recipe/          RecipeController · IngredientController · TagController · RecipeService
├── planning/        WeekPlanController · SuggestionService · VarietyService · CookingLogController
├── shopping/        ShoppingListController · ShoppingListService
├── pantry/          PantryController
├── admin/           AdminController
└── common/          ApiResponse · ApiError · GlobalExceptionHandler · HouseholdContext
Auth & household endpoints

Authentication

Auth
MethodPath / DescriptionAuthJourney
POST
/v1/auth/signup
Create account. Sets session cookie. Returns user object.
public J6
Request body
{
  "email": "[email protected]",     // required, valid email
  "password": "s3cure!Pass",         // required, min 8 chars
  "displayName": "Sarah"             // required, 1–100 chars
}
Response · 201 Created
{
  "id": "550e8400-...",
  "email": "[email protected]",
  "displayName": "Sarah",
  "householdId": null,              // no household yet
  "householdRole": null
}
+ Set-Cookie: JSESSIONID=...; HttpOnly; Secure; SameSite=Lax
POST
/v1/auth/login
Login. Creates session. Returns Set-Cookie + user.
public J6
Request body
{
  "email": "[email protected]",
  "password": "s3cure!Pass"
}
Response · 200 OK
{
  "id": "550e8400-...",
  "email": "[email protected]",
  "displayName": "Sarah",
  "householdId": "7c9e6679-...",
  "householdRole": "planner",
  "systemRole": "user"
}
+ Set-Cookie: JSESSIONID=...; HttpOnly; Secure; SameSite=Lax
POST
/v1/auth/logout
Invalidate session. Clears cookie.
auth
Request body
// empty — no body needed
Response · 204 No Content
// empty body
+ Set-Cookie: JSESSIONID=; Max-Age=0
GET
/v1/auth/me
Current user + household + role. Every app launch.
auth
Request
// no body — GET request
// session cookie sent automatically
Response · 200 OK
{
  "id": "550e8400-...",
  "email": "[email protected]",
  "displayName": "Sarah",
  "householdId": "7c9e6679-...",
  "householdName": "Smith family",
  "householdRole": "planner",
  "systemRole": "user"
}
PATCH
/v1/auth/me
Update own displayName or password. Screen E1.
auth
Request body
{
  "displayName": "Sarah S.",       // optional
  "currentPassword": "old...",     // required if changing pw
  "newPassword": "new..."          // optional, min 8 chars
}
Response · 200 OK
{
  "id": "550e8400-...",
  "email": "[email protected]",
  "displayName": "Sarah S."
}

Households & members

Auth
MethodPath / DescriptionAuthJourney
POST
/v1/households
Create household + planner role + seed staples & tags. @Transactional.
auth J6
Request body
{
  "name": "Smith family"            // required, 1–100 chars
}
// Seeds ~20 default staple ingredients
// Seeds default tags (protein types, dietary)
// Adds current user as planner
Response · 201 Created
{
  "id": "7c9e6679-...",
  "name": "Smith family",
  "members": [
    {
      "userId": "550e8400-...",
      "displayName": "Sarah",
      "role": "planner",
      "joinedAt": "2026-04-01T10:00:00Z"
    }
  ]
}
GET
/v1/households/mine
Current user's household with members. Screen E2.
auth J6
Request
// no body — GET
Response · 200 OK
{
  "id": "7c9e6679-...",
  "name": "Smith family",
  "members": [
    { "userId": "550e...", "displayName": "Sarah",
      "role": "planner", "joinedAt": "..." },
    { "userId": "661f...", "displayName": "Tom",
      "role": "member", "joinedAt": "..." }
  ]
}
POST
/v1/households/mine/invites
Generate invite code. Expires 48h.
planner J6
Request body
// empty — server generates the code
Response · 201 Created
{
  "inviteCode": "ABC12XYZ",
  "shareUrl": "https://yourapp.com/join/ABC12XYZ",
  "expiresAt": "2026-04-03T10:00:00Z"
}
POST
/v1/invites/{code}/accept
Accept invite → join as member.
auth J6
Request
// code is in the URL path
// no request body needed
Response · 200 OK
{
  "householdId": "7c9e6679-...",
  "householdName": "Smith family",
  "role": "member"
}
// 409 if code already used
// 422 if code expired
// 409 if user already in a household
GET
/v1/households/mine/members
List members with names and roles.
auth J6
Request
// no body — GET
Response · 200 OK
[
  { "userId": "550e...", "displayName": "Sarah",
    "role": "planner", "joinedAt": "..." },
  { "userId": "661f...", "displayName": "Tom",
    "role": "member", "joinedAt": "..." }
]
Recipe endpoints

Recipes

Recipe
MethodPath / DescriptionAuthJourney
GET
/v1/recipes
List recipes (B1). Summary fields. ?search, ?effort, ?isChildFriendly, ?sort, ?limit, ?offset.
planner J1J2
Query parameters
?search=pasta              // ILIKE on name
?effort=easy               // exact match
?isChildFriendly=true      // boolean
?cookTimeMin.lte=30        // ≤ 30 minutes
?sort=-cookTimeMin         // descending
?limit=20&offset=0         // pagination
Response · 200 OK
{
  "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
    }
  }
}
GET
/v1/recipes/{id}
Full detail (B2). Ingredients, steps, tags. @EntityGraph — one query.
planner J3
Request
// no body — GET
// {id} = recipe UUID
Response · 200 OK
{
  "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" }
  ]
}
POST
/v1/recipes
Create with nested ingredients, steps, tag IDs. @Transactional.
planner J1
Request body
{
  "name": "Spaghetti Bolognese",
  "serves": 4,                      // 1–20
  "cookTimeMin": 45,                // ≥ 0
  "effort": "medium",               // easy|medium|hard
  "isChildFriendly": true,          // default false
  "heroImageUrl": null,
  "ingredients": [
    { "ingredientId": "f1e2-...",   // existing id
      "quantity": 400,
      "unit": "g",
      "sortOrder": 1 },
    { "newIngredientName": "pancetta",// OR create new
      "quantity": 100,
      "unit": "g",
      "sortOrder": 2 }
  ],
  "steps": [
    { "stepNumber": 1,
      "instruction": "Boil water..." }
  ],
  "tagIds": ["t1-...", "t2-..."]    // ≥ 2 (effort + 1 cat)
}
Response · 201 Created
// full RecipeDetail (same shape as GET /recipes/{id})
{
  "id": "a1b2c3d4-...",
  "name": "Spaghetti Bolognese",
  ...
}
+ Location: /v1/recipes/a1b2c3d4-...
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.
PUT
/v1/recipes/{id}
Full replace. Same shape as POST body. Replaces all children.
planner J1
Request body
// identical shape to POST /v1/recipes
// sends the complete new state
// server deletes old children, inserts new
{
  "name": "Spaghetti Bolognese (updated)",
  "serves": 4,
  "cookTimeMin": 40,
  "effort": "medium",
  "ingredients": [ ... ],
  "steps": [ ... ],
  "tagIds": [ ... ]
}
Response · 200 OK
// full RecipeDetail with updated data
{
  "id": "a1b2c3d4-...",
  "name": "Spaghetti Bolognese (updated)",
  ...
}
DELETE
/v1/recipes/{id}
Soft delete (sets deletedAt).
planner
Request
// no body — DELETE
Response · 204 No Content
// empty body

Ingredients & tags

Recipe
MethodPath / DescriptionAuthJourney
GET
/v1/ingredients?search={q}
Autocomplete by name. Limit 10.
auth J1
Query params
?search=chick             // ILIKE '%chick%'
?isStaple=true            // filter staples (A3/D3)
Response · 200 OK
[
  { "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 }
]
PATCH
/v1/ingredients/{id}
Toggle isStaple. Update name or categoryId.
planner J6
Request body
{
  "isStaple": true,
  "name": "olive oil",
  "categoryId": "cat-03-..."       // FK → ingredient_category
}
Response · 200 OK
{ "id": "f1e2-...", "name": "olive oil",
  "category": { "id": "cat-03-...", "name": "oil" },
  "isStaple": true }
GET
/v1/tags
All tags grouped by tagType. For B3 picker.
auth J1
Request
// no body — GET
Response · 200 OK
[
  { "id": "t1-...", "name": "chicken", "tagType": "protein" },
  { "id": "t2-...", "name": "beef", "tagType": "protein" },
  { "id": "t3-...", "name": "vegetarian", "tagType": "dietary" },
  { "id": "t4-...", "name": "Italian", "tagType": "cuisine" }
]
POST
/v1/tags
Create custom tag.
planner J1
Request body
{
  "name": "Thai",
  "tagType": "cuisine"            // protein|dietary|cuisine
}
Response · 201 Created
{ "id": "t9-...", "name": "Thai",
  "tagType": "cuisine" }

Ingredient categories

Recipe
MethodPath / DescriptionAuthJourney
GET
/v1/ingredient-categories
List all ingredient categories. Used in B3 recipe form and for shopping list grouping (D1).
auth J1J5
Request
// no body — GET
// scoped to user's household automatically
Response · 200 OK
[
  { "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" }
]
// seeded on household creation
// ordered alphabetically by name
POST
/v1/ingredient-categories
Create custom category. Planner can extend the default list.
planner J1
Request body
{
  "name": "frozen"                  // required, 1–50 chars
}
// 409 if name already exists in household
Response · 201 Created
{
  "id": "cat-08-...",
  "name": "frozen"
}
Planning endpoints

Week plans & slots

Planning
MethodPath / DescriptionAuthJourney
GET
/v1/week-plans?weekStart={date}
Week plan + slots + recipe summaries. C1 home screen.
auth J2J3
Query params
?weekStart=2026-04-06      // ISO date, must be Monday
Response · 200 OK
{
  "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                // empty day
    }
  ]
}
// 404 if no plan exists for that week yet
POST
/v1/week-plans
Create week plan (draft).
planner J2
Request body
{
  "weekStart": "2026-04-06"        // must be a Monday
}
Response · 201 Created
{
  "id": "wp-1234-...",
  "weekStart": "2026-04-06",
  "status": "draft",
  "slots": []
}
// 409 if plan already exists for that week
POST
/v1/week-plans/{id}/slots
Assign recipe to a day.
planner J2
Request body
{
  "slotDate": "2026-04-07",        // within plan week
  "recipeId": "a1b2c3d4-..."
}
Response · 201 Created
{
  "id": "sl-03-...",
  "slotDate": "2026-04-07",
  "recipe": {
    "id": "a1b2c3d4-...",
    "name": "Spaghetti Bolognese",
    "effort": "medium",
    "cookTimeMin": 45,
    "heroImageUrl": "..."
  }
}
PATCH
/v1/week-plans/{planId}/slots/{slotId}
Swap recipe. The ≤ 3-tap mid-week swap.
planner J4
Request body
{
  "recipeId": "x9y8z7-..."         // new recipe
}
Response · 200 OK
{
  "id": "sl-03-...",
  "slotDate": "2026-04-07",
  "recipe": {
    "id": "x9y8z7-...",
    "name": "Quick Stir Fry",
    "effort": "easy", "cookTimeMin": 15, ...
  }
}
DELETE
/v1/week-plans/{planId}/slots/{slotId}
Clear a day slot.
planner J4
Request
// no body — DELETE
Response · 204 No Content
// empty body
POST
/v1/week-plans/{id}/confirm
Confirm plan. Validates ≥1 slot. 422 if already confirmed.
planner J2
Request body
// empty — action endpoint
Response · 200 OK
{
  "id": "wp-1234-...",
  "status": "confirmed",
  "confirmedAt": "2026-04-05T18:30:00Z"
}
// 422 if no slots filled
// 422 if already confirmed

Suggestions & variety

Planning
MethodPath / DescriptionAuthJourney
GET
/v1/week-plans/{id}/suggestions?slotDate={date}
3–5 suggestions. Filters: ingredients (3d), protein, effort.
planner J2J4
Query params
?slotDate=2026-04-08       // target day
Response · 200 OK
{
  "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"]
    }
  ]
}
GET
/v1/week-plans/{id}/variety-score
Computed score (0–10) + breakdown.
auth J2
Request
// no body — GET
Response · 200 OK
{
  "score": 7.5,
  "ingredientOverlaps": [
    { "ingredientName": "onion",
      "days": ["2026-04-06", "2026-04-07"] }
  ],
  "proteinRepeats": [],
  "effortBalance": {
    "easy": 2, "medium": 3, "hard": 2
  }
}
POST
/v1/cooking-logs
Mark meal cooked. Immutable INSERT.
planner J3
Request body
{
  "recipeId": "a1b2c3d4-...",
  "cookedOn": "2026-04-07"          // default: today
}
Response · 201 Created
{
  "id": "cl-01-...",
  "recipeId": "a1b2c3d4-...",
  "cookedOn": "2026-04-07",
  "cookedBy": "550e8400-..."
}
GET
/v1/cooking-logs?limit=30
Recent history (desc by cookedOn).
auth J2
Query params
?limit=30                  // default 30
?offset=0
Response · 200 OK
[
  { "id": "cl-01-...", "recipeId": "a1b2-...",
    "recipeName": "Spaghetti Bolognese",
    "cookedOn": "2026-04-07",
    "cookedBy": "550e8400-..." }
]
Shopping endpoints

Shopping list

Shopping
MethodPath / DescriptionAuthJourney
POST
/v1/week-plans/{id}/shopping-list
Generate from plan. Merge, sum, filter staples. Draft for preview.
planner J5
Request body
// empty — generated from the week plan
// server merges ingredients across meals,
// sums quantities, filters staples
Response · 201 Created
{
  "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-..."] }
  ]
}
GET
/v1/shopping-lists/{id}
Full list with items. Both roles. Pull-to-refresh target.
auth J5
Request
// no body — GET
// this is the refresh action:
// pull-to-refresh calls this endpoint
Response · 200 OK
// same shape as POST response above
{
  "id": "shl-01-...",
  "status": "published",
  "items": [ ... ]
}
POST
/v1/shopping-lists/{id}/publish
Publish (draft → published). Live for members.
planner J5
Request body
// empty — action endpoint
Response · 200 OK
{
  "id": "shl-01-...",
  "status": "published",
  "publishedAt": "2026-04-06T09:00:00Z"
}
// 422 if already published
PATCH
/v1/shopping-lists/{listId}/items/{itemId}
Check/uncheck item. Both roles.
auth J5
Request body
{
  "isChecked": true
}
Response · 200 OK
{
  "id": "si-01-...",
  "name": "spaghetti",
  "isChecked": true,
  "checkedBy": "661f-..."           // who checked it
}
// other members see this on next refresh
POST
/v1/shopping-lists/{id}/items
Add custom item. Both roles.
auth J5
Request body
{
  "ingredientId": null,             // or existing id
  "customName": "Paper towels",     // if no ingredientId
  "quantity": 1,
  "unit": ""                         // blank for countable
}
Response · 201 Created
{
  "id": "si-10-...",
  "ingredientId": null,
  "name": "Paper towels",
  "quantity": 1, "unit": "",
  "isChecked": false,
  "sourceRecipes": []
}
DELETE
/v1/shopping-lists/{listId}/items/{itemId}
Remove item. Planner only, pre-publish.
planner J5
Request
// no body — DELETE
Response · 204 No Content
// 422 if list is already published
Pantry endpoints

Pantry items

Pantry
MethodPath / DescriptionAuth
GET
/v1/pantry-items
List items, expiring soonest first.
auth
Request
// no body — GET
Response · 200 OK
[
  { "id": "pi-01-...",
    "ingredientId": "f1e2-...",
    "name": "chicken breast",
    "category": { "id": "cat-02-...", "name": "meat" },
    "quantity": 500, "unit": "g",
    "bestBefore": "2026-04-10",
    "openedOn": null }
]
POST
/v1/pantry-items
Add item.
planner
Request body
{
  "ingredientId": "f1e2-...",      // or null
  "customName": null,               // if no ingredientId
  "quantity": 500,
  "unit": "g",
  "bestBefore": "2026-04-10",
  "openedOn": null
}
Response · 201 Created
{ "id": "pi-02-...",
  "ingredientId": "f1e2-...",
  "name": "chicken breast",
  "quantity": 500, "unit": "g",
  "bestBefore": "2026-04-10",
  "openedOn": null }
PATCH
/v1/pantry-items/{id}
Update quantity, bestBefore, openedOn.
planner
Request body
{
  "quantity": 250,
  "openedOn": "2026-04-07"
}
Response · 200 OK
{ "id": "pi-02-...", "quantity": 250,
  "openedOn": "2026-04-07", ... }
DELETE
/v1/pantry-items/{id}
Remove consumed/expired item.
planner
Request
// no body
Response · 204 No Content
// empty
Admin endpoints

Admin user management

Admin
MethodPath / DescriptionAuth
GET
/v1/admin/users
List all users. Paginated.
admin
Query params
?limit=50&offset=0
?search=jane               // by email or name
?isActive=true
Response · 200 OK
{
  "data": [
    { "id": "550e-...", "email": "[email protected]",
      "displayName": "Sarah", "systemRole": "user",
      "isActive": true, "createdAt": "..." }
  ],
  "meta": { "pagination": { "total": 24, ... } }
}
POST
/v1/admin/users
Create user with temp password + audit log.
admin
Request body
{
  "email": "[email protected]",
  "displayName": "New User",
  "tempPassword": "Change1Me!",
  "systemRole": "user"             // default "user"
}
Response · 201 Created
{
  "id": "new-uuid-...",
  "email": "[email protected]",
  "displayName": "New User",
  "systemRole": "user",
  "isActive": true,
  "mustChangePassword": true
}
PATCH
/v1/admin/users/{id}
Update user. Audit logged.
admin
Request body
{
  "displayName": "Jane Smith",
  "email": "[email protected]",
  "systemRole": "admin",
  "isActive": false                 // deactivate
}
Response · 200 OK
{ "id": "...", "email": "[email protected]",
  "displayName": "Jane Smith",
  "systemRole": "admin", "isActive": false }
POST
/v1/admin/users/{id}/reset-password
Reset to temp password. Audit logged.
admin
Request body
{
  "tempPassword": "Reset1Me!",
  "reason": "user requested via support"
}
Response · 200 OK
{
  "message": "Password reset successfully",
  "mustChangePassword": true
}
GET
/v1/admin/audit-log
View audit trail. Read-only.
admin
Query params
?limit=50&offset=0
?targetUserId=550e-...     // filter by user
Response · 200 OK
[
  { "id": "al-01-...",
    "adminId": "adm-...",
    "adminEmail": "[email protected]",
    "targetUserId": "550e-...",
    "targetEmail": "[email protected]",
    "action": "reset_password",
    "detail": { "reason": "user requested" },
    "performedAt": "2026-04-01T10:05:00Z" }
]
Journey → API mapping
J1 — Add a recipe

3 requests

  • GET /v1/ingredients?search=...
  • GET /v1/tags
  • POST /v1/recipes
J2 — Plan the week

4–10 requests

  • GET /v1/week-plans?weekStart=...
  • GET .../suggestions?slotDate=...
  • POST .../slots
  • GET .../variety-score
  • POST .../confirm
J3 — Cook tonight

2 requests

  • GET /v1/recipes/{id}
  • POST /v1/cooking-logs
J4 — Adapt on the fly

2 requests (≤ 3 taps)

  • GET .../suggestions?slotDate=...
  • PATCH .../slots/{slotId}
J5 — Shopping list

3–5 requests

  • POST .../shopping-list
  • GET /v1/shopping-lists/{id}
  • POST .../publish
  • PATCH .../items/{id}
J6 — Household setup

4 requests

  • POST /v1/auth/signup
  • POST /v1/households
  • POST .../invites
  • POST /v1/invites/{code}/accept
Security architecture

Three layers

1. Authentication: Spring Security 7 session. HttpOnly + Secure + SameSite=Lax cookie. 24h expiry.
2. Role authorization: @PreAuthorize on systemRole (admin) and householdRole (planner vs member). 403 on mismatch.
3. Household isolation: HouseholdContext resolves householdId from session. Every query includes AND household_id = ?. Wrong household → 404.

Authorization matrix
Role        │ Recipes  │ Plan     │ Shopping list     │ Pantry   │ Admin
────────────┼──────────┼──────────┼───────────────────┼──────────┼──────
Planner     │ CRUD     │ CRUD     │ generate,publish  │ CRUD     │ —
Member      │ —        │ READ     │ read,check,add    │ —        │ —
Admin       │ —        │ —        │ —                 │ —        │ CRUD + audit
Unauth      │ —        │ —        │ —                 │ —        │ —

Never exposed

password_hash (@JsonIgnore) · sequential IDs (UUIDs only) · JSESSIONID (HttpOnly) · cross-household data (404, not 403) · audit_log.detail (admin-only)

Implementation phases

Phase 1 — Skeleton + Auth + CRUD (days 1–3)

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.

Phase 2 — Household + Planning CRUD (days 4–5)

Household creation + invite flow (J6). Week plan + slot CRUD (J2). Cooking log (J3). Household scoping verified.

Phase 3 — Business logic (days 6–10)

SuggestionService · VarietyService · ShoppingListService. The 3 services that need real thinking.

Phase 4 — Admin + Polish (days 11–14)