Goal: Deliver the API side of M2 Planning as a generated weekly meal plan (Monday to Sunday, produced from stored preferences through a mock provider seam, editable, regenerated every Sunday morning, with a synchronized shopping list) plus plan-to-diary confirmation and shared planning, and the M3 foundations the mobile app will consume next: two-adult household invitations and sharing, a mock subscription with a photo-analysis allowance, per-adult export and verified deletion, and calm alerts through the notification seam.
Direction change (2026-09-20, product): the manual planner first built under Task 1 was replaced in place by the generated planner (ADR-0010). Existing item endpoints and SDK operations were kept; preferences, generation, and the shopping list were added.
Architecture: Every capability is a NestJS module shaped like the M1 modules: Zod contracts in
packages/contracts, a repository port with a Prisma adapter, an audited service, a controller with
hand-written OpenAPI schemas, and colocated unit tests plus an integration test against PostgreSQL.
Provider seams stay mock-only (ADR 0003/0005). Nothing is shared between adults without an explicit
sharing setting (ADR 0006/0008), plans never become diary facts without confirmation, and the
allowance never gates manual entry (ADR 0009).
Tech Stack: NestJS, Zod, Prisma/PostgreSQL, LocalStack S3, Vitest, @hey-api/openapi-ts.
Status (2026-09-20): Tasks 1–7 implemented in the working tree; mobile screens are the mobile
agent’s follow-up and consume packages/api-client/src/generated/sdk.gen.ts.
planned through the diary service and never auto-converts.NotificationProvider only.AuditService.apps/api/src/planningFiles: packages/contracts/src/planning.ts (+ test), migration 20260920100211_m2_plan_items,
planning.dto.ts, planning.repository.ts, prisma-planning.repository.ts, planning.service.ts,
planning.controller.ts, planning.module.ts, planning.service.test.ts,
planning.controller.test.ts, planning.repository.test.ts; DiaryService.createPlannedMeal.
PlanItem rows carry plan_… public IDs, local date, slot, title, optional note, optional
eight-metric estimate, optional sourceMealId, confirmedMealEntryId (FK, set null on meal
deletion), and a version. Check constraints mirror the diary tables.POST /planning/items, PATCH /planning/items/:id, DELETE /planning/items/:id,
POST /planning/items/:id/move) use the diary’s operation envelope: idempotency key replay,
baseVersion optimistic concurrency, 409 with details.reason on stale or deleted items, all
recorded in client_operations with new operation types.POST /planning/items/:id/confirm creates the diary meal through DiaryService.createPlannedMeal
(source planned, estimate confirmed, recorded as confirm_plan_item), then links the plan
item to that meal exactly once. Replays with the same idempotency key return the same meal; a
different operation on an already-confirmed item is a 409.GET /planning/week?start= returns seven dates, the adult’s items, and, only when both adults have
sharePlans on, the other adult’s items flagged readOnly: true with authorAdultId.plan.create, plan.update, plan.move, plan.delete, plan.confirm.Files: packages/contracts/src/meal-planning.ts (+ test), packages/provider-contracts
MealPlanProvider + DeterministicMealPlanProvider + synthetic recipe library (+ tests), migration
20260920135330_m2_generated_meal_plans, planning-preferences.repository.ts,
prisma-planning-preferences.repository.ts, shopping-list.ts, plan-generation.scheduler.ts,
extended planning.dto.ts / planning.repository.ts / prisma-planning.repository.ts /
planning.service.ts / planning.controller.ts / planning.module.ts, worker wiring, tests
(planning.service.test.ts, planning.controller.test.ts, meal-plan.repository.test.ts,
shopping-list.test.ts, plan-generation.scheduler.test.ts).
GET/PATCH /planning/preferences): 14 EU allergens (hard), free-text
dislikes (soft), focus (balanced, lighter, more_fuel, higher_protein, more_plants; no
numeric goals), dietary pattern, household servings, unit system, country + city/postal code
only, time zone, autoGenerate.POST /planning/weeks/:monday/generate {dates?, replaceEdits?}): seven days times
four slots through the seam; each item carries description, per-serving nutrition, ingredients
with store sections and allergens, servings, and origin: generated. Generated items are
replaced on regeneration; manual items are kept unless replaceEdits; a week with edits answers
409 week_edited without it. Confirmed items are never replaced.GET /planning/week?start=) snaps to the Monday-to-Sunday week and adds a week
summary (source, edited, focus, generatedAt).GET /planning/weeks/:monday/shopping-list,
PATCH /planning/shopping-items/:id {state, version}): rebuilt after every generation and manual
change, one line per ingredient and unit, ordered by first day needed, tick state preserved by
ingredient key (reset when the quantity grew), optimistic versions.PlanGenerationScheduler.runDue in the worker every minute; on Sunday from
PLAN_AUTO_GENERATE_HOUR (default 7) local time generates next week for adults who have not
opted out, skipping weeks that already exist; audited as system.plan.generate, plan.preferences_update.apps/api/src/householdFiles: packages/contracts/src/household.ts (+ test), migration
20260920101655_m3_household_invitations_sharing, household.dto.ts, household.repository.ts,
prisma-household.repository.ts (also implements PlanSharingReader), household.service.ts,
household.controller.ts, household.module.ts, three tests; mock identity token
local.synthetic-token-b.
HouseholdInvitation (12-character code from an unambiguous alphabet, single use, 7-day expiry,
status pending/accepted/declined/revoked/expired) and HouseholdSharingSetting (sharePlans,
shareDaySummary, both default off).GET /household, POST /household, POST /household/invitations,
POST /household/invitations/:code/accept|decline, DELETE /household/invitations/:id,
PATCH /household/sharing, DELETE /household/membership,
GET /household/shared-summary?date=.household.create, household.invitation_create, household.invitation_accept,
household.invitation_decline, household.invitation_revoke, household.sharing_update,
household.leave.apps/api/src/subscriptionFiles: packages/provider-contracts BillingProvider + DeterministicBillingProvider,
packages/contracts/src/subscription.ts, migration
20260920102915_m3_subscription_alerts_deletion_receipts (shared with Tasks 5 and 6), environment
SUBSCRIPTION_TRIAL_DAYS, SUBSCRIPTION_MONTHLY_ANALYSIS_ALLOWANCE,
SUBSCRIPTION_MONTHLY_PRICE_DISPLAY, subscription.* module files and three tests; photo service
allowance guard; explicit error code support in the shared error envelope.
POST /subscription/trial/start);
POST /subscription/restore activates a 30-day mock period; POST /subscription/cancel keeps
access until the period ends. A lapsed active period rolls forward one month without charging.GET /subscription reports status (trial, active, cancelled, expired), access end,
renewal, plan (display price from configuration), manage-URL placeholder, and the month’s
allowance/usage counted from meal_analysis_jobs.POST /photo/uploads asks the guard before authorizing a new upload; when the allowance is
exhausted or access ended it answers 403 with code: analysis_allowance_exhausted. Manual
entry is never gated.subscription.trial_start, subscription.restore, subscription.cancel.apps/api/src/adult-dataFiles: packages/contracts/src/adult-data.ts, adult-data.* module files and three tests,
docs/runbooks/data-export-and-deletion.md.
GET /adult-profile/export returns platepilot.adult-export.v1 JSON: profile, privacy, household
facts (never the other adult), subscription, alert preferences, diary, plan items, photo-analysis
metadata. No photo bytes, object keys, or tokens.DELETE /adult-profile requires X-Confirm-Deletion: <adultProfileId>, removes retained diary
photos from the retention bucket first (503 and no deletion if storage is unavailable), then
deletes the adult in one transaction (cascades), drops an empty household, writes a
deletion_receipts row holding a subject hash, and answers 202 with the receipt.adult_profile.export, adult_profile.delete.apps/api/src/alertsFiles: packages/contracts/src/alerts.ts, alerts.* module files and three tests.
GET /alerts?date=¬ices= derives meal reminders from the adult’s unconfirmed plan items (fixed
suggested times per slot), adds the fixed water schedule only when waterReminders is on, passes
through client problem notices with calm copy, sends each through the mock
NotificationProvider, and returns receipts. Nothing is stored.GET/PATCH /alerts/preferences manage mealReminders (default on) and waterReminders
(default off). Audit action: alerts.preferences_update.pnpm --filter @platepilot/api-client generate regenerates
packages/api-client/openapi/platepilot.openapi.json and src/generated/**.docs/plans/README.md, docs/architecture.md, docs/threat-model.md,
docs/runbooks/data-export-and-deletion.md, README delivery status and mock-provider notes,
ADR 0008 (household sharing model), ADR 0009 (mock billing and allowance), ADR 0010 (generated
weekly meal plans).apps/api, packages/contracts,
packages/provider-contracts.pnpm check passes in Docker.localTime needs a real notification adapter and the adult’s time zone.