Files
familysync/.planning/phases/04-shared-lists-live-sync/04-03-PLAN.md
T

14 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, user_setup, must_haves
phase plan type wave depends_on files_modified autonomous requirements user_setup must_haves
04-shared-lists-live-sync 03 execute 2
04-01
apps/api/src/routes/lists.ts
apps/api/src/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
true
LIST-01
truths artifacts key_links
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)
path provides exports
apps/api/src/routes/lists.ts POST/GET/PATCH/DELETE /api/lists with scoped access + zod validation
listsRouter
path provides min_lines
apps/pwa/src/components/CreateListSheet.tsx new-list form with shared/private toggle (default shared) 30
path provides min_lines
apps/pwa/src/components/ListCard.tsx list summary card navigating to /lists/:id 25
path provides min_lines
apps/pwa/src/components/ListDeleteDialog.tsx list-delete confirmation (D-06) 25
from to via pattern
apps/pwa/src/routes/ListsIndex.tsx /api/lists useQuery + useMutation in listsClient fetchLists|createList
from to via pattern
apps/api/src/routes/lists.ts list_shares auto-insert shares on create + scoped GET listShares
from to via pattern
apps/api/src/index.ts listsRouter app.route('/api/lists', listsRouter) api/lists
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.

<execution_context> @$HOME/.claude/gsd-core/workflows/execute-plan.md @$HOME/.claude/gsd-core/templates/summary.md </execution_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 Task 1: Lists router — POST/GET/PATCH/DELETE /api/lists with scoped access (LIST-01, D-01/D-02/D-04/D-06) apps/api/src/routes/lists.ts, apps/api/src/routes/lists.test.ts, apps/api/src/index.ts - apps/api/src/routes/events.ts (full — resolveUserId, zod schemas, handler/try-catch/401 conventions) - apps/api/src/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) - 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. 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.
pnpm --filter @familysync/api exec vitest run src/routes/lists.test.ts && grep -q "app.route('/api/lists'" apps/api/src/index.ts && pnpm --filter @familysync/api typecheck - 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. POST/GET/PATCH/DELETE /api/lists work with server-enforced scoped access and auto-share-on-create; tests green. Task 2: ListsIndex wired to real data + ListCard + CreateListSheet + ListDeleteDialog (LIST-01, D-01/D-06) 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 - 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" 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.
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 - 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. User can create (shared by default) and delete named lists through the UI, backed by scoped API; counts and sharing badge render.

<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>
pnpm --filter @familysync/api exec vitest run src/routes/lists.test.ts && pnpm --filter @familysync/pwa exec tsc --noEmit - `playwright-cli`: create + delete a list end to end. - D-04 negative test green.

<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>
Create `.planning/phases/04-shared-lists-live-sync/04-03-SUMMARY.md` when done.