docs(03): finalize phase plan (8 plans, verified)
This commit is contained in:
+5
-5
@@ -3,8 +3,8 @@ gsd_state_version: 1.0
|
||||
milestone: v1.0
|
||||
milestone_name: milestone
|
||||
status: planning
|
||||
stopped_at: Phase 3 context gathered
|
||||
last_updated: "2026-06-05T20:17:26.434Z"
|
||||
stopped_at: Phase 03 UI-SPEC approved
|
||||
last_updated: "2026-06-05T21:08:16.025Z"
|
||||
last_activity: 2026-06-05 -- Phase 2 completed
|
||||
progress:
|
||||
total_phases: 6
|
||||
@@ -95,6 +95,6 @@ Recent decisions affecting current work:
|
||||
|
||||
## Session Continuity
|
||||
|
||||
Last session: 2026-06-05T20:17:26.429Z
|
||||
Stopped at: Phase 3 context gathered
|
||||
Resume file: .planning/phases/03-event-write-back-pwa-install/03-CONTEXT.md
|
||||
Last session: 2026-06-05T20:40:21.988Z
|
||||
Stopped at: Phase 03 UI-SPEC approved
|
||||
Resume file: .planning/phases/03-event-write-back-pwa-install/03-UI-SPEC.md
|
||||
|
||||
@@ -75,7 +75,7 @@ five RED test files.
|
||||
New symbols introduced across Phase 3 (excluded from drift verification):
|
||||
- DB: `calendarOutbox` table (`calendar_outbox`), `calendarEvents.objectUrl` column (`object_url`)
|
||||
- Backend files: `apps/api/src/broker/vevent.ts` (`buildVeventString`, `NewEventParams`), `apps/api/src/broker/write.ts` (`createCalendarEvent`, `updateCalendarEvent`, `deleteCalendarEvent`), `apps/api/src/broker/outboxWorker.ts` (`runOutboxDrain`, `startOutboxWorker`, `RRULE_PRESETS`)
|
||||
- Backend routes: `POST /api/events/create`, `PATCH /api/events/:uid/edit`, `DELETE /api/events/:uid`, `GET /api/events/sync-status`
|
||||
- Backend routes: `POST /api/events/create`, `PATCH /api/events/:uid/edit`, `DELETE /api/events/:uid`, `GET /api/events/sync-status`, `GET /api/events/writable-calendars`
|
||||
- Frontend files: `apps/pwa/src/components/EventForm.tsx`, `apps/pwa/src/components/InstallPrompt.tsx`, `apps/pwa/src/components/SyncStateToast.tsx`, `apps/pwa/src/components/DeleteConfirmationDialog.tsx`
|
||||
- Frontend client fns: `createEvent`, `updateEvent`, `deleteEvent`, `fetchSyncStatus`, `fetchWritableCalendars`
|
||||
- Zustand keys: `eventFormOpen`, `eventFormMode`, `eventFormUid`, `deleteDialogOpen`, `deleteDialogUid`, `lastSyncedUid`
|
||||
@@ -170,7 +170,7 @@ New symbols introduced across Phase 3 (excluded from drift verification):
|
||||
- vevent.test.ts: buildVeventString produces VCALENDAR with VEVENT for a timed event (DTSTART with Z/UTC); for an all-day event a DATE value (no time component, no TZID) per D-13; with rruleString produces an RRULE property (CAL-04, CAL-07).
|
||||
- write.test.ts: createCalendarEvent calls client.createCalendarObject with `${uid}.ics` filename; updateCalendarEvent passes etag into the calendarObject (If-Match); deleteCalendarEvent passes etag; each returns the raw Response (mock client).
|
||||
- outboxWorker.test.ts: runOutboxDrain transitions pending→done on mock 204; pending→failed on mock 412 (and triggers re-sync, no retry); pending→backoff (nextAttemptAt advanced, attemptCount++) on mock 500; pending→dead at MAX_ATTEMPTS; edit-as-move emits a create row processed BEFORE the linked delete row (D-04/D-07/D-08).
|
||||
- events.test.ts (extend existing): POST /api/events/create returns 202 + inserts a pending outbox row; PATCH /api/events/:uid/edit returns 202 + inserts row with etag; DELETE /api/events/:uid returns 202 + inserts delete row; GET /api/events/sync-status?uid= returns the outbox status; create rejects writing to a calendar not owned by the user with 403 (D-03 / V4 access control).
|
||||
- events.test.ts (extend existing): POST /api/events/create returns 202 + inserts a pending outbox row; PATCH /api/events/:uid/edit returns 202 + inserts row with etag; DELETE /api/events/:uid returns 202 + inserts delete row; GET /api/events/sync-status?uid= returns the outbox status; GET /api/events/writable-calendars returns the member's writable set (own personal + shared `isShared=1`) and NEVER another member's read-only personal calendar (different userId, isShared=false) — D-03 / V4; create rejects writing to a calendar not owned by the user with 403 (D-03 / V4 access control).
|
||||
- InstallPrompt.test.tsx: isIOSSafariNonStandalone() returns true for a mock iOS Safari non-standalone UA and false in standalone; useAndroidInstallPrompt sets canInstall=true when a mock beforeinstallprompt event dispatches.
|
||||
</behavior>
|
||||
<action>
|
||||
@@ -182,6 +182,7 @@ New symbols introduced across Phase 3 (excluded from drift verification):
|
||||
<acceptance_criteria>
|
||||
- All five test files exist.
|
||||
- `pnpm --filter @familysync/api test -- broker/vevent` reports failures or unresolved imports (RED — implementation not present).
|
||||
- The events.test.ts scaffold includes a `writable-calendars` describe block (`grep -c "writable-calendars" apps/api/tests/routes/events.test.ts` ≥1).
|
||||
- The existing GET /api/events describe block is still present in events.test.ts (`grep -c "GET /api/events" apps/api/tests/routes/events.test.ts` ≥1).
|
||||
</acceptance_criteria>
|
||||
<done>Five RED test files exist and fail because their target modules are unimplemented; existing tests preserved.</done>
|
||||
|
||||
@@ -18,10 +18,11 @@ must_haves:
|
||||
- "A member cannot enqueue a write to a calendar they do not own (403) — D-03 / V4 access control"
|
||||
- "GET /api/events/sync-status?uid= returns the outbox status for that member's UID"
|
||||
- "Edit that changes the target calendar enqueues a linked delete+create pair in one transaction (D-04)"
|
||||
- "GET /api/events/writable-calendars returns the member's writable set per D-03 — own personal + shared Family (read-write); never the other member's read-only personal"
|
||||
artifacts:
|
||||
- path: "apps/api/src/routes/events.ts"
|
||||
provides: "create/edit/delete write endpoints + sync-status, all enqueue-only (broker boundary)"
|
||||
contains: "/sync-status"
|
||||
provides: "create/edit/delete write endpoints + sync-status + writable-calendars, all enqueue-only (broker boundary)"
|
||||
contains: "/writable-calendars"
|
||||
key_links:
|
||||
- from: "apps/api/src/routes/events.ts"
|
||||
to: "calendarOutbox"
|
||||
@@ -35,17 +36,21 @@ must_haves:
|
||||
|
||||
<objective>
|
||||
Add the write API surface to the events router: `POST /create`, `PATCH /:uid/edit`,
|
||||
`DELETE /:uid`, and `GET /sync-status`. Every write endpoint validates with zod,
|
||||
asserts the target calendar belongs to the current member (D-03), and ENQUEUES an
|
||||
outbox row — it never calls Fastmail (broker boundary, D-12). The endpoints return 202
|
||||
immediately so the UI can optimistically accept (D-05). sync-status exposes the outbox
|
||||
state for the polled toast (D-09).
|
||||
`DELETE /:uid`, `GET /sync-status`, and `GET /writable-calendars`. Every write endpoint
|
||||
validates with zod, asserts the target calendar belongs to the current member (D-03), and
|
||||
ENQUEUES an outbox row — it never calls Fastmail (broker boundary, D-12). The endpoints
|
||||
return 202 immediately so the UI can optimistically accept (D-05). sync-status exposes the
|
||||
outbox state for the polled toast (D-09). writable-calendars exposes the member's authorized
|
||||
write target set (D-03) so the client picker (Plan 05) renders only legal targets and honors
|
||||
the D-02 single-calendar hide rule.
|
||||
|
||||
Purpose: this is the backend half of the create/edit/delete vertical slices. It depends
|
||||
only on the outbox schema (Plan 01); it does not import the worker or write.ts (those
|
||||
drain the queue the endpoints fill).
|
||||
drain the queue the endpoints fill). The writable-calendars endpoint is the authoritative
|
||||
owner of the D-03 writable-set authorization — the client never derives it.
|
||||
|
||||
Output: extended events.ts, GREEN against the create/edit/delete/sync-status tests from Plan 01.
|
||||
Output: extended events.ts, GREEN against the create/edit/delete/sync-status/writable-calendars
|
||||
tests from Plan 01.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@@ -117,6 +122,36 @@ Output: extended events.ts, GREEN against the create/edit/delete/sync-status tes
|
||||
<done>GET /api/events/sync-status returns the member-scoped outbox status; tests GREEN.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 3: GREEN — GET /api/events/writable-calendars (D-03 writable set, authoritative)</name>
|
||||
<files>apps/api/src/routes/events.ts, apps/api/tests/routes/events.test.ts</files>
|
||||
<read_first>
|
||||
- apps/api/tests/routes/events.test.ts (extend — add a `GET /api/events/writable-calendars` describe block alongside the create/edit/delete/sync-status stubs)
|
||||
- apps/api/src/routes/events.ts (existing GET / handler — mirror its auth + db.select + try/catch shape)
|
||||
- apps/api/src/db/schema.ts (`calendars` table — `url`, `displayName`, `color`, `userId`, `isShared` columns)
|
||||
- .planning/phases/03-event-write-back-pwa-install/03-RESEARCH.md (§Open Questions Q3 — writable-set resolution query; §Security Domain V4 — D-03 access control)
|
||||
- .planning/phases/03-event-write-back-pwa-install/03-CONTEXT.md (D-02 picker-visibility, D-03 writable set)
|
||||
- .planning/phases/03-event-write-back-pwa-install/03-PATTERNS.md (§events.ts, §Auth guard in write route handlers)
|
||||
</read_first>
|
||||
<action>
|
||||
Add `eventsRouter.get('/writable-calendars', ...)`. Resolve the current member id with the same dev-bypass + `getAuth(c)` pattern as the write endpoints (401 if neither). This endpoint is the AUTHORITATIVE owner of the D-03 writable-set authorization — the client (Plan 05) consumes it verbatim and never derives the set itself.
|
||||
|
||||
Per RESEARCH.md Open Q3: select the writable set = rows in `calendars WHERE userId = currentUser.id` (the member's own personal calendar(s)) UNION rows WHERE `isShared = 1` (the shared Family calendar, when read-write to the household). Express this as a single Drizzle query with `WHERE eq(calendars.userId, currentUser.id) OR eq(calendars.isShared, true)`. The other member's personal calendar (a row with a different `userId` and `isShared = 0/false`) MUST NOT appear — it is a read-only overlay only (D-03), never a write target.
|
||||
|
||||
Map each row to the response shape `{ calendars: [{ url, displayName, color, isShared }] }` (exactly the `WritableCalendar` shape Plan 05's `fetchWritableCalendars` consumes). Wrap the db work in try/catch returning 503 per the existing GET handler pattern. Do NOT include any Fastmail call (broker boundary).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/luc/Projects/familysync && pnpm --filter @familysync/api test -- routes/events && grep -q "/writable-calendars" apps/api/src/routes/events.ts && pnpm --filter @familysync/api exec tsc --noEmit</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- writable-calendars test GREEN: returns only the member's own personal calendar(s) plus the shared (`isShared=1`) calendar.
|
||||
- The test asserts another member's personal calendar (different userId, isShared=false) is NEVER returned (D-03 / V4).
|
||||
- Response items expose `url`, `displayName`, `color`, `isShared` (the picker's `WritableCalendar` shape).
|
||||
- `grep -c "/writable-calendars" apps/api/src/routes/events.ts` ≥1.
|
||||
</acceptance_criteria>
|
||||
<done>GET /api/events/writable-calendars returns the D-03 writable set (own personal + shared Family), never another member's read-only personal; response matches the Plan 05 WritableCalendar shape; tests GREEN.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
@@ -125,7 +160,7 @@ Output: extended events.ts, GREEN against the create/edit/delete/sync-status tes
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| client → write API | Untrusted member input (event fields, target calendar, uid) crosses here |
|
||||
| member A → member B data | A member must never write to or read another member's outbox/calendar |
|
||||
| member A → member B data | A member must never write to, treat-as-writable, or read another member's outbox/calendar |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
@@ -136,17 +171,18 @@ Output: extended events.ts, GREEN against the create/edit/delete/sync-status tes
|
||||
| T-03-08 | Tampering | XSS/oversized payload via title/location/description | mitigate | zod length bounds (title 255, location/description 2000); plain-text storage; rendered as JSX children downstream |
|
||||
| T-03-09 | Tampering | SQL injection via uid/calendarUrl | mitigate | Drizzle parameterized queries; no string interpolation |
|
||||
| T-03-10 | Spoofing | client-supplied etag bypassing conflict detection | mitigate | etag read from calendarEvents server-side at enqueue; client never supplies it |
|
||||
| T-03-11 | Elevation of Privilege | writable-calendars surfacing another member's personal calendar as a write target | mitigate | Query restricted to `userId = currentUser.id OR isShared = true`; another member's `isShared=false` personal row is never returned; client treats the response as authoritative and the write endpoints re-enforce D-03 on enqueue |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `pnpm --filter @familysync/api test -- routes/events` GREEN (create, edit, delete, sync-status, 403 ownership).
|
||||
- `pnpm --filter @familysync/api test -- routes/events` GREEN (create, edit, delete, sync-status, writable-calendars, 403 ownership).
|
||||
- `pnpm --filter @familysync/api exec tsc --noEmit` passes.
|
||||
- No tsdav import in events.ts (broker boundary): `grep -c "tsdav\|createFastmailClient" apps/api/src/routes/events.ts` returns 0.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- All four write/status endpoints enqueue-only and member-scoped.
|
||||
- D-03 ownership enforced; D-04 edit-as-move pair transactional; D-09 polling endpoint live.
|
||||
- All five write/status/writable-calendars endpoints enqueue-only and member-scoped.
|
||||
- D-03 ownership enforced on both the write path and the writable-calendars listing; D-04 edit-as-move pair transactional; D-09 polling endpoint live.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
|
||||
@@ -31,6 +31,10 @@ must_haves:
|
||||
to: "/api/events/create"
|
||||
via: "createEvent mutation"
|
||||
pattern: "createEvent"
|
||||
- from: "apps/pwa/src/api/client.ts"
|
||||
to: "/api/events/writable-calendars"
|
||||
via: "fetchWritableCalendars GET"
|
||||
pattern: "writable-calendars"
|
||||
- from: "apps/pwa/src/components/CalendarShell.tsx"
|
||||
to: "EventForm"
|
||||
via: "New Event FAB toggles eventFormOpen"
|
||||
@@ -47,6 +51,9 @@ open the form and submit a write (delete + sync feedback land in Plan 06).
|
||||
Purpose: CAL-04 (create timed/all-day) and CAL-07 (create recurring) become user-reachable.
|
||||
Built against the UI Design Contract (03-UI-SPEC.md) for fields, copy, tokens, and
|
||||
interaction; reuses the Phase 2 EventDetailPopover overlay/focus-trap/responsive pattern (D-10).
|
||||
The calendar picker is populated from the authoritative `GET /api/events/writable-calendars`
|
||||
endpoint (added in Plan 03) — the writable set (D-03) is owned by the server, not derived
|
||||
on the client.
|
||||
|
||||
Output: EventForm + client write calls + store keys + FAB, all wired to the Plan 03 API.
|
||||
</objective>
|
||||
@@ -75,28 +82,30 @@ Output: EventForm + client write calls + store keys + FAB, all wired to the Plan
|
||||
- apps/pwa/src/store/calendarStore.ts (existing — CalendarStore interface + create() pattern)
|
||||
- .planning/phases/03-event-write-back-pwa-install/03-UI-SPEC.md (§State Management Contract — Zustand keys; §EventForm fields → request shape)
|
||||
- .planning/phases/03-event-write-back-pwa-install/03-PATTERNS.md (§client.ts — POST/PATCH fetch shape; §Zustand UI state)
|
||||
- .planning/phases/03-event-write-back-pwa-install/03-03-PLAN.md (Task 3 — GET /api/events/writable-calendars response shape `{ calendars: [{ url, displayName, color, isShared }] }`)
|
||||
</read_first>
|
||||
<behavior>
|
||||
Tests (extend pwa test suite where one exists, else add a small client unit test):
|
||||
- createEvent posts to /api/events/create with credentials:'include' and JSON body; returns { uid } on 202.
|
||||
- updateEvent PATCHes /api/events/:uid/edit.
|
||||
- fetchWritableCalendars GETs the writable-calendar set.
|
||||
- fetchWritableCalendars GETs /api/events/writable-calendars and returns the WritableCalendar[] from the response's `calendars` array.
|
||||
- The Zustand store exposes the new keys with correct defaults.
|
||||
</behavior>
|
||||
<action>
|
||||
In client.ts add exported interfaces `CreateEventPayload` (title, allDay, start, end, optional location, description, recurrence: 'none'|'daily'|'weekly'|'monthly'|'yearly', calendarUrl?), `CreateEventResponse` ({ uid }), `WritableCalendar` ({ url, displayName, color, isShared }). Add `createEvent(payload): Promise<CreateEventResponse>` (POST), `updateEvent(uid, payload): Promise<CreateEventResponse>` (PATCH `/api/events/${uid}/edit`), and `fetchWritableCalendars(): Promise<WritableCalendar[]>` (GET `/api/events/writable-calendars` — if Plan 03 did not add this endpoint, derive the writable set on the client from the existing calendars data; document which). All follow the existing fetch shape with credentials:'include' and `if (!res.ok) throw`.
|
||||
In client.ts add exported interfaces `CreateEventPayload` (title, allDay, start, end, optional location, description, recurrence: 'none'|'daily'|'weekly'|'monthly'|'yearly', calendarUrl?), `CreateEventResponse` ({ uid }), `WritableCalendar` ({ url, displayName, color, isShared }). Add `createEvent(payload): Promise<CreateEventResponse>` (POST), `updateEvent(uid, payload): Promise<CreateEventResponse>` (PATCH `/api/events/${uid}/edit`), and `fetchWritableCalendars(): Promise<WritableCalendar[]>` (GET `/api/events/writable-calendars`, added by Plan 03 Task 3 — call it unconditionally; parse the JSON `{ calendars }` envelope and return `body.calendars`). The server is the authoritative owner of the D-03 writable set; do NOT derive the writable set on the client. All follow the existing fetch shape with credentials:'include' and `if (!res.ok) throw`.
|
||||
|
||||
In calendarStore.ts extend `CalendarStore` with `eventFormOpen: boolean`, `eventFormMode: 'create'|'edit'`, `eventFormUid: string|null`, plus setters `setEventForm(open, mode?, uid?)`. Defaults: closed, mode 'create', uid null. Keep all server data out of Zustand (D — server state stays in TanStack Query).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/luc/Projects/familysync && pnpm --filter @familysync/pwa exec tsc --noEmit && grep -q "createEvent" apps/pwa/src/api/client.ts && grep -q "eventFormOpen" apps/pwa/src/store/calendarStore.ts && pnpm --filter @familysync/pwa test</automated>
|
||||
<automated>cd /home/luc/Projects/familysync && pnpm --filter @familysync/pwa exec tsc --noEmit && grep -q "createEvent" apps/pwa/src/api/client.ts && grep -q "writable-calendars" apps/pwa/src/api/client.ts && grep -q "eventFormOpen" apps/pwa/src/store/calendarStore.ts && pnpm --filter @familysync/pwa test</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -Eq "createEvent|updateEvent" apps/pwa/src/api/client.ts`.
|
||||
- `grep -q "writable-calendars" apps/pwa/src/api/client.ts` (calls the Plan 03 endpoint; no client-side derivation).
|
||||
- `grep -q "eventFormOpen" apps/pwa/src/store/calendarStore.ts`.
|
||||
- PWA tsc --noEmit passes; existing PWA tests stay green.
|
||||
</acceptance_criteria>
|
||||
<done>Write client calls and form-state Zustand keys exist and type-check.</done>
|
||||
<done>Write client calls (including fetchWritableCalendars against the Plan 03 endpoint) and form-state Zustand keys exist and type-check.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
@@ -109,10 +118,10 @@ Output: EventForm + client write calls + store keys + FAB, all wired to the Plan
|
||||
- apps/pwa/src/api/client.ts (createEvent/updateEvent/fetchWritableCalendars from Task 1)
|
||||
</read_first>
|
||||
<behavior>
|
||||
Tests (EventForm.test.tsx): renders title/all-day/start/end/recurrence/location/description fields; toggling "All day" hides time inputs; calendar picker is absent when one writable calendar and present when two (D-02); empty title shows "Title is required"; end-before-start shows "End time must be after start"; submitting calls the createEvent mutation in create mode and updateEvent in edit mode; Escape and backdrop close the form.
|
||||
Tests (EventForm.test.tsx): renders title/all-day/start/end/recurrence/location/description fields; toggling "All day" hides time inputs; calendar picker is absent when fetchWritableCalendars returns one calendar and present when it returns two (D-02); empty title shows "Title is required"; end-before-start shows "End time must be after start"; submitting calls the createEvent mutation in create mode and updateEvent in edit mode; Escape and backdrop close the form.
|
||||
</behavior>
|
||||
<action>
|
||||
Implement `EventForm.tsx` as a modal overlay reusing the EventDetailPopover backdrop+dialog+focus-trap+responsive pattern (bottom sheet on phone, centered 480px dialog on desktop). Fields and order exactly per UI-SPEC §EventForm. All-day toggle (`role="switch"`) hides start/end time inputs and applies the auto-advance rule; defaults start 09:00/end 10:00 when toggled off. Recurrence as a segmented select (`role="radiogroup"` or `<select>`) of None/Daily/Weekly/Monthly/Yearly (D-11 whole-series; map to the recurrence enum). Calendar picker rendered only when `fetchWritableCalendars()` returns >1 (D-02); default selection = last-used (read from a localStorage key) else personal (D-01). Use `useMutation` (TanStack Query) calling `createEvent`/`updateEvent` by `eventFormMode`; on success close the form (`setEventForm(false)`) and set `lastSyncedUid` (added in Plan 06; if absent, store the returned uid in a placeholder for now). Validation: empty title and end-before-start show the exact UI-SPEC error copy in `--color-destructive`. All spacing/color via tokens; all field values rendered as plain-text JSX children (XSS guard); 44px min touch targets; `role="dialog"` `aria-modal="true"` `aria-label` "New Event"/"Edit Event"; focus the Title input on open; Escape/backdrop close. Edit mode pre-populates fields from the occurrence identified by `eventFormUid` (read from the TanStack `['events']` cache like EventDetailPopover does).
|
||||
Implement `EventForm.tsx` as a modal overlay reusing the EventDetailPopover backdrop+dialog+focus-trap+responsive pattern (bottom sheet on phone, centered 480px dialog on desktop). Fields and order exactly per UI-SPEC §EventForm. All-day toggle (`role="switch"`) hides start/end time inputs and applies the auto-advance rule; defaults start 09:00/end 10:00 when toggled off. Recurrence as a segmented select (`role="radiogroup"` or `<select>`) of None/Daily/Weekly/Monthly/Yearly (D-11 whole-series; map to the recurrence enum). Calendar picker rendered only when `fetchWritableCalendars()` (TanStack Query, key `['writable-calendars']`) returns >1 (D-02); default selection = last-used (read from a localStorage key) else personal (D-01). Use `useMutation` (TanStack Query) calling `createEvent`/`updateEvent` by `eventFormMode`; on success close the form (`setEventForm(false)`) and set `lastSyncedUid` (added in Plan 06; if absent, store the returned uid in a placeholder for now). Validation: empty title and end-before-start show the exact UI-SPEC error copy in `--color-destructive`. All spacing/color via tokens; all field values rendered as plain-text JSX children (XSS guard); 44px min touch targets; `role="dialog"` `aria-modal="true"` `aria-label` "New Event"/"Edit Event"; focus the Title input on open; Escape/backdrop close. Edit mode pre-populates fields from the occurrence identified by `eventFormUid` (read from the TanStack `['events']` cache like EventDetailPopover does).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/luc/Projects/familysync && pnpm --filter @familysync/pwa test -- EventForm && pnpm --filter @familysync/pwa exec tsc --noEmit</automated>
|
||||
@@ -160,18 +169,18 @@ Output: EventForm + client write calls + store keys + FAB, all wired to the Plan
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-03-15 | Tampering | XSS via event title/location/description in the form | mitigate | All values rendered as plain-text JSX children; never dangerouslySetInnerHTML (Phase 2 T-02e-01 pattern); server re-validates with zod (Plan 03) |
|
||||
| T-03-16 | Elevation of Privilege | client offering a non-writable calendar in the picker | mitigate | Picker is populated only from the member's writable set; server enforces D-03 ownership regardless (Plan 03 is authoritative) |
|
||||
| T-03-16 | Elevation of Privilege | client offering a non-writable calendar in the picker | mitigate | Picker is populated only from the authoritative `GET /api/events/writable-calendars` set (Plan 03, D-03 enforced server-side); the client never derives writability, and the write endpoints re-enforce D-03 ownership on enqueue regardless |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `pnpm --filter @familysync/pwa test` green (EventForm + existing).
|
||||
- `pnpm --filter @familysync/pwa exec tsc --noEmit` passes.
|
||||
- EventForm reachable from CalendarShell; D-02 picker conditional; D-11 recurrence presets present.
|
||||
- EventForm reachable from CalendarShell; D-02 picker conditional (driven by the writable-calendars endpoint); D-11 recurrence presets present.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- CAL-04 and CAL-07 create paths are user-reachable through EventForm → POST /api/events/create.
|
||||
- Edit mode pre-populates and PATCHes; calendar picker honors D-01/D-02.
|
||||
- Edit mode pre-populates and PATCHes; calendar picker honors D-01/D-02, sourced from the Plan 03 writable-calendars endpoint.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
|
||||
@@ -0,0 +1,566 @@
|
||||
# Phase 3: Event Write-Back + PWA Install - Pattern Map
|
||||
|
||||
**Mapped:** 2026-06-05
|
||||
**Files analyzed:** 12 new/modified files
|
||||
**Analogs found:** 10 / 12
|
||||
|
||||
---
|
||||
|
||||
## File Classification
|
||||
|
||||
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|
||||
|-------------------|------|-----------|----------------|---------------|
|
||||
| `apps/api/src/db/schema.ts` | model | CRUD | `apps/api/src/db/schema.ts` (extend existing) | exact |
|
||||
| `apps/api/src/broker/write.ts` | service | request-response | `apps/api/src/broker/client.ts` | role-match |
|
||||
| `apps/api/src/broker/vevent.ts` | utility | transform | `apps/api/src/broker/sync.ts` (ical.js usage) | role-match |
|
||||
| `apps/api/src/broker/outboxWorker.ts` | service | batch | `apps/api/src/broker/poller.ts` | exact |
|
||||
| `apps/api/src/routes/events.ts` | route | request-response | `apps/api/src/routes/events.ts` (extend existing) | exact |
|
||||
| `apps/pwa/src/components/EventDetailPopover.tsx` | component | request-response | `apps/pwa/src/components/EventDetailPopover.tsx` (extend) | exact |
|
||||
| `apps/pwa/src/components/EventForm.tsx` | component | request-response | `apps/pwa/src/components/EventDetailPopover.tsx` | role-match |
|
||||
| `apps/pwa/src/components/InstallPrompt.tsx` | component | event-driven | `apps/pwa/src/components/EmptyState.tsx` | partial |
|
||||
| `apps/pwa/src/api/client.ts` | utility | request-response | `apps/pwa/src/api/client.ts` (extend existing) | exact |
|
||||
| `apps/pwa/vite.config.ts` | config | — | `apps/pwa/vite.config.ts` (extend existing) | exact |
|
||||
| `apps/api/tests/broker/outboxWorker.test.ts` | test | batch | `apps/api/tests/broker/sync.test.ts` | role-match |
|
||||
| `apps/api/tests/routes/events.test.ts` | test | request-response | `apps/api/tests/routes/events.test.ts` (extend) | exact |
|
||||
|
||||
---
|
||||
|
||||
## Pattern Assignments
|
||||
|
||||
### `apps/api/src/db/schema.ts` — add `calendarOutbox` table + `objectUrl` column on `calendarEvents`
|
||||
|
||||
**Analog:** `apps/api/src/db/schema.ts` (lines 1–112, existing file)
|
||||
|
||||
**Imports pattern** (lines 1–12):
|
||||
```typescript
|
||||
import {
|
||||
mysqlTable,
|
||||
varchar,
|
||||
text,
|
||||
int,
|
||||
date,
|
||||
timestamp,
|
||||
boolean,
|
||||
index,
|
||||
unique,
|
||||
} from 'drizzle-orm/mysql-core'
|
||||
```
|
||||
Add `mysqlEnum` to the import list — already used in the research pattern but not yet in schema.ts.
|
||||
|
||||
**Existing table pattern** (lines 86–112) — copy this structure for `calendarOutbox`:
|
||||
```typescript
|
||||
export const calendarEvents = mysqlTable(
|
||||
'calendar_events',
|
||||
{
|
||||
id: int().primaryKey().autoincrement(),
|
||||
calendarId: int('calendar_id')
|
||||
.notNull()
|
||||
.references(() => calendars.id, { onDelete: 'cascade' }),
|
||||
uid: varchar('uid', { length: 512 }).notNull(),
|
||||
etag: varchar('etag', { length: 256 }),
|
||||
// ...
|
||||
updatedAt: timestamp('updated_at').defaultNow().onUpdateNow(),
|
||||
},
|
||||
(t) => [
|
||||
index('idx_calendar_events_dtstart_utc').on(t.dtstartUtc),
|
||||
unique('uniq_calendar_uid').on(t.calendarId, t.uid),
|
||||
],
|
||||
)
|
||||
```
|
||||
|
||||
**New column on `calendarEvents`** — add `objectUrl` after `etag`:
|
||||
```typescript
|
||||
objectUrl: varchar('object_url', { length: 1024 }), // CalDAV object URL; populated by sync.ts from obj.url
|
||||
```
|
||||
|
||||
**References pattern** (lines 40–47) — copy for `calendarOutbox.userId`:
|
||||
```typescript
|
||||
userId: int('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `apps/api/src/broker/write.ts` — new file, tsdav PUT/DELETE wrapper
|
||||
|
||||
**Analog:** `apps/api/src/broker/client.ts` (lines 1–32)
|
||||
|
||||
**File header and imports pattern** (client.ts lines 1–12):
|
||||
```typescript
|
||||
/**
|
||||
* [JSDoc comment with source citations]
|
||||
* Source: https://...
|
||||
*/
|
||||
|
||||
import { createDAVClient } from 'tsdav'
|
||||
|
||||
export type FastmailClient = Awaited<ReturnType<typeof createDAVClient>>
|
||||
```
|
||||
|
||||
**Export pattern** — named exports, no default (matches all broker files):
|
||||
```typescript
|
||||
import type { FastmailClient } from './client.js'
|
||||
import type { DAVCalendar } from 'tsdav'
|
||||
|
||||
export async function createCalendarEvent(...): Promise<Response> { ... }
|
||||
export async function updateCalendarEvent(...): Promise<Response> { ... }
|
||||
export async function deleteCalendarEvent(...): Promise<Response> { ... }
|
||||
```
|
||||
|
||||
**Import extension `.js`** — all broker imports use `.js` suffix (e.g., `'./client.js'`, `'../db/client.js'`). Required for ESM with TypeScript.
|
||||
|
||||
---
|
||||
|
||||
### `apps/api/src/broker/vevent.ts` — new file, ical.js VEVENT builder
|
||||
|
||||
**Analog:** `apps/api/src/broker/sync.ts` (lines 1–127) — existing ical.js usage
|
||||
|
||||
**ical.js import pattern** (sync.ts line 20):
|
||||
```typescript
|
||||
import ICAL from 'ical.js'
|
||||
```
|
||||
|
||||
**ical.js parse → component pattern** (sync.ts lines 72–86) — the reverse direction (build vs parse) uses the same ICAL.Component/ICAL.Time API:
|
||||
```typescript
|
||||
const comp = new ICAL.Component(parsed)
|
||||
const vevent = comp.getFirstSubcomponent('vevent')
|
||||
const dtstart = vevent.getFirstPropertyValue('dtstart') as ICAL.Time | null
|
||||
```
|
||||
|
||||
**D-13 all-day vs timed split** (sync.ts lines 89–101) — must mirror this exact split in the builder:
|
||||
```typescript
|
||||
// D-13 / Pitfall #3: isDate=true → DATE column; isDate=false → TIMESTAMP column
|
||||
const allDay: boolean = dtstart?.isDate ?? false
|
||||
const dtstartDateValue: Date | null =
|
||||
allDay && dtstart ? new Date(dtstart.toString().slice(0, 10) + 'T00:00:00Z') : null
|
||||
const dtstartUtcValue: Date | null = !allDay && dtstart ? dtstart.toJSDate() : null
|
||||
```
|
||||
|
||||
**Error isolation pattern** (sync.ts lines 74–78):
|
||||
```typescript
|
||||
try {
|
||||
parsed = ICAL.parse(obj.data as string)
|
||||
} catch {
|
||||
// Malformed VCALENDAR — skip but do not crash the sync
|
||||
continue
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `apps/api/src/broker/outboxWorker.ts` — new file, outbox drain loop
|
||||
|
||||
**Analog:** `apps/api/src/broker/poller.ts` (lines 1–85) — closest match, exact role
|
||||
|
||||
**File header JSDoc pattern** (poller.ts lines 1–16):
|
||||
```typescript
|
||||
/**
|
||||
* CalDAV broker poller — runs every 5 minutes via node-cron.
|
||||
*
|
||||
* Responsibilities (D-13, D-02):
|
||||
* - ...
|
||||
*
|
||||
* runPoll is exported for unit testing (inject mocks via vi.mock at the module level).
|
||||
* startBrokerPoller wraps it in node-cron's 5-minute schedule.
|
||||
*
|
||||
* Source: https://github.com/node-cron/node-cron (v4 stable basic API)
|
||||
*/
|
||||
```
|
||||
|
||||
**Imports pattern** (poller.ts lines 18–25):
|
||||
```typescript
|
||||
import { schedule } from 'node-cron'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { db } from '../db/client.js'
|
||||
import { memberCredentials, calendars } from '../db/schema.js'
|
||||
import { decryptPassword } from './crypto.js'
|
||||
import { createFastmailClient } from './client.js'
|
||||
import { syncCalendar } from './sync.js'
|
||||
```
|
||||
Replace with: `and`, `lte`, `eq` from `drizzle-orm`; `calendarOutbox`, `calendars` from schema; `syncCalendar` from `./sync.js`; write functions from `./write.js`.
|
||||
|
||||
**Exported runX + startX pair pattern** (poller.ts lines 35–85):
|
||||
```typescript
|
||||
// runPoll exported for unit testing
|
||||
export async function runPoll(): Promise<void> { ... }
|
||||
|
||||
// startBrokerPoller wraps it in a schedule
|
||||
export function startBrokerPoller(): void {
|
||||
schedule('*/5 * * * *', () => {
|
||||
runPoll().catch((err: unknown) => {
|
||||
console.error('[broker/poller] Unhandled runPoll error:', err)
|
||||
})
|
||||
})
|
||||
}
|
||||
```
|
||||
Outbox worker follows: `export async function runOutboxDrain()` + `export function startOutboxWorker()`.
|
||||
|
||||
**Per-item error isolation pattern** (poller.ts lines 65–72):
|
||||
```typescript
|
||||
} catch (err) {
|
||||
// Log the error but do NOT log the app password or key (T-03-04)
|
||||
console.error(
|
||||
`[broker/poller] Error processing credential id=${cred.id} (${cred.fastmailEmail}):`,
|
||||
err instanceof Error ? err.message : String(err),
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Drizzle select + where + limit pattern** (poller.ts lines 47–53):
|
||||
```typescript
|
||||
const [stored] = await db
|
||||
.select()
|
||||
.from(calendars)
|
||||
.where(eq(calendars.url, davCal.url))
|
||||
.limit(1)
|
||||
```
|
||||
|
||||
**Drizzle update pattern** — extend from sync.ts `onDuplicateKeyUpdate` shape:
|
||||
```typescript
|
||||
await db.update(calendarOutbox)
|
||||
.set({ status: 'done' })
|
||||
.where(eq(calendarOutbox.id, row.id))
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `apps/api/src/routes/events.ts` — extend with write endpoints + sync-status
|
||||
|
||||
**Analog:** `apps/api/src/routes/events.ts` (lines 1–141, existing file)
|
||||
|
||||
**File header invariant comment** (lines 1–15) — copy verbatim and extend:
|
||||
```typescript
|
||||
/**
|
||||
* Architecture invariant (T-03-02, broker-boundary):
|
||||
* This route reads ONLY from the MariaDB cache. It NEVER calls Fastmail directly.
|
||||
* All Fastmail I/O is owned exclusively by the broker module (src/broker/).
|
||||
* No tsdav import here; no createFastmailClient import here.
|
||||
*/
|
||||
```
|
||||
|
||||
**Hono router + zValidator pattern** (lines 17–41):
|
||||
```typescript
|
||||
import { Hono } from 'hono'
|
||||
import { zValidator } from '@hono/zod-validator'
|
||||
import { z } from 'zod'
|
||||
import { and, or, eq, lte, lt } from 'drizzle-orm'
|
||||
import { sql } from 'drizzle-orm'
|
||||
import { db } from '../db/client.js'
|
||||
import { calendarEvents, calendars, users } from '../db/schema.js'
|
||||
|
||||
export const eventsRouter = new Hono()
|
||||
|
||||
const eventsQuerySchema = z.object({
|
||||
start: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||
end: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||
})
|
||||
```
|
||||
|
||||
**Route handler + zValidator + try/catch error pattern** (lines 53–141):
|
||||
```typescript
|
||||
eventsRouter.get('/', zValidator('query', eventsQuerySchema), async (c) => {
|
||||
// ... input validation ...
|
||||
try {
|
||||
const rows = await db.select(...).from(...).where(...)
|
||||
return c.json({ occurrences: allOccurrences })
|
||||
} catch (err) {
|
||||
console.error('[events] DB query or expansion failed:', err)
|
||||
return c.json({ error: 'Service unavailable' }, 503)
|
||||
}
|
||||
})
|
||||
```
|
||||
New write endpoints follow the same shape: `eventsRouter.post('/create', zValidator('json', createSchema), async (c) => { ... })`.
|
||||
|
||||
**Auth identity pattern** (from me.ts lines 33–44) — write endpoints need current user:
|
||||
```typescript
|
||||
const devUser = c.get('user')
|
||||
if (devUser) {
|
||||
// dev bypass path
|
||||
}
|
||||
const auth = await getAuth(c)
|
||||
if (!auth) {
|
||||
return c.json({ error: 'Unauthorized' }, 401)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `apps/pwa/src/components/EventDetailPopover.tsx` — add edit/delete to reserved footer
|
||||
|
||||
**Analog:** `apps/pwa/src/components/EventDetailPopover.tsx` (lines 380–388, reserved footer)
|
||||
|
||||
**Reserved footer (lines 380–388)** — Phase 3 wires buttons here:
|
||||
```tsx
|
||||
{/* Phase 3 footer action area — Phase 3 adds edit/delete actions here (D-08) */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
// Reserved: empty in Phase 2 (read-only); Phase 3 wires edit/delete buttons here
|
||||
marginTop: 'var(--space-4)',
|
||||
}}
|
||||
/>
|
||||
```
|
||||
Replace with real content. Remove `aria-hidden="true"`.
|
||||
|
||||
**Button style pattern** (lines 235–251) — copy close button style for action buttons:
|
||||
```tsx
|
||||
<button
|
||||
aria-label="Close"
|
||||
onClick={handleClose}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
minWidth: '44px',
|
||||
minHeight: '44px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '20px',
|
||||
color: 'var(--color-text-secondary)',
|
||||
borderRadius: 'var(--space-1)',
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
```
|
||||
|
||||
**Design token usage** — all spacing/color uses CSS vars (not hardcoded values):
|
||||
- `var(--color-surface-raised)`, `var(--color-text-primary)`, `var(--color-text-secondary)`, `var(--color-border-subtle)`
|
||||
- `var(--space-2)`, `var(--space-3)`, `var(--space-4)`, `var(--space-6)`
|
||||
- `var(--text-body-size)`, `var(--text-heading-size)`, `var(--font-family-base)`
|
||||
|
||||
**XSS guard pattern** (T-02e-01, lines 283–285) — all text content as plain JSX children:
|
||||
```tsx
|
||||
{/* Plain text child only — XSS guard (T-02e-01) */}
|
||||
{occurrence.title}
|
||||
```
|
||||
EventForm must follow this: all field values rendered as plain-text children, never `dangerouslySetInnerHTML`.
|
||||
|
||||
**Zustand + TanStack Query pattern** (lines 109–137):
|
||||
```tsx
|
||||
const { openEventId, setOpenEventId } = useCalendarStore()
|
||||
const queryClient = useQueryClient()
|
||||
// Read from TanStack Query cache — do not store server data in Zustand
|
||||
const allEntries = queryClient.getQueriesData<{ occurrences: CalendarOccurrence[] }>({
|
||||
queryKey: ['events'],
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `apps/pwa/src/components/EventForm.tsx` — new file, create/edit form
|
||||
|
||||
**Analog:** `apps/pwa/src/components/EventDetailPopover.tsx` (role-match — same overlay surface)
|
||||
|
||||
**Modal/overlay structure** — copy the backdrop + dialog pattern from EventDetailPopover (lines 202–221):
|
||||
```tsx
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
data-testid="popover-backdrop"
|
||||
onClick={handleClose}
|
||||
style={{ position: 'fixed', inset: 0, background: 'var(--color-overlay)', zIndex: 199 }}
|
||||
/>
|
||||
{/* Dialog */}
|
||||
<div
|
||||
ref={dialogRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="..."
|
||||
tabIndex={-1}
|
||||
style={dialogStyle}
|
||||
>
|
||||
```
|
||||
|
||||
**Escape + focus trap useEffect pattern** (lines 143–159):
|
||||
```tsx
|
||||
useEffect(() => {
|
||||
if (!activeId) return
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') handleClose()
|
||||
}
|
||||
document.addEventListener('keydown', onKeyDown)
|
||||
return () => document.removeEventListener('keydown', onKeyDown)
|
||||
}, [activeId])
|
||||
|
||||
useEffect(() => {
|
||||
if (activeId && dialogRef.current) dialogRef.current.focus()
|
||||
}, [activeId])
|
||||
```
|
||||
|
||||
**Responsive phone/desktop detection** (lines 165–199) — copy the `isPhone` / `dialogStyle` pattern.
|
||||
|
||||
**TanStack Query mutation pattern** — use `useMutation` from `@tanstack/react-query` (same import, already in stack):
|
||||
```tsx
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
// On success: queryClient.invalidateQueries({ queryKey: ['events'] })
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `apps/pwa/src/components/InstallPrompt.tsx` — new file, iOS/Android install
|
||||
|
||||
**Analog:** `apps/pwa/src/components/EmptyState.tsx` (partial — informational UI surface)
|
||||
|
||||
No close analog. Use the design token and component conventions from EventDetailPopover:
|
||||
- CSS vars for all spacing/color
|
||||
- Plain-text JSX children (no dangerouslySetInnerHTML)
|
||||
- 44px minimum touch targets on all buttons
|
||||
- `useEffect` for event listener cleanup (same pattern as popover Escape handler)
|
||||
|
||||
**Standalone detection** — no existing analog; use RESEARCH.md Pattern 6 directly.
|
||||
|
||||
---
|
||||
|
||||
### `apps/pwa/src/api/client.ts` — add write calls + sync-status poll
|
||||
|
||||
**Analog:** `apps/pwa/src/api/client.ts` (lines 1–106, extend)
|
||||
|
||||
**Fetch function pattern** (lines 89–102):
|
||||
```typescript
|
||||
export async function fetchEvents(start: string, end: string): Promise<OccurrencesResponse> {
|
||||
const res = await fetch(`/api/events?start=${start}&end=${end}`, {
|
||||
credentials: 'include',
|
||||
})
|
||||
if (!res.ok) {
|
||||
throw new Error(`GET /api/events failed: ${res.status}`)
|
||||
}
|
||||
return res.json() as Promise<OccurrencesResponse>
|
||||
}
|
||||
```
|
||||
New write functions follow the same shape. POST/PATCH/DELETE calls:
|
||||
```typescript
|
||||
export async function createEvent(payload: CreateEventPayload): Promise<CreateEventResponse> {
|
||||
const res = await fetch('/api/events/create', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
if (!res.ok) throw new Error(`POST /api/events/create failed: ${res.status}`)
|
||||
return res.json() as Promise<CreateEventResponse>
|
||||
}
|
||||
```
|
||||
|
||||
**Interface-first pattern** (lines 14–74) — define TypeScript interfaces before the fetch functions. All request/response shapes declared as exported interfaces.
|
||||
|
||||
---
|
||||
|
||||
### `apps/pwa/vite.config.ts` — add VitePWA plugin
|
||||
|
||||
**Analog:** `apps/pwa/vite.config.ts` (lines 1–13, extend existing)
|
||||
|
||||
**Existing config** (lines 1–13):
|
||||
```typescript
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/health': 'http://localhost:3000',
|
||||
'/api': 'http://localhost:3000',
|
||||
'/callback': 'http://localhost:3000',
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
Keep the proxy block exactly as-is. Add `VitePWA` to `plugins` array. The `/callback` proxy entry is critical — it must remain so the SW denylist matches the actual handler.
|
||||
|
||||
---
|
||||
|
||||
## Shared Patterns
|
||||
|
||||
### Auth guard in write route handlers
|
||||
**Source:** `apps/api/src/routes/me.ts` lines 29–49
|
||||
**Apply to:** All new POST/PATCH/DELETE handlers in `routes/events.ts`
|
||||
```typescript
|
||||
const devUser = c.get('user')
|
||||
if (devUser) {
|
||||
// dev bypass — use devUser.id as userId
|
||||
}
|
||||
const auth = await getAuth(c)
|
||||
if (!auth) {
|
||||
return c.json({ error: 'Unauthorized' }, 401)
|
||||
}
|
||||
```
|
||||
Also import `'../auth/devBypass.js'` as a side-effect to get the ContextVariableMap augmentation (see me.ts line 25).
|
||||
|
||||
### Error handling in route handlers
|
||||
**Source:** `apps/api/src/routes/events.ts` lines 136–140
|
||||
**Apply to:** All route handlers
|
||||
```typescript
|
||||
} catch (err) {
|
||||
console.error('[events] DB query or expansion failed:', err)
|
||||
return c.json({ error: 'Service unavailable' }, 503)
|
||||
}
|
||||
```
|
||||
Use consistent `[module/file] description:` log prefix format.
|
||||
|
||||
### ESM import extension
|
||||
**Source:** All existing broker and route files
|
||||
**Apply to:** All new TypeScript files
|
||||
All project imports use `.js` extension suffix on relative imports:
|
||||
`'./client.js'`, `'../db/client.js'`, `'../db/schema.js'`, `'./sync.js'`
|
||||
|
||||
### Drizzle DB mock in tests
|
||||
**Source:** `apps/api/tests/routes/events.test.ts` lines 29–52
|
||||
**Apply to:** `outboxWorker.test.ts`, extended `events.test.ts`
|
||||
```typescript
|
||||
// Chain of mocks matching the Drizzle query builder
|
||||
const mockWhereFn = vi.fn().mockImplementation(() => Promise.resolve(mockDbRows))
|
||||
const mockFromFn = vi.fn().mockReturnValue({ where: mockWhereFn })
|
||||
const mockSelectFn = vi.fn().mockReturnValue({ from: mockFromFn })
|
||||
|
||||
vi.mock('../../src/db/client.js', () => ({
|
||||
db: { select: mockSelectFn, insert: mockInsert, update: mockUpdate },
|
||||
}))
|
||||
```
|
||||
|
||||
### OIDC mock in tests
|
||||
**Source:** `apps/api/tests/routes/events.test.ts` lines 22–26
|
||||
**Apply to:** All new route tests
|
||||
```typescript
|
||||
vi.mock('@hono/oidc-auth', () => ({
|
||||
oidcAuthMiddleware: () => async (_c: unknown, next: () => Promise<void>) => next(),
|
||||
processOAuthCallback: () => async (c: { json: (v: unknown) => unknown }) => c.json({ ok: true }),
|
||||
getAuth: () => null,
|
||||
}))
|
||||
```
|
||||
|
||||
### TanStack Query integration in React components
|
||||
**Source:** `apps/pwa/src/components/EventDetailPopover.tsx` lines 26, 111–112
|
||||
**Apply to:** `EventForm.tsx`, `InstallPrompt.tsx`
|
||||
```tsx
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
// ...
|
||||
const queryClient = useQueryClient()
|
||||
// On write success: invalidate events cache
|
||||
queryClient.invalidateQueries({ queryKey: ['events'] })
|
||||
```
|
||||
|
||||
### Zustand UI state (not server state)
|
||||
**Source:** `apps/pwa/src/components/EventDetailPopover.tsx` lines 109–110
|
||||
**Apply to:** `EventForm.tsx`
|
||||
```tsx
|
||||
const { openEventId, setOpenEventId } = useCalendarStore()
|
||||
```
|
||||
EventForm visibility/mode (create vs edit) is UI state → Zustand. Event data is server state → TanStack Query.
|
||||
|
||||
---
|
||||
|
||||
## No Analog Found
|
||||
|
||||
| File | Role | Data Flow | Reason |
|
||||
|------|------|-----------|--------|
|
||||
| `apps/pwa/src/components/InstallPrompt.tsx` (iOS walkthrough) | component | event-driven | No precedent for install-prompt or browser-API-driven components in codebase |
|
||||
|
||||
---
|
||||
|
||||
## Metadata
|
||||
|
||||
**Analog search scope:** `apps/api/src/`, `apps/pwa/src/`, `apps/api/tests/`
|
||||
**Files scanned:** 14 source files read
|
||||
**Pattern extraction date:** 2026-06-05
|
||||
@@ -811,23 +811,26 @@ const isInstalled = window.matchMedia('(display-mode: standalone)').matches
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
## Open Questions (RESOLVED)
|
||||
|
||||
1. **Fastmail object URL format**
|
||||
- What we know: `tsdav` `fetchCalendarObjects` returns `DAVCalendarObject` with a `url` field; Fastmail CalDAV URLs follow the pattern `https://caldav.fastmail.com/dav/calendars/user/<email>/<calendar-slug>/<uid>.ics`
|
||||
- What's unclear: Whether the URL is returned verbatim by `fetchCalendarObjects` or constructed — and whether the `calendarObjectUrl` stored in the outbox is stable across syncs
|
||||
- Recommendation: At worker time, fetch fresh object URLs from the DB `calendarEvents.url` column (which does not exist yet — the schema needs a `url` column added to `calendarEvents` for the CalDAV object URL). Alternatively, construct it from `calendars.url + uid + '.ics'` — verify against a real REPORT response in Wave 0.
|
||||
- **Action for planner:** Add `objectUrl varchar(1024)` to `calendarEvents` schema OR document URL construction convention.
|
||||
- **Resolution:** RESOLVED — `objectUrl` column added to `calendarEvents` in plan 03-01 Task 2 and populated from `obj.url` in `sync.ts` (03-01 Task 3); the worker reads the stored object URL rather than reconstructing it.
|
||||
|
||||
2. **`calendarEvents` schema missing object URL**
|
||||
- What we know: Current `calendarEvents` schema has `uid`, `etag`, `rawVevent` but no `url` field. The object URL is needed for `updateCalendarObject` and `deleteCalendarObject`.
|
||||
- What's unclear: Whether `tsdav` `fetchCalendarObjects` returns a `url` field in the `DAVCalendarObject` (it does — the tsdav type shows `url: string`). So the URL can be stored at sync time.
|
||||
- Recommendation: Add `objectUrl varchar(1024)` to `calendarEvents` in the schema migration. Populate it from `obj.url` in `sync.ts` alongside `etag`.
|
||||
- **Resolution:** RESOLVED — same as Q1: `calendarEvents.objectUrl` (`object_url varchar(1024)`) added in plan 03-01 Task 2 and set from `obj.url` in `sync.ts` (03-01 Task 3).
|
||||
|
||||
3. **Writable calendar set resolution (D-03)**
|
||||
- What we know: D-03 says writable = own personal + shared Family; D-16 says shared calendar not yet created; `calendars.isShared` marks the shared one.
|
||||
- What's unclear: How the API knows which calendars belong to the current user vs being read-only overlays from other members. Currently, `calendars` rows are owned by `userId` — the current user's writable set is simply `WHERE userId = currentUser.id`.
|
||||
- Recommendation: Writable set = `SELECT * FROM calendars WHERE user_id = :userId` (personal) UNION the row where `is_shared = 1` (shared family). This matches D-03 with no additional schema changes.
|
||||
- **Resolution:** RESOLVED via Option A (server-side endpoint) — `GET /api/events/writable-calendars` (plan 03-03 Task 3) is the authoritative owner of the D-03 writable set (`userId = currentUser.id OR isShared = true`); the PWA picker consumes it verbatim (03-05 Task 1) and never derives writability client-side.
|
||||
|
||||
---
|
||||
|
||||
@@ -877,6 +880,7 @@ const isInstalled = window.matchMedia('(display-mode: standalone)').matches
|
||||
| CAL-07 | `buildVeventString` with `rruleString` produces VCALENDAR with RRULE property | unit | same | ❌ Wave 0 |
|
||||
| CAL-04/05/06 | Outbox worker transitions status: pending→done on mock 204, pending→failed on mock 412, pending→backoff on mock 500 | unit | `pnpm --filter @familysync/api test -- broker/outboxWorker` | ❌ Wave 0 |
|
||||
| CAL-04/05/06 | GET /api/events/sync-status returns correct status from outbox row | unit | same events test | ❌ Wave 0 |
|
||||
| CAL-04/05/07 | GET /api/events/writable-calendars returns D-03 writable set; never another member's read-only personal (V4) | unit | `pnpm --filter @familysync/api test -- routes/events` | ❌ Wave 0 |
|
||||
| D-08 | 412 response routes to conflict (not retry), marks failed, triggers re-sync | unit | same outboxWorker test | ❌ Wave 0 |
|
||||
| D-04 | Edit-as-move creates DELETE + CREATE pair; create runs first | unit | same outboxWorker test | ❌ Wave 0 |
|
||||
| PWA-01 | `vite.config.ts` produces a valid `manifest.webmanifest` with required fields | smoke (build output check) | `pnpm --filter @familysync/pwa build && node -e "..."` | ❌ Wave 0 |
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
phase: 3
|
||||
slug: event-write-back-pwa-install
|
||||
status: draft
|
||||
nyquist_compliant: false
|
||||
nyquist_compliant: true
|
||||
wave_0_complete: false
|
||||
created: 2026-06-05
|
||||
---
|
||||
@@ -10,6 +10,7 @@ created: 2026-06-05
|
||||
# Phase 3 — Validation Strategy
|
||||
|
||||
> Per-phase validation contract for feedback sampling during execution.
|
||||
> Sourced from 03-RESEARCH.md §Validation Architecture.
|
||||
|
||||
---
|
||||
|
||||
@@ -17,28 +18,46 @@ created: 2026-06-05
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| **Framework** | {pytest 7.x / jest 29.x / vitest / go test / other} |
|
||||
| **Config file** | {path or "none — Wave 0 installs"} |
|
||||
| **Quick run command** | `{quick command}` |
|
||||
| **Full suite command** | `{full command}` |
|
||||
| **Estimated runtime** | ~{N} seconds |
|
||||
| **Framework (API)** | Vitest 4.x, environment: `node` |
|
||||
| **Framework (PWA)** | Vitest 4.x + `jsdom` + `@testing-library/react` |
|
||||
| **Config (API)** | `apps/api/vitest.config.ts` |
|
||||
| **Config (PWA)** | `apps/pwa/vitest.config.ts` |
|
||||
| **Quick run (API)** | `pnpm --filter @familysync/api test` |
|
||||
| **Quick run (PWA)** | `pnpm --filter @familysync/pwa test` |
|
||||
| **Full suite** | `pnpm test` (from repo root — runs both apps) |
|
||||
| **Estimated runtime** | ~20-40 seconds (mocked DB + CalDAV; no network) |
|
||||
|
||||
---
|
||||
|
||||
## Sampling Rate
|
||||
|
||||
- **After every task commit:** Run `{quick run command}`
|
||||
- **After every plan wave:** Run `{full suite command}`
|
||||
- **Before `/gsd-verify-work`:** Full suite must be green
|
||||
- **Max feedback latency:** {N} seconds
|
||||
- **After every task commit:** Run the filtered quick command for the app touched
|
||||
(`pnpm --filter @familysync/api test -- <path>` or `pnpm --filter @familysync/pwa test -- <name>`).
|
||||
- **After every plan wave:** Run `pnpm test` (full suite, both apps).
|
||||
- **Before `/gsd-verify-work`:** Full suite must be green.
|
||||
- **Max feedback latency:** ~40 seconds (full suite).
|
||||
|
||||
---
|
||||
|
||||
## Per-Task Verification Map
|
||||
|
||||
| Task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status |
|
||||
|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------|
|
||||
| {N}-01-01 | 01 | 1 | REQ-{XX} | T-{N}-01 / — | {expected secure behavior or "N/A"} | unit | `{command}` | ✅ / ❌ W0 | ⬜ pending |
|
||||
| Req ID | Behavior | Plan | Wave | Test Type | Automated Command | File Exists | Status |
|
||||
|--------|----------|------|------|-----------|-------------------|-------------|--------|
|
||||
| CAL-04 | `buildVeventString` → VCALENDAR for a timed event (DTSTART UTC) | 02 | 2 | unit | `pnpm --filter @familysync/api test -- broker/vevent` | ❌ W0 (03-01) | ⬜ pending |
|
||||
| CAL-04 | `buildVeventString` → all-day event uses DATE not DATETIME (D-13) | 02 | 2 | unit | same | ❌ W0 (03-01) | ⬜ pending |
|
||||
| CAL-04 | POST /api/events/create → 202 + inserts pending outbox row | 03 | 2 | unit (mocked DB) | `pnpm --filter @familysync/api test -- routes/events` | ❌ W0 (03-01) | ⬜ pending |
|
||||
| CAL-05 | PATCH /api/events/:uid/edit → 202 + inserts row with etag | 03 | 2 | unit | same | ❌ W0 (03-01) | ⬜ pending |
|
||||
| CAL-06 | DELETE /api/events/:uid → 202 + inserts delete row | 03 | 2 | unit | same | ❌ W0 (03-01) | ⬜ pending |
|
||||
| CAL-07 | `buildVeventString` with `rruleString` → RRULE property | 02 | 2 | unit | `pnpm --filter @familysync/api test -- broker/vevent` | ❌ W0 (03-01) | ⬜ pending |
|
||||
| CAL-04/05/06 | Outbox worker: pending→done (204), pending→failed (412), pending→backoff (500), pending→dead (max attempts) | 02/04 | 2 | unit | `pnpm --filter @familysync/api test -- broker/outboxWorker` | ❌ W0 (03-01) | ⬜ pending |
|
||||
| CAL-04/05/06 | GET /api/events/sync-status returns outbox status (D-09) | 03 | 2 | unit | `pnpm --filter @familysync/api test -- routes/events` | ❌ W0 (03-01) | ⬜ pending |
|
||||
| CAL-04/05/07 | GET /api/events/writable-calendars returns D-03 set; never another member's read-only personal (V4) | 03 | 2 | unit | `pnpm --filter @familysync/api test -- routes/events` | ❌ W0 (03-01) | ⬜ pending |
|
||||
| D-08 | 412 → conflict (not retry), mark failed, trigger re-sync | 04 | 2 | unit | `pnpm --filter @familysync/api test -- broker/outboxWorker` | ❌ W0 (03-01) | ⬜ pending |
|
||||
| D-04 | Edit-as-move emits DELETE+CREATE pair; create runs first | 04 | 2 | unit | same | ❌ W0 (03-01) | ⬜ pending |
|
||||
| D-03/V4 | create rejects write to non-owned/non-shared calendar (403) | 03 | 2 | unit | `pnpm --filter @familysync/api test -- routes/events` | ❌ W0 (03-01) | ⬜ pending |
|
||||
| PWA-01 | `vite.config.ts` produces valid `manifest.webmanifest` with required fields | 06 | 3 | smoke (build output) | `pnpm --filter @familysync/pwa build` + manifest field check | ❌ W0 (03-01) | ⬜ pending |
|
||||
| PWA-02 | `isIOSSafariNonStandalone()` true on mock iOS Safari non-standalone UA | 06 | 3 | unit | `pnpm --filter @familysync/pwa test -- InstallPrompt` | ❌ W0 (03-01) | ⬜ pending |
|
||||
| PWA-02 | `useAndroidInstallPrompt` sets `canInstall=true` on `beforeinstallprompt` | 06 | 3 | unit (mock event) | same | ❌ W0 (03-01) | ⬜ pending |
|
||||
|
||||
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
|
||||
|
||||
@@ -46,11 +65,16 @@ created: 2026-06-05
|
||||
|
||||
## Wave 0 Requirements
|
||||
|
||||
- [ ] `{tests/test_file.py}` — stubs for REQ-{XX}
|
||||
- [ ] `{tests/conftest.py}` — shared fixtures
|
||||
- [ ] `{framework install}` — if no framework detected
|
||||
The Wave 0 RED test scaffold is created in **plan 03-01 Task 4** (these files import
|
||||
not-yet-existing modules so they fail RED until later waves implement them):
|
||||
|
||||
*If none: "Existing infrastructure covers all phase requirements."*
|
||||
- [ ] `apps/api/tests/broker/vevent.test.ts` — CAL-04, CAL-07 (VEVENT builder, DATE/DATETIME split, RRULE)
|
||||
- [ ] `apps/api/tests/broker/write.test.ts` — tsdav call shapes, etag/If-Match, response interpretation
|
||||
- [ ] `apps/api/tests/broker/outboxWorker.test.ts` — outbox state machine (done/failed/backoff/dead), edit-as-move ordering (D-04/D-07/D-08)
|
||||
- [ ] `apps/api/tests/routes/events.test.ts` — EXTEND existing: POST /create, PATCH /edit, DELETE /:uid, GET /sync-status, GET /writable-calendars, 403 ownership (preserve existing GET /api/events block)
|
||||
- [ ] `apps/pwa/src/components/InstallPrompt.test.tsx` — iOS detection, Android `beforeinstallprompt` capture (PWA-02)
|
||||
|
||||
Existing test files (`broker/sync`, `routes/events` GET block, `auth/devBypass`) remain in place.
|
||||
|
||||
---
|
||||
|
||||
@@ -58,19 +82,18 @@ created: 2026-06-05
|
||||
|
||||
| Behavior | Requirement | Why Manual | Test Instructions |
|
||||
|----------|-------------|------------|-------------------|
|
||||
| {behavior} | REQ-{XX} | {reason} | {steps} |
|
||||
|
||||
*If none: "All phase behaviors have automated verification."*
|
||||
| SW `navigateFallbackDenylist` excludes `/callback` | PWA-01 | Requires a real production build + SW registration over HTTPS | Verify against prod build; confirm `/callback` not intercepted by SW |
|
||||
| iOS standalone PWA login completes without leaving standalone | Gate 2 | Requires a physical iPhone, installed PWA, Authelia OIDC round-trip | Follow `docs/deployment.md` Gate 2 checklist (Plan 07) |
|
||||
|
||||
---
|
||||
|
||||
## Validation Sign-Off
|
||||
|
||||
- [ ] All tasks have `<automated>` verify or Wave 0 dependencies
|
||||
- [ ] Sampling continuity: no 3 consecutive tasks without automated verify
|
||||
- [ ] Wave 0 covers all MISSING references
|
||||
- [ ] No watch-mode flags
|
||||
- [ ] Feedback latency < {N}s
|
||||
- [ ] `nyquist_compliant: true` set in frontmatter
|
||||
- [x] All tasks have `<automated>` verify or a Wave 0 RED dependency (created in 03-01 Task 4)
|
||||
- [x] Sampling continuity: no 3 consecutive tasks without automated verify
|
||||
- [x] Wave 0 covers all MISSING references (five RED files in 03-01)
|
||||
- [x] No watch-mode flags
|
||||
- [x] Feedback latency < 40s
|
||||
- [x] `nyquist_compliant: true` set in frontmatter
|
||||
|
||||
**Approval:** {pending / approved YYYY-MM-DD}
|
||||
**Approval:** approved 2026-06-05
|
||||
|
||||
Reference in New Issue
Block a user