diff --git a/.planning/STATE.md b/.planning/STATE.md index fa88971..cad0a6f 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -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 diff --git a/.planning/phases/03-event-write-back-pwa-install/03-01-PLAN.md b/.planning/phases/03-event-write-back-pwa-install/03-01-PLAN.md index ba7d028..cbff9ca 100644 --- a/.planning/phases/03-event-write-back-pwa-install/03-01-PLAN.md +++ b/.planning/phases/03-event-write-back-pwa-install/03-01-PLAN.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. @@ -182,6 +182,7 @@ New symbols introduced across Phase 3 (excluded from drift verification): - 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). Five RED test files exist and fail because their target modules are unimplemented; existing tests preserved. diff --git a/.planning/phases/03-event-write-back-pwa-install/03-03-PLAN.md b/.planning/phases/03-event-write-back-pwa-install/03-03-PLAN.md index c3a1e89..cf50362 100644 --- a/.planning/phases/03-event-write-back-pwa-install/03-03-PLAN.md +++ b/.planning/phases/03-event-write-back-pwa-install/03-03-PLAN.md @@ -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: 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. @@ -117,6 +122,36 @@ Output: extended events.ts, GREEN against the create/edit/delete/sync-status tes GET /api/events/sync-status returns the member-scoped outbox status; tests GREEN. + + Task 3: GREEN — GET /api/events/writable-calendars (D-03 writable set, authoritative) + apps/api/src/routes/events.ts, apps/api/tests/routes/events.test.ts + + - 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) + + + 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). + + + 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 + + + - 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. + + 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. + + @@ -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 | -- `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. -- 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. diff --git a/.planning/phases/03-event-write-back-pwa-install/03-05-PLAN.md b/.planning/phases/03-event-write-back-pwa-install/03-05-PLAN.md index f08475c..e67be03 100644 --- a/.planning/phases/03-event-write-back-pwa-install/03-05-PLAN.md +++ b/.planning/phases/03-event-write-back-pwa-install/03-05-PLAN.md @@ -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. @@ -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 }] }`) 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. - 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` (POST), `updateEvent(uid, payload): Promise` (PATCH `/api/events/${uid}/edit`), and `fetchWritableCalendars(): Promise` (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` (POST), `updateEvent(uid, payload): Promise` (PATCH `/api/events/${uid}/edit`), and `fetchWritableCalendars(): Promise` (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). - 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 + 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 - `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. - Write client calls and form-state Zustand keys exist and type-check. + Write client calls (including fetchWritableCalendars against the Plan 03 endpoint) and form-state Zustand keys exist and type-check. @@ -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) - 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. - 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 ``) 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). cd /home/luc/Projects/familysync && pnpm --filter @familysync/pwa test -- EventForm && pnpm --filter @familysync/pwa exec tsc --noEmit @@ -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 | - `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. - 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. diff --git a/.planning/phases/03-event-write-back-pwa-install/03-PATTERNS.md b/.planning/phases/03-event-write-back-pwa-install/03-PATTERNS.md new file mode 100644 index 0000000..ae257ed --- /dev/null +++ b/.planning/phases/03-event-write-back-pwa-install/03-PATTERNS.md @@ -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> +``` + +**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 { ... } +export async function updateCalendarEvent(...): Promise { ... } +export async function deleteCalendarEvent(...): Promise { ... } +``` + +**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 { ... } + +// 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) */} +