feat(mealPlan) Add meal plan feature #3

Merged
araemer merged 36 commits from meal-plan into stage 2026-06-14 06:40:32 +00:00
Owner

Summary

Introduces the full meal plan feature: CRUD for plans, days, dishes, and share-based access control. Also adds dish sort order and a database naming strategy.

Meal plan feature

  • Plans — create, load (with optional date-range filter), update, delete via POST /meal-plan/save-or-update, POST /meal-plan/:id/load, DELETE /meal-plan/:id
  • Days — per-day save-or-update and delete; one day per calendar date enforced at handler level
  • Dishes — managed as a collection on the day via three-way merge (add / update / remove in one request)
  • Shares — grant readonly, read_write, or full access to other users; share management is its own sub-resource
  • List — compact plan list via a raw UNION query returning both owned and shared plans in one round-trip
  • Permissions — resolved in MealPlanHandler.loadWithPermission(); each write operation uses an explicit allowlist so future permission levels are denied by default

See docs/adr/2026-05-31-meal-plan.md for the full design rationale and docs/architecture.md for the updated architecture overview.

Dish sort order

Adds a sort_order column to meal_plan_day_dish (migration 005). The value is client-managed; the backend stores, returns, and orders by it. Enables the frontend to present dishes in a stable, user-defined sequence and support drag-and-drop reordering.

Snake case naming strategy

Adds SnakeCaseNamingStrategy to the TypeORM AppDataSource. Without it, every camelCase entity property mapping to a multi-word column required an explicit name option in its @Column decorator — omitting it caused TypeORM to use the camelCase name literally in SQL. The strategy converts property names automatically; explicit name options still take precedence.

Bug fixes

  • UNION query in findCompactForUser passed two parameters for a single $1 placeholder — fixed to [userId]
  • mps.permission in the UNION was a native meal_plan_permission enum; PostgreSQL tried to cast the literal string 'owner' into it and failed — fixed with ::text cast
  • findByIdWithDays QueryBuilder used plain property paths ("shares", "days.dishes") where TypeORM requires alias-qualified paths ("meal_plan.shares", "day.dishes") — fixed and documented in CLAUDE.md
  • Bruno collection pre-request script now auto-logins when no token is present, in addition to the existing auto-refresh on expiry

Migrations

# Name Change
001 CreateMealPlanTable meal_plan table
002 CreateMealPlanDayTable meal_plan_day table
003 CreateMealPlanDayDishTable meal_plan_day_dish table
004 CreateMealPlanShareTable meal_plan_permission enum + meal_plan_share table
005 AddSortOrderToMealPlanDayDish sort_order column on meal_plan_day_dish

Run via npx typeorm migration:run -d ./build/data-source.js after deploying.

Test plan

  • Create a meal plan and verify it appears in the list
  • Load a plan with and without a date-range filter
  • Add a day with multiple dishes; verify sort order is respected
  • Reorder dishes (update sortOrder values) and confirm the load response reflects the new order
  • Share a plan at each permission level and verify access control for read, write, delete, and share operations
  • Verify a user with no meal plans receives an empty list (not a 500)
  • Confirm Bruno auto-login fires on first request after clearing the token from the environment
## Summary Introduces the full meal plan feature: CRUD for plans, days, dishes, and share-based access control. Also adds dish sort order and a database naming strategy. ### Meal plan feature - **Plans** — create, load (with optional date-range filter), update, delete via `POST /meal-plan/save-or-update`, `POST /meal-plan/:id/load`, `DELETE /meal-plan/:id` - **Days** — per-day save-or-update and delete; one day per calendar date enforced at handler level - **Dishes** — managed as a collection on the day via three-way merge (add / update / remove in one request) - **Shares** — grant `readonly`, `read_write`, or `full` access to other users; share management is its own sub-resource - **List** — compact plan list via a raw UNION query returning both owned and shared plans in one round-trip - **Permissions** — resolved in `MealPlanHandler.loadWithPermission()`; each write operation uses an explicit allowlist so future permission levels are denied by default See `docs/adr/2026-05-31-meal-plan.md` for the full design rationale and `docs/architecture.md` for the updated architecture overview. ### Dish sort order Adds a `sort_order` column to `meal_plan_day_dish` (migration 005). The value is client-managed; the backend stores, returns, and orders by it. Enables the frontend to present dishes in a stable, user-defined sequence and support drag-and-drop reordering. ### Snake case naming strategy Adds `SnakeCaseNamingStrategy` to the TypeORM `AppDataSource`. Without it, every camelCase entity property mapping to a multi-word column required an explicit `name` option in its `@Column` decorator — omitting it caused TypeORM to use the camelCase name literally in SQL. The strategy converts property names automatically; explicit `name` options still take precedence. ### Bug fixes - UNION query in `findCompactForUser` passed two parameters for a single `$1` placeholder — fixed to `[userId]` - `mps.permission` in the UNION was a native `meal_plan_permission` enum; PostgreSQL tried to cast the literal string `'owner'` into it and failed — fixed with `::text` cast - `findByIdWithDays` QueryBuilder used plain property paths (`"shares"`, `"days.dishes"`) where TypeORM requires alias-qualified paths (`"meal_plan.shares"`, `"day.dishes"`) — fixed and documented in CLAUDE.md - Bruno collection pre-request script now auto-logins when no token is present, in addition to the existing auto-refresh on expiry ## Migrations | # | Name | Change | |---|---|---| | 001 | CreateMealPlanTable | `meal_plan` table | | 002 | CreateMealPlanDayTable | `meal_plan_day` table | | 003 | CreateMealPlanDayDishTable | `meal_plan_day_dish` table | | 004 | CreateMealPlanShareTable | `meal_plan_permission` enum + `meal_plan_share` table | | 005 | AddSortOrderToMealPlanDayDish | `sort_order` column on `meal_plan_day_dish` | Run via `npx typeorm migration:run -d ./build/data-source.js` after deploying. ## Test plan - [ ] Create a meal plan and verify it appears in the list - [ ] Load a plan with and without a date-range filter - [ ] Add a day with multiple dishes; verify sort order is respected - [ ] Reorder dishes (update `sortOrder` values) and confirm the load response reflects the new order - [ ] Share a plan at each permission level and verify access control for read, write, delete, and share operations - [ ] Verify a user with no meal plans receives an empty list (not a 500) - [ ] Confirm Bruno auto-login fires on first request after clearing the token from the environment
Implements the full meal plan aggregate: plans, days, dishes (linked to
existing recipes), and per-user sharing with three permission levels
(readonly, read_write, full). Includes four migrations, all API endpoints,
Bruno request files, and API documentation in recipe-requirements.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Documents the share-based permission model, dedicated sub-resource
endpoints, UNION query for the list, JOIN ON clause date filter, and
native PostgreSQL enum for permission.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Fix UNION query passing two parameters for a single $1 placeholder
- Cast mps.permission to text to avoid PostgreSQL attempting to coerce
  the literal string "owner" into the meal_plan_permission enum
- Type MealPlanCompactProjection.effective_permission as the enum rather
  than string, removing the cast at the service layer
- Rewrite Bruno collection pre-request script to auto-login when no
  token is present, and fix the blocked guard to use string booleans

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds a sort_order column to meal_plan_day_dish so clients can assign and
maintain a stable, user-defined display order for dishes within a day.
The value is client-managed — the backend stores and returns it without
imposing any auto-assignment logic.

- Migration 005: ALTER TABLE adds sort_order integer NOT NULL DEFAULT 0
- Entity: sortOrder field with matching column definition
- DTO: sortOrder field exposed on request and response
- Mapper: sortOrder mapped in toDto, toEntity, and doMergeDtoIntoEntity
- Repository: findByIdWithDays orders dishes by sort_order ASC within
  each day, after the existing day.date ASC ordering
- Bruno: saveOrUpdateDay example body includes sortOrder

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The leftJoinAndSelect for SHARES used alias "share" but the subsequent
nested join SHARES_SHARED_WITH_USER ("shares.sharedWithUser") expected
TypeORM to resolve an alias named "shares". Renaming the alias to
"shares" makes the two consistent and eliminates the TypeORMError
"shares alias was not found".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
TypeORM QueryBuilder leftJoinAndSelect requires "alias.relation" format
where the first segment is a previously registered alias, not a bare
entity property name. The previous fix renamed the shares alias to
"shares" to match the path in RELATIONS, but that caused TypeORM to
look up the alias and fail to find entity metadata for it.

Root cause: RELATIONS constants hold plain property paths designed for
findOne/find relations arrays; they cannot be used verbatim in QB joins.

Fix: add QB_-prefixed constants with alias-qualified paths and rewrite
all five joins in findByIdWithDays to use them consistently.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add a note to the repository conventions section explaining that
TypeORM QueryBuilder leftJoinAndSelect requires alias-qualified paths
("alias.relation"), which are incompatible with the plain property
paths used in findOne/find relations arrays. Repositories that use
both must keep them in separate QB_-prefixed constants.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
No snake_case naming strategy is configured — all multi-word DB columns
require an explicit name option in the @Column decorator (same pattern as
create_date, update_date, created_by in AbstractEntity). Without it
TypeORM used the property name "sortOrder" literally in SQL, while the
migration created the column as "sort_order".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Without a naming strategy, every camelCase entity property that maps to
a multi-word DB column needs an explicit name option in its @Column
decorator — omitting it causes TypeORM to use the camelCase name
literally in SQL, which PostgreSQL cannot resolve.

SnakeCaseNamingStrategy overrides columnName() to convert camelCase
property names to snake_case automatically. Explicit name options
always take precedence, so existing column mappings are unaffected.
Table and join column names are also unaffected since those decorators
already carry explicit names throughout the codebase.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
araemer changed title from meal-plan to feat(mealPlan) Add meal plan feature 2026-06-06 06:29:11 +00:00
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
TypeORM's default orphanedRowAction is "nullify": when a child entity is
removed from a collection and the parent is saved, TypeORM issues an UPDATE
setting the FK to NULL. All meal plan FK columns are NOT NULL, so this
produces a constraint violation instead of a delete.

Affected relations:
- MealPlanDayEntity.dishes — triggered when a dish is removed via day save-or-update
- MealPlanEntity.days — same risk if days were ever orphaned via plan save
- MealPlanEntity.shares — same risk for shares

No migration required — orphanedRowAction is ORM-level behaviour only.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
orphanedRowAction: "delete" only works on entities loaded and tracked by
TypeORM's EntityManager. The day entity passed to updateDay is freshly
constructed by the mapper (not fetched from the DB), so TypeORM has no
baseline to detect orphans from — it falls back to the default "nullify"
behaviour and issues UPDATE ... SET meal_plan_day_id = NULL, which fails
the NOT NULL constraint.

Fix: within the transaction, load the current dishes for the day before
saving, compute the set of IDs absent from the incoming collection, and
hard-delete them explicitly. TypeORM then has nothing to orphan during
the subsequent save.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Reviewed-on: #4
araemer merged commit 384b6fbaeb into stage 2026-06-14 06:40:32 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
araemer/recipe-backend!3
No description provided.