mcp: project-plan — PBS Membership LOE 1 Phase 4 — Collections

This commit is contained in:
Lovebug MCP 2026-07-24 00:44:43 +00:00
parent aebbc485c8
commit ce941c6e1b

View File

@ -0,0 +1,180 @@
---
created: '2026-07-24'
path: Sources/Dev
project: pbs-membership-loe1-phase4-collections
tags:
- pbs
- membership
- loe1
- garden
- collections
- flask
- mysql
- data-model
type: project-plan
---
# PBS Membership LOE 1 Phase 4 — Collections
## Constitution
n/a — phase within an existing LOE. Inherits the platform's locked architecture (WordPress owns identity, Garden owns features, `pbs_garden` DB, WP-cookie auth, Option D chrome).
---
## Spec
### Goal
Named, shareable groups of saved recipes. A member organizes their saves into meaningful collections; Jenny publishes collections members can subscribe to.
### Problem
3c gives members one flat, save-date-ordered list. That doesn't scale past a few dozen saves and offers no way to group by occasion, season, or theme. Jenny already publishes recipe roundups as posts — collections are the member-side equivalent of something the audience already understands.
### Why separate from meal planning (LOE 2)
Different verbs. A collection is a taxonomy — these recipes belong together. A meal plan is a schedule — this recipe on Tuesday. Collections have no time dimension; LOE 2's model is entirely time (`meal_slots`, calendars). Merging them means one inherits a model it doesn't use. They will want to talk to each other later ("add this collection to next week's plan"), which is an integration between two clean things — cheap. Untangling one merged thing is not.
**Design constraint:** collections must be handable to a meal planner later. Stable collection IDs, clean "list the recipes in this collection" read.
### Out of scope
- Meal-plan integration (LOE 2)
- `editor` role (schema leaves room; only `owner` and `subscriber` built)
- Drag-and-drop reordering — carried from the April sketch, unresolved, may not survive contact with reality
### User journeys
**Own:** Member with saves creates "Weeknight Dinners," adds recipes to it, views it, removes one, deletes it.
**Subscribe:** Jenny publishes "Holiday Sides." Member subscribes. It appears alongside their own collections, marked as not theirs. Jenny adds a recipe next week; the member sees it.
**Cascade:** Member unsaves a recipe that sits in three of their collections. They're told which three before it goes.
### Requirements
- **FR-001** — WHEN a member creates a collection, THE SYSTEM SHALL write the collection and an `owner` membership row in a single transaction.
- **FR-002** — WHEN a member views their collections, THE SYSTEM SHALL return all collections where a membership row exists for that member, with the role available to the UI.
- **FR-003** — WHEN a member adds a recipe to a collection they own, THE SYSTEM SHALL create a save for that member if one does not already exist.
- **FR-004** — WHEN a member unsaves a recipe, THE SYSTEM SHALL remove it from all collections that member owns.
- **FR-005** — WHEN an unsave would remove a recipe from one or more collections, THE SYSTEM SHALL name those collections in a confirmation before proceeding.
- **FR-006** — WHEN a member deletes a collection, THE SYSTEM SHALL leave their saves untouched.
- **FR-007** — THE SYSTEM SHALL permit a recipe to belong to any number of a member's collections.
- **FR-008** — THE SYSTEM SHALL permit a collection to be empty.
- **FR-009** — WHEN a member subscribes to a collection, THE SYSTEM SHALL write a `subscriber` membership row without copying the collection or its items.
- **FR-010** — WHEN an owner modifies a collection, THE SYSTEM SHALL reflect that change for all subscribers.
- **FR-011** — WHEN a member without an `owner` role views a collection, THE SYSTEM SHALL present it read-only.
- **FR-012** — THE SYSTEM SHALL permit more than one `owner` membership row per collection.
### Success criteria
All FRs observable in-browser. A member can organize saves into overlapping collections, and a Jenny-published collection reaches subscribers with edits propagating.
### Edge cases
- Recipe already saved when added to a collection → no duplicate save
- Recipe absent from WordPress → inherits 3c's skip-and-log
- Last owner leaves a collection with subscribers → **unresolved** (see Unresolved questions)
- Subscriber unsubscribes → membership row removed; collection and other members untouched
---
## Plan
### Decisions
| Decision | Chosen | Rejected | Reversibility |
|---|---|---|---|
| Relationship to LOE 2 | Separate feature | Collections as part of meal planning — different verbs, forces an unused time model | Two-way, but merging later is expensive |
| Item pointer | `collection_items` → recipe post ID | Point at `user_interactions` row — collides with sharing (would reference another user's rows or manufacture phantom saves); sharing requires dereferencing to the recipe anyway | One-way once data exists |
| Item polymorphism | `item_type` + `item_id`, only `recipe` used | Recipe-only column — cheap to leave room, no cost now | Two-way |
| Collections per recipe | Many | One (folder model) — forces a false choice; roundups are genuinely multi-category | Two-way |
| Control model | `collection_members` rows with `role` | Single `user_id` owner column + separate subscriptions table — can't express multiple owners; garden is intended as community-oriented | One-way in practice |
| Sharing mechanism | Subscribe — one collection, edits propagate | Copy — cheaper and satisfies the invariant naturally, but Jenny's later edits never reach members | Two-way |
| Save/collection coupling | Adding to an owned collection creates the save | Independent — would break the must-be-saved invariant | Two-way |
| Unsave behavior | Cascades out of the member's owned collections | Independent — leaves collections holding unsaved recipes, contradicting the invariant | Two-way |
| Collection deletion | Does not touch saves | Cascade to saves — destructive and surprising | Two-way |
### Data model
Two new tables in `pbs_garden`:
**`collections`** — id, name, timestamps. No owner column; ownership is a membership row.
**`collection_members`** — collection_id, user_id, role (`owner` | `subscriber`; `editor` reserved), timestamp. Unique on (collection_id, user_id).
**`collection_items`** — collection_id, item_type (`recipe`), item_id (WP post ID), timestamp. Unique on (collection_id, item_type, item_id).
No changes to `user_interactions`. The must-be-saved invariant is enforced in application logic, not schema.
**Known consequence:** with ownership in membership rows, nothing at the schema level guarantees a collection has an owner at all. Creation transaction handles the happy path; the owner-departure case is unresolved below.
### Interfaces / contracts
JSON endpoints under the garden blueprint, inheriting 3c's CSRF pattern for all writes — token in page, sent as header, validated server-side. Reads need no token.
**Writes:** create collection, rename, delete, add item, remove item, subscribe, unsubscribe.
**Reads:** list my collections (owned + subscribed, role included), list items in a collection.
### Deployment
Inherits 3a — existing `pbs-garden` container, Traefik routing. New tables require a migration.
### Tests
Deferred to the end-of-LOE hardening pass, consistent with 3c. Two worth writing then: the cascade (unsave removes from all owned collections) and the creation transaction (collection without owner row must not persist).
### NFRs
The "which collections is this recipe in" read on the flat saved list is an extra query per row if done naively — batch it. Otherwise n/a at this scale.
---
## Tasks
### Phase 4 scope
- Migration for three tables
- Create/rename/delete collection + owner membership transaction (FR-001, FR-006)
- Add/remove item with save-creation (FR-003, FR-007, FR-008)
- Collections list read with role (FR-002, FR-011)
- Unsave cascade + confirmation (FR-004, FR-005)
- Subscribe/unsubscribe (FR-009, FR-010, FR-012)
### Iterate-shaped
**Collection UI and the cascade confirmation wording** — converge in-browser.
### Human-required handoffs
- Jenny's collection cards may want their own seed-packet treatment; asset decision
- Jenny defines the first published collections
---
## Cross-cutting
### Codebase assumptions
3c shipped: the saved-recipes page, the JS-write-plus-CSRF pattern, hard delete on unsave with optimistic undo, WP featured image as canonical thumbnail with fallback, WP-absence skip-and-log.
### Unresolved questions
**Must-resolve before code:**
- **Does the must-be-saved invariant apply to subscribed collections?** Either propagation auto-saves into every subscriber's account when an owner adds a recipe, or the invariant is scoped to owned collections and subscribed ones are view-only lists. Travis is thinking on it. The read-only reading is consistent with FR-011. **Note: this decision impacts the relational data structure** — it is not purely an application-logic or UX choice.
**Defer and tag:**
- Last owner leaving a collection that has subscribers
- Whether the flat saved list displays collection membership
**Soft — revisit in testing:**
- The must-be-saved invariant generally. If it proves brittle, drop it and let collections hold unsaved recipes.
### Pre-mortem
Most likely failure is the invariant. It's enforced in application logic across four separate paths — add item, unsave, subscribe, and collection delete — and a miss in any one leaves collections holding unsaved recipes. Second: the creation transaction — a collection whose owner row failed to write is invisible and unrecoverable through the UI. Third: the cascade confirmation is the first destructive multi-object action in the garden, and getting it wrong means silently destroying curation a member spent real effort on.