Files
familysync/.planning/milestones/v1.0-phases/04-shared-lists-live-sync/04-03-PLAN.md
T
Lucas Berger 982438dc10 style(13-03): apply Prettier formatting across repo
Mechanical reformat — no logic changes. 398 files changed, 19125
insertions(+), 16457 deletions(-). Prettier 3.8.4 with .prettierrc
(singleQuote:true, semi:true, tabWidth:2, trailingComma:all,
printWidth:100). Isolated per D-13-08 for reviewability.
2026-06-11 20:35:18 -04:00

208 lines
15 KiB
Markdown

---
phase: 04-shared-lists-live-sync
plan: 03
type: execute
wave: 2
depends_on: ['04-01']
files_modified:
- apps/api/src/routes/lists.ts
- apps/api/tests/routes/lists.test.ts
- apps/api/src/index.ts
- apps/pwa/src/api/listsClient.ts
- apps/pwa/src/routes/ListsIndex.tsx
- apps/pwa/src/components/ListCard.tsx
- apps/pwa/src/components/CreateListSheet.tsx
- apps/pwa/src/components/ListDeleteDialog.tsx
- apps/pwa/src/components/ListsEmptyState.tsx
autonomous: true
requirements: [LIST-01]
user_setup: []
must_haves:
truths:
- 'A member can create a named list and it appears in their lists'
- 'A new shared list auto-populates list_shares rows for the other household members (D-01/D-02)'
- 'GET /api/lists returns only lists the member owns or that are shared with them (D-04)'
- 'A member can delete a list (with confirmation) and its items/shares cascade-delete (D-06)'
artifacts:
- path: 'apps/api/src/routes/lists.ts'
provides: 'POST/GET/PATCH/DELETE /api/lists with scoped access + zod validation'
exports: ['listsRouter']
- path: 'apps/pwa/src/components/CreateListSheet.tsx'
provides: 'new-list form with shared/private toggle (default shared)'
min_lines: 30
- path: 'apps/pwa/src/components/ListCard.tsx'
provides: 'list summary card navigating to /lists/:id'
min_lines: 25
- path: 'apps/pwa/src/components/ListDeleteDialog.tsx'
provides: 'list-delete confirmation (D-06)'
min_lines: 25
key_links:
- from: 'apps/pwa/src/routes/ListsIndex.tsx'
to: '/api/lists'
via: 'useQuery + useMutation in listsClient'
pattern: 'fetchLists|createList'
- from: 'apps/api/src/routes/lists.ts'
to: 'list_shares'
via: 'auto-insert shares on create + scoped GET'
pattern: 'listShares'
- from: 'apps/api/src/index.ts'
to: 'listsRouter'
via: "app.route('/api/lists', listsRouter)"
pattern: 'api/lists'
---
<objective>
Deliver the list-CRUD vertical slice end to end (LIST-01): a member can create a named list (defaulting to Shared), see it in their list index, and delete it with confirmation. The slice spans UI (ListsIndex/ListCard/CreateListSheet/ListDeleteDialog) → API (POST/GET/PATCH/DELETE /api/lists) → DB (lists + list_shares), with server-enforced scoped access (D-04) so a member only ever sees their own and shared lists.
MVP slice: after this plan a real user can create and delete lists — a capability they did not have after Plan 01's empty shell.
Purpose: Establish the lists router (the analog every later list/item endpoint extends) with correct access control and the auto-share-on-create behavior, plus the lists-index UI.
Output: listsRouter mounted at /api/lists; ListsIndex wired to real data; CreateListSheet + ListCard + ListDeleteDialog; listsClient typed functions.
</objective>
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/phases/04-shared-lists-live-sync/04-CONTEXT.md
@.planning/phases/04-shared-lists-live-sync/04-RESEARCH.md
@.planning/phases/04-shared-lists-live-sync/04-PATTERNS.md
@.planning/phases/04-shared-lists-live-sync/04-UI-SPEC.md
@.planning/phases/04-shared-lists-live-sync/04-VALIDATION.md
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Lists router — POST/GET/PATCH/DELETE /api/lists with scoped access (LIST-01, D-01/D-02/D-04/D-06)</name>
<files>apps/api/src/routes/lists.ts, apps/api/tests/routes/lists.test.ts, apps/api/src/index.ts</files>
<read_first>
- apps/api/src/routes/events.ts (full — resolveUserId, zod schemas, handler/try-catch/401 conventions)
- apps/api/tests/routes/lists.test.ts (RED stub from Plan 01)
- apps/api/src/index.ts (route mount order)
- apps/api/src/auth/user.ts (upsertUser, deriveDisplayName signatures)
- apps/api/src/db/schema.ts (lists, listShares, listItems, users)
- .planning/phases/04-shared-lists-live-sync/04-PATTERNS.md §"apps/api/src/routes/lists.ts" + §"Shared Patterns"
- .planning/phases/04-shared-lists-live-sync/04-RESEARCH.md §"Open Questions" item 3 (auto-populate list_shares)
</read_first>
<behavior>
- Test: POST /api/lists { name, isShared:true } inserts a lists row owned by the caller AND inserts list_shares rows for every other user (not the creator). (LIST-01, D-01, Open Question 3)
- Test: POST /api/lists { name, isShared:false } inserts the list with NO list_shares rows.
- Test: GET /api/lists returns lists where owner_id = caller OR caller is in list_shares; does NOT return another member's private list (D-04 security-critical).
- Test: GET /api/lists includes an item-count summary per list (active/done) for the card badge; assert the field is present.
- Test: DELETE /api/lists/:id by the owner removes the list and cascades items + shares; a non-owner/non-sharee gets 403; unknown id gets 404.
- Test: PATCH /api/lists/:id updates name and/or isShared by an authorized member; toggling isShared false→true (re)populates shares, true→false removes non-owner shares.
- Test: zod rejects name > 255 or empty.
</behavior>
<action>
Create apps/api/src/routes/lists.ts exporting `listsRouter` (Hono). Copy the `resolveUserId` helper verbatim from events.ts (per project convention it is duplicated per router, not extracted). Apply the 401 guard + try/catch-503 conventions on every handler. Define zod schemas: createListSchema (name 1..255, isShared default true), patchListSchema (name?/isShared?, at least one).
Implement handlers: POST / (create list; if isShared, query users for all member ids except creator and insert list_shares rows — YAGNI auto-share per Open Question 3); GET / (scoped select: owner_id = caller OR id IN list_shares.userId = caller, returning id/name/isShared/ownerId + per-list item counts); PATCH /:id (authorized update of name/isShared, reconciling list_shares on visibility change); DELETE /:id (owner-only delete is the safe default; cascade handles items/shares). Verify list access with the ownership/share-check pattern from 04-PATTERNS before any mutation.
Mount in index.ts: `import { listsRouter }` and `app.route('/api/lists', listsRouter)` after the sseRouter mount (so it sits behind the OIDC/dev-bypass guard). Do NOT add fan-out emit calls here yet — Plan 06 adds publishListEvent triggers once the SSE endpoint exists (leave a commented seam, note it in SUMMARY). NOTE: per-field item PATCH and item endpoints are Plan 04; this plan is lists only.
</action>
<verify>
<automated>pnpm --filter @familysync/api exec vitest run tests/routes/lists.test.ts && grep -q "app.route('/api/lists'" apps/api/src/index.ts && pnpm --filter @familysync/api typecheck</automated>
</verify>
<acceptance_criteria>
- lists.test.ts: all create/get/delete/patch/scope tests green, including the D-04 "private list of another member is NOT returned by GET /api/lists" assertion.
- Shared-create auto-inserts list_shares for other members; private-create inserts none.
- listsRouter mounted at /api/lists in index.ts; typecheck passes.
</acceptance_criteria>
<done>POST/GET/PATCH/DELETE /api/lists work with server-enforced scoped access and auto-share-on-create; tests green.</done>
</task>
<task type="auto">
<name>Task 2: ListsIndex wired to real data + ListCard + CreateListSheet + ListDeleteDialog (LIST-01, D-01/D-06)</name>
<files>apps/pwa/src/api/listsClient.ts, apps/pwa/src/routes/ListsIndex.tsx, apps/pwa/src/components/ListCard.tsx, apps/pwa/src/components/CreateListSheet.tsx, apps/pwa/src/components/ListDeleteDialog.tsx, apps/pwa/src/components/ListsEmptyState.tsx</files>
<read_first>
- apps/pwa/src/routes/ListsIndex.tsx (placeholder shell from Plan 01)
- apps/pwa/src/api/client.ts (credentials:'include' fetch convention)
- apps/pwa/src/api/listsClient.ts (fetchLists/List from Plan 01, if present)
- apps/pwa/src/components/DeleteConfirmationDialog.tsx (modal/focus-trap/CSS-token pattern to mirror)
- apps/pwa/src/store/listsStore.ts (createListSheetOpen)
- .planning/phases/04-shared-lists-live-sync/04-UI-SPEC.md §"ListsIndex", §"ListCard", §"CreateListSheet", §"ListsEmptyState", §"Sharing Toggle", §"Copywriting Contract", §"List Delete"
- .planning/phases/04-shared-lists-live-sync/04-PATTERNS.md §"ListsIndex.tsx", §"listsClient.ts", §"DeleteConfirmationDialog reuse"
</read_first>
<action>
Expand apps/pwa/src/api/listsClient.ts with credentials:'include' typed functions: fetchLists, createList({name,isShared}), patchList(id, {...}), deleteList(id), plus List/ListItem types (ListItem used by Plan 04). Follow the client.ts apiFetch wrapper convention.
Build CreateListSheet.tsx per UI-SPEC: bottom sheet (mobile) / centered modal (desktop), heading "New list", auto-focused name input (placeholder "e.g. Groceries"), Shared/Private toggle defaulting to Shared (D-01), "Create" button (accent var(--color-member-0), disabled while name empty, destructive border on blank-submit attempt), "Cancel". On create: useMutation(createList) with optimistic insert into ['lists'] + onError rollback + onSettled invalidate; close sheet on success. Open/close driven by listsStore.createListSheetOpen.
Build ListCard.tsx per UI-SPEC: rounded card, list name (heading), "N items / N active · M done" badge, "Shared" pill for shared lists (nothing for private), ChevronRight; whole card taps through to /lists/:id via react-router navigate/Link; swipe/long-press (phone) or hover X (desktop) reveals Delete which opens ListDeleteDialog. All user text as plain-text JSX (XSS guard).
Build ListDeleteDialog.tsx by mirroring DeleteConfirmationDialog structure (do NOT modify the existing one — it is wired to calendarStore): same modal layout, backdrop, role="dialog"/aria-modal, Escape-to-close, focus-on-open, CSS tokens; heading "Delete list?", body '"{name}" and all its items will be permanently removed.', Cancel + destructive Delete (D-06). On confirm: useMutation(deleteList) optimistic removal from ['lists'] + navigate back to /lists; failure toast "Couldn't delete. Try again."
Replace the ListsIndex placeholder card stack with real ListCard rendering from useQuery(['lists']); ListsEmptyState when zero lists; FAB ("+ New List") opens CreateListSheet.
</action>
<verify>
<automated>pnpm --filter @familysync/pwa exec tsc --noEmit && pnpm --filter @familysync/pwa exec vitest run src/components/DeleteConfirmationDialog.test.tsx 2>&1 | grep -Eiq 'passed' && grep -q "createList" apps/pwa/src/api/listsClient.ts</automated>
</verify>
<acceptance_criteria>
- listsClient exports fetchLists/createList/patchList/deleteList + List/ListItem types.
- CreateListSheet defaults to Shared, disables Create on empty name, creates via optimistic mutation.
- ListCard shows name + count badge + "Shared" pill (shared only) and navigates to /lists/:id.
- ListDeleteDialog confirms before delete and does not modify DeleteConfirmationDialog.tsx.
- PWA typecheck passes; existing DeleteConfirmationDialog test still green.
- Browser check (`playwright-cli`): create a list named "Groceries" → it appears as a card with a "Shared" pill; open delete dialog → confirm → card disappears. Record in SUMMARY.
</acceptance_criteria>
<done>User can create (shared by default) and delete named lists through the UI, backed by scoped API; counts and sharing badge render.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
| -------------------- | ----------------------------------------------------- |
| browser → /api/lists | client supplies name/isShared/list id — all untrusted |
| API → MariaDB | scoped queries enforce who can see/mutate a list |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
| --------- | ---------------------- | --------------------------------------------------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| T-04-05 | Elevation of Privilege | accessing another member's private list via direct id (GET/DELETE/PATCH /api/lists/:id) | mitigate | Every handler resolves caller via resolveUserId and verifies owner_id OR list_shares before returning/mutating; 403 otherwise; tested |
| T-04-02 | Information Disclosure | GET /api/lists leaking non-shared lists | mitigate | Scoped WHERE owner_id = caller OR id IN list_shares; negative test asserts another member's private list is absent (D-04) |
| T-04-06 | Tampering | XSS via list name | mitigate | List names rendered as plain-text JSX children only; no dangerouslySetInnerHTML (T-03-15 pattern) |
| T-04-07 | Tampering | overposting on PATCH (fields beyond name/isShared) | mitigate | zod patchListSchema whitelists name/isShared only |
| T-04-08 | Elevation of Privilege | self-adding to list_shares | mitigate | Shares are server-managed only (auto-populated on create/visibility change); no client-writable shares endpoint exposed in Phase 4 |
</threat_model>
<verification>
<automated>pnpm --filter @familysync/api exec vitest run tests/routes/lists.test.ts && pnpm --filter @familysync/pwa exec tsc --noEmit</automated>
- `playwright-cli`: create + delete a list end to end.
- D-04 negative test green.
</verification>
<success_criteria>
- LIST-01 satisfied: create + delete named lists end to end.
- Shared-by-default with server-managed list_shares; scoped GET enforced.
- listsRouter is the analog later item/SSE plans extend.
</success_criteria>
<artifacts_produced>
**Symbols/files this plan creates (exclude from drift verification):**
- `apps/api/src/routes/lists.ts` exporting `listsRouter` (POST/GET/PATCH/DELETE /api/lists); local `resolveUserId` copy
- `app.route('/api/lists', listsRouter)` mount in apps/api/src/index.ts
- `apps/pwa/src/api/listsClient.ts`: `fetchLists`, `createList`, `patchList`, `deleteList`, types `List`, `ListItem`
- Components: `CreateListSheet`, `ListCard`, `ListDeleteDialog`, `ListsEmptyState`
- Real-data `ListsIndex` (replaces Plan 01 placeholder)
</artifacts_produced>
<output>
Create `.planning/phases/04-shared-lists-live-sync/04-03-SUMMARY.md` when done.
</output>