From 9dd08d28d1db8ab16e881efd4ee88b1afaf51ece Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Fri, 5 Jun 2026 16:57:01 -0400 Subject: [PATCH] docs(03): create phase plan (8 plans, 5 waves) --- .planning/ROADMAP.md | 26 +- .../03-01-PLAN.md | 245 ++++++++++++++++++ .../03-02-PLAN.md | 160 ++++++++++++ .../03-03-PLAN.md | 154 +++++++++++ .../03-04-PLAN.md | 165 ++++++++++++ .../03-05-PLAN.md | 179 +++++++++++++ .../03-06-PLAN.md | 185 +++++++++++++ .../03-07-PLAN.md | 160 ++++++++++++ .../03-08-PLAN.md | 165 ++++++++++++ 9 files changed, 1438 insertions(+), 1 deletion(-) create mode 100644 .planning/phases/03-event-write-back-pwa-install/03-01-PLAN.md create mode 100644 .planning/phases/03-event-write-back-pwa-install/03-02-PLAN.md create mode 100644 .planning/phases/03-event-write-back-pwa-install/03-03-PLAN.md create mode 100644 .planning/phases/03-event-write-back-pwa-install/03-04-PLAN.md create mode 100644 .planning/phases/03-event-write-back-pwa-install/03-05-PLAN.md create mode 100644 .planning/phases/03-event-write-back-pwa-install/03-06-PLAN.md create mode 100644 .planning/phases/03-event-write-back-pwa-install/03-07-PLAN.md create mode 100644 .planning/phases/03-event-write-back-pwa-install/03-08-PLAN.md diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 4f0e73b..1120faf 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -93,7 +93,31 @@ Plans: 5. The installed PWA opens full-screen without browser chrome on both iOS and Android 6. **(Carried from Phase 1 Gate 2, D-14)** Live Authelia OIDC login works over the public Pangolin URL — including the **iOS standalone-PWA** flow: the wife can install to Home Screen and complete login without the redirect breaking out of standalone mode; sessions persist (AUTH-01/02) and members get distinct stable colors (AUTH-03). Verify per `docs/deployment.md` Gate 2 checklist; this is the first real external deploy (local Newt test rig is sufficient — Unraid prod is optional until go-live). -**Plans**: TBD +**Plans**: 8 plans +Plans: +**Wave 1** + +- [ ] 03-01-PLAN.md — Foundation: calendarOutbox table + calendarEvents.objectUrl (pushed), vite-plugin-pwa install + legitimacy gate, sync.ts objectUrl, full Wave 0 RED test scaffold + +**Wave 2** *(blocked on Wave 1)* + +- [ ] 03-02-PLAN.md — TDD: VEVENT builder (vevent.ts, D-13 DATE/DATETIME + RRULE) + tsdav write wrappers (write.ts, D-12 broker boundary) +- [ ] 03-03-PLAN.md — Write API: POST/PATCH/DELETE events + GET sync-status, enqueue-only, D-03 ownership, D-04 edit-as-move pair (CAL-04/05/06/07) + +**Wave 3** *(blocked on Wave 2)* + +- [ ] 03-04-PLAN.md — TDD: outbox worker state machine (D-05/06/07/08 retry/backoff/dead/conflict, edit-as-move ordering) + index.ts wiring +- [ ] 03-05-PLAN.md — Frontend create/edit slice: write client calls + Zustand keys + EventForm (D-01/02/11) + New Event FAB +- [ ] 03-07-PLAN.md — PWA install: VitePWA manifest + auth-safe SW denylist + icons + InstallPrompt (iOS walkthrough + Android prompt) (PWA-01/02) + +**Wave 4** *(blocked on Wave 3)* + +- [ ] 03-06-PLAN.md — Delete + sync feedback: popover Edit/Delete footer + DeleteConfirmationDialog + SyncStateToast polling (D-06/08/09) (CAL-05/06) + +**Wave 5** *(blocked on Wave 4)* + +- [ ] 03-08-PLAN.md — Gate 2 live verification: real Authelia OIDC over Pangolin + iOS standalone login + end-to-end Fastmail write round-trips (success criterion 6, D-14/D-15) + **UI hint**: yes ### Phase 4: Shared Lists + Live Sync 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 new file mode 100644 index 0000000..ba7d028 --- /dev/null +++ b/.planning/phases/03-event-write-back-pwa-install/03-01-PLAN.md @@ -0,0 +1,245 @@ +--- +phase: 03-event-write-back-pwa-install +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: + - apps/api/src/db/schema.ts + - apps/api/src/broker/sync.ts + - apps/api/tests/broker/vevent.test.ts + - apps/api/tests/broker/write.test.ts + - apps/api/tests/broker/outboxWorker.test.ts + - apps/api/tests/routes/events.test.ts + - apps/pwa/src/components/InstallPrompt.test.tsx + - apps/pwa/package.json + - apps/pwa/vite.config.ts +autonomous: false +requirements: [CAL-04, CAL-05, CAL-06, CAL-07, PWA-01, PWA-02] +user_setup: [] + +must_haves: + truths: + - "calendar_outbox table exists in the live MariaDB schema after drizzle-kit push" + - "calendar_events has an object_url column populated by sync.ts from obj.url" + - "vite-plugin-pwa is installed and importable in apps/pwa" + - "All Wave 0 RED test files exist and fail (no implementation yet)" + artifacts: + - path: "apps/api/src/db/schema.ts" + provides: "calendarOutbox table + calendarEvents.objectUrl column" + contains: "calendarOutbox" + - path: "apps/api/tests/broker/vevent.test.ts" + provides: "RED stubs for VEVENT builder (CAL-04/CAL-07)" + - path: "apps/api/tests/broker/outboxWorker.test.ts" + provides: "RED stubs for outbox state machine (D-07/D-08/D-04)" + key_links: + - from: "apps/api/src/broker/sync.ts" + to: "calendarEvents.objectUrl" + via: "upsert sets objectUrl from obj.url" + pattern: "objectUrl" +--- + + +Stand up the shared foundation for Phase 3: the `calendarOutbox` table and the +`calendarEvents.objectUrl` column (both pushed live to MariaDB), the `vite-plugin-pwa` +dependency, and the complete Wave 0 RED test scaffold for every behavior this phase +implements. No write logic, no worker, no UI is built here — only the substrate the +later vertical slices stand on. + +Purpose: D-05 (server-side outbox) and the CalDAV write path (CAL-04/05/06) cannot +exist without the outbox table and a stored CalDAV object URL. Per the Nyquist rule, +every implementing task in this phase references a test file that MUST exist (RED) +before implementation. This plan creates those files. + +Output: extended schema (pushed), populated `objectUrl` on sync, installed PWA plugin, +five RED test files. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/03-event-write-back-pwa-install/03-CONTEXT.md +@.planning/phases/03-event-write-back-pwa-install/03-RESEARCH.md +@.planning/phases/03-event-write-back-pwa-install/03-PATTERNS.md +@apps/api/src/db/schema.ts +@apps/api/src/broker/sync.ts + + + +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` +- 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` +- Dep: `vite-plugin-pwa` (+ peer `workbox-window`, `workbox-build`) +- PWA assets: `apps/pwa/public/icon-192.png`, `icon-512.png`, `apple-touch-icon.png`, generated `manifest.webmanifest` + service worker + + + + + + Task 1: [BLOCKING] Verify vite-plugin-pwa package legitimacy before install + + - .planning/phases/03-event-write-back-pwa-install/03-RESEARCH.md (§Package Legitimacy Audit — all three packages tagged [ASSUMED], slopcheck unavailable) + - apps/pwa/package.json (confirm vite-plugin-pwa not yet present) + - CLAUDE.md (§Recommended Stack — vite-plugin-pwa 1.3.0 is the locked PWA tooling) + + Verify legitimacy of vite-plugin-pwa and peers (workbox-window, workbox-build) before the Task 2 install per the steps below: npm version check + npmjs.com repository confirmation. This is the T-03-SC supply-chain gate, mandatory because RESEARCH.md tagged all three packages [ASSUMED] (slopcheck unavailable). + Nothing yet — this gate precedes the install. RESEARCH.md tagged `vite-plugin-pwa`, `workbox-window`, `workbox-build` as `[ASSUMED]` because slopcheck could not run. The legitimacy gate is mandatory before any package-manager install (T-03-SC). + + 1. Run `npm view vite-plugin-pwa version` and confirm it resolves to 1.3.0 (or newer 1.x). + 2. Visit https://www.npmjs.com/package/vite-plugin-pwa — confirm repository is github.com/vite-pwa/vite-plugin-pwa, high weekly downloads, recent publish. + 3. Confirm `workbox-window` and `workbox-build` resolve to github.com/GoogleChrome/workbox (Google-maintained). + 4. Confirm `vite-plugin-pwa` appears in CLAUDE.md §Recommended Stack (project-approved). + + + - `npm view vite-plugin-pwa version` returns a 1.x version. + - Operator confirms the npm repository links match github.com/vite-pwa and github.com/GoogleChrome. + + Type "approved" to proceed with install, or describe a mismatch. + + + + Task 2: Extend Drizzle schema — calendarOutbox table + calendarEvents.objectUrl; install vite-plugin-pwa + apps/api/src/db/schema.ts, apps/pwa/package.json + + - apps/api/src/db/schema.ts (existing — imports at lines 1-12; calendarEvents table lines 80-130; calendars/users for references()) + - .planning/phases/03-event-write-back-pwa-install/03-RESEARCH.md (§Pattern 3 — outbox column definitions, indexes; §Open Questions Q2 — objectUrl) + - .planning/phases/03-event-write-back-pwa-install/03-PATTERNS.md (§schema.ts — exact import + table + references patterns) + + + Add `mysqlEnum` to the `drizzle-orm/mysql-core` import in schema.ts (existing import block has mysqlTable, varchar, text, int, date, timestamp, boolean, index, unique). + + Add a new exported `calendarOutbox = mysqlTable('calendar_outbox', {...})` per RESEARCH.md Pattern 3 with columns: `id` (int autoincrement PK), `userId` int('user_id') notNull references users.id onDelete cascade, `operation` mysqlEnum(['create','update','delete']) notNull, `status` mysqlEnum(['pending','done','failed','dead']) notNull default 'pending', `uid` varchar(512) notNull, `calendarUrl` varchar('calendar_url',1024) notNull, `calendarObjectUrl` varchar('calendar_object_url',1024) (nullable), `etag` varchar(256) (nullable), `payload` text (nullable), `attemptCount` int('attempt_count') notNull default 0, `nextAttemptAt` timestamp('next_attempt_at') defaultNow notNull, `lastError` text('last_error'), `createdAt` timestamp defaultNow notNull, `updatedAt` timestamp onUpdateNow. Add a `groupId` varchar('group_id', 64) nullable column to link the delete+create pair for edit-as-move (D-04, RESEARCH.md Pitfall 5). Add three indexes: `idx_outbox_user_status` on (userId, status), `idx_outbox_next_attempt` on (nextAttemptAt, status), `idx_outbox_uid` on (uid). + + On the existing `calendarEvents` table, add `objectUrl: varchar('object_url', { length: 1024 })` (nullable) immediately after the `etag` column — this stores the CalDAV object URL for If-Match update/delete (D-08, RESEARCH.md Open Q2). + + From the apps/pwa directory, install vite-plugin-pwa: `pnpm --filter @familysync/pwa add vite-plugin-pwa` (workbox-window and workbox-build install as peer deps). Do NOT configure the plugin yet (that is Plan 06). + + + cd /home/luc/Projects/familysync && pnpm --filter @familysync/api exec tsc --noEmit && grep -q "calendar_outbox" apps/api/src/db/schema.ts && grep -q "object_url" apps/api/src/db/schema.ts && grep -q '"vite-plugin-pwa"' apps/pwa/package.json + + + - `grep -c "calendar_outbox" apps/api/src/db/schema.ts` returns ≥1. + - `grep -c "object_url" apps/api/src/db/schema.ts` returns ≥1. + - `apps/pwa/package.json` dependencies/devDependencies include `vite-plugin-pwa`. + - `pnpm --filter @familysync/api exec tsc --noEmit` exits 0. + + calendarOutbox table and calendarEvents.objectUrl exist in schema.ts; vite-plugin-pwa installed; types compile. + + + + Task 3: Populate calendarEvents.objectUrl in sync.ts + apps/api/src/broker/sync.ts + + - apps/api/src/broker/sync.ts (existing — the calendarEvents upsert at lines ~100-130 sets etag from obj.etag; objectUrl is added alongside) + - .planning/phases/03-event-write-back-pwa-install/03-RESEARCH.md (§Open Questions Q1/Q2 — obj.url is returned by tsdav fetchCalendarObjects) + + + In `syncCalendar`, in the `for (const obj of objects)` loop, set `objectUrl: obj.url ?? null` in BOTH the `.values({...})` block and the `.onDuplicateKeyUpdate({ set: {...} })` block of the calendarEvents upsert, right next to the existing `etag: obj.etag ?? null` lines. `obj.url` is the CalDAV object URL needed by update/delete (D-08). Do not change any other behavior; D-13 DATE/DATETIME split is unaffected. + + + cd /home/luc/Projects/familysync && grep -c "objectUrl: obj.url" apps/api/src/broker/sync.ts | grep -qx 2 && pnpm --filter @familysync/api test -- broker/sync + + + - `grep -c "objectUrl: obj.url" apps/api/src/broker/sync.ts` returns exactly 2 (values + onDuplicateKeyUpdate). + - Existing `broker/sync` test suite stays green. + + sync.ts stores obj.url into calendarEvents.objectUrl on every upsert; sync tests pass. + + + + Task 4: Create Wave 0 RED test scaffold for all Phase 3 behaviors + apps/api/tests/broker/vevent.test.ts, apps/api/tests/broker/write.test.ts, apps/api/tests/broker/outboxWorker.test.ts, apps/api/tests/routes/events.test.ts, apps/pwa/src/components/InstallPrompt.test.tsx + + - .planning/phases/03-event-write-back-pwa-install/03-RESEARCH.md (§Validation Architecture — Phase Requirements → Test Map; Wave 0 Gaps list) + - .planning/phases/03-event-write-back-pwa-install/03-PATTERNS.md (§Drizzle DB mock in tests, §OIDC mock in tests — exact vi.mock shapes) + - apps/api/tests/routes/events.test.ts (existing — extend, do not overwrite; copy its db + oidc mock setup) + - apps/api/tests/broker/sync.test.ts (analog for outboxWorker.test.ts structure) + + + Write FAILING (RED) tests — import the not-yet-existing modules so they error/fail. Cover, per RESEARCH.md Test Map: + - 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). + - 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. + + + Create the five test files with the behaviors above using Vitest. Use the existing Drizzle and OIDC mock patterns from PATTERNS.md verbatim. Where the implementation module does not exist yet, the import will fail — that is the intended RED state. For events.test.ts, EXTEND the existing file (append new describe blocks); do not delete existing GET /api/events tests. Mark any behavior that is manual-only (none here — Gate 2 manual checks live in Plan 07) out of scope. Do NOT write implementation code in this plan. + + + cd /home/luc/Projects/familysync && test -f apps/api/tests/broker/vevent.test.ts && test -f apps/api/tests/broker/write.test.ts && test -f apps/api/tests/broker/outboxWorker.test.ts && test -f apps/pwa/src/components/InstallPrompt.test.tsx && (pnpm --filter @familysync/api test -- broker/vevent 2>&1 | grep -Eq "fail|error|No test|Cannot find") + + + - All five test files exist. + - `pnpm --filter @familysync/api test -- broker/vevent` reports failures or unresolved imports (RED — implementation not present). + - 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. + + + + Task 5: [BLOCKING] Push schema to MariaDB (drizzle-kit push) + + - apps/api/src/db/schema.ts (modified — must contain calendarOutbox + objectUrl before push) + - .planning/STATE.md (§Pending Todos — local-dev env requires sourcing .env and DB_HOST=localhost) + + Run the Drizzle schema push against the live MariaDB so the calendar_outbox table and calendar_events.object_url column exist before verification (types compile from the schema file, not the DB, so this is mandatory). Use the env-loaded push command below; abort on any reported destructive operation. + The schema file now declares the `calendar_outbox` table and `calendar_events.object_url` column. The live MariaDB has NOT been altered — types compile from the schema file, not the live DB, so verification would falsely pass without this push. + + 1. Run the push (env must be loaded, MariaDB up): `set -a; source .env; set +a && DB_HOST=localhost pnpm --filter @familysync/api exec drizzle-kit push`. + 2. If drizzle-kit prompts for confirmation on a non-destructive create, accept it. If it reports a DESTRUCTIVE change, STOP and report — do not drop data. + 3. Confirm the table exists: `mysql ... -e "SHOW TABLES LIKE 'calendar_outbox'; SHOW COLUMNS FROM calendar_events LIKE 'object_url';"`. + + + - `SHOW TABLES LIKE 'calendar_outbox'` returns one row. + - `SHOW COLUMNS FROM calendar_events LIKE 'object_url'` returns one row. + - drizzle-kit push reported no unexpected destructive operation. + + Type "pushed" once the table and column exist in MariaDB, or report a destructive-change warning. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| package registry → build | New npm dependency (vite-plugin-pwa) enters the supply chain | +| schema file → live DB | drizzle-kit push mutates the production schema | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-03-SC | Tampering | vite-plugin-pwa + workbox peer deps install | mitigate | Blocking human-verify legitimacy checkpoint (Task 1) before install; `npm view` version check; npmjs.com repo confirmation | +| T-03-01 | Tampering | drizzle-kit push | mitigate | Blocking human-action checkpoint (Task 5); abort on any reported destructive operation | +| T-03-02 | Information Disclosure | calendar_outbox stores payload/etag | accept | Outbox rows are server-side only, never exposed to frontend; payload is the member's own VEVENT | + + + +- `pnpm --filter @familysync/api exec tsc --noEmit` passes. +- `calendar_outbox` table and `calendar_events.object_url` exist in live MariaDB (Task 5). +- vite-plugin-pwa present in apps/pwa/package.json. +- Five RED test files exist and fail (no implementation). + + + +- Outbox table + objectUrl column pushed live (the schema-push blocking requirement is satisfied here for the schema introduced this wave). +- PWA tooling installed and legitimacy-gated. +- Complete Wave 0 RED scaffold in place for every later implementing task. + + + +Create `.planning/phases/03-event-write-back-pwa-install/03-01-SUMMARY.md` when done. + diff --git a/.planning/phases/03-event-write-back-pwa-install/03-02-PLAN.md b/.planning/phases/03-event-write-back-pwa-install/03-02-PLAN.md new file mode 100644 index 0000000..07d913f --- /dev/null +++ b/.planning/phases/03-event-write-back-pwa-install/03-02-PLAN.md @@ -0,0 +1,160 @@ +--- +phase: 03-event-write-back-pwa-install +plan: 02 +type: tdd +wave: 2 +depends_on: ["03-01"] +files_modified: + - apps/api/src/broker/vevent.ts + - apps/api/src/broker/write.ts + - apps/api/tests/broker/vevent.test.ts + - apps/api/tests/broker/write.test.ts +autonomous: true +requirements: [CAL-04, CAL-05, CAL-06, CAL-07] +user_setup: [] + +must_haves: + truths: + - "buildVeventString produces a valid VCALENDAR/VEVENT for timed, all-day, and recurring events" + - "All-day events serialize as DATE (no time component, no TZID) per D-13 — never coerced to DATETIME" + - "createCalendarEvent / updateCalendarEvent / deleteCalendarEvent route all Fastmail writes through tsdav with correct If-Match/If-None-Match" + artifacts: + - path: "apps/api/src/broker/vevent.ts" + provides: "buildVeventString(NewEventParams) → { uid, icsString }" + exports: ["buildVeventString", "NewEventParams", "RRULE_PRESETS"] + min_lines: 40 + - path: "apps/api/src/broker/write.ts" + provides: "tsdav PUT/DELETE wrappers (broker boundary, D-12)" + exports: ["createCalendarEvent", "updateCalendarEvent", "deleteCalendarEvent"] + key_links: + - from: "apps/api/src/broker/vevent.ts" + to: "ical.js ICAL.Component / ICAL.Time" + via: "VEVENT construction" + pattern: "ICAL\\.(Component|Time)" + - from: "apps/api/src/broker/write.ts" + to: "tsdav createCalendarObject/updateCalendarObject/deleteCalendarObject" + via: "FastmailClient methods" + pattern: "(create|update|delete)CalendarObject" +--- + + +Build the two pure broker primitives every write slice depends on: `vevent.ts` +(construct a valid iCalendar VEVENT from form params) and `write.ts` (wrap tsdav's +three CalDAV write methods to enforce the broker boundary, D-12). These are the most +testable units in the phase — defined input → defined ICS/HTTP output — so they are +built TDD against the RED stubs from Plan 01. + +Purpose: CAL-04/05/06/07 all reduce to "produce the right VEVENT and PUT/DELETE it +through tsdav." Getting the D-13 DATE-vs-DATETIME split and the If-Match wiring right +here means the worker (Plan 03) and endpoints (Plan 04) just orchestrate. + +Output: `vevent.ts`, `write.ts`, both GREEN against their Plan 01 test files. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/phases/03-event-write-back-pwa-install/03-RESEARCH.md +@.planning/phases/03-event-write-back-pwa-install/03-PATTERNS.md +@apps/api/src/broker/client.ts +@apps/api/src/broker/sync.ts + + + + + + Task 1: GREEN — buildVeventString VEVENT builder (vevent.ts) + apps/api/src/broker/vevent.ts, apps/api/tests/broker/vevent.test.ts + + - apps/api/tests/broker/vevent.test.ts (RED stubs from Plan 01 — these define the contract) + - .planning/phases/03-event-write-back-pwa-install/03-RESEARCH.md (§Pattern 1 — full buildVeventString reference incl. NewEventParams; §Pitfall 3 — DATE vs DATETIME) + - apps/api/src/broker/sync.ts (lines ~89-101 — the existing D-13 isDate split this must mirror in reverse) + - .planning/phases/03-event-write-back-pwa-install/03-PATTERNS.md (§vevent.ts — ICAL import, D-13 split, error isolation) + + + RED → GREEN. Tests assert: + - Timed event: output contains `BEGIN:VEVENT`, `DTSTART:` with a `Z` UTC suffix (no TZID param), matching UID and SUMMARY. + - All-day event (allDay:true): DTSTART is a DATE value (`VALUE=DATE` or 8-digit YYYYMMDD with no `T`/time), NO TZID, NO time component (D-13). End is also DATE. + - Recurring: passing `rruleString: 'FREQ=WEEKLY'` yields an `RRULE:FREQ=WEEKLY` line. + - location/description optional properties appear only when provided. + - omitting `uid` generates a `@familysync` UID via crypto.randomUUID(). + + + Implement `buildVeventString(params: NewEventParams): { uid: string; icsString: string }` exactly per RESEARCH.md Pattern 1. Export the `NewEventParams` interface and a `RRULE_PRESETS` map (`daily:'FREQ=DAILY'`, `weekly:'FREQ=WEEKLY'`, `monthly:'FREQ=MONTHLY'`, `yearly:'FREQ=YEARLY'`). Use `import ICAL from 'ical.js'` and `import { randomUUID } from 'crypto'`. For all-day use `new ICAL.Time({ year, month, day, isDate: true })`; for timed use `ICAL.Time.fromJSDate(date, true)` (useUTC=true → Z suffix, no TZID). Always add VERSION 2.0 and PRODID `-//FamilySync//FamilySync//EN`. Use `.js`-suffixed relative imports if any. Never coerce DATE→DATETIME. + + + cd /home/luc/Projects/familysync && pnpm --filter @familysync/api test -- broker/vevent + + + - `pnpm --filter @familysync/api test -- broker/vevent` is GREEN (all assertions pass). + - All-day test asserts no `T000000`/time component and no `TZID` in the DATE DTSTART. + - `grep -q "RRULE_PRESETS" apps/api/src/broker/vevent.ts`. + + buildVeventString passes all vevent.test.ts cases including the D-13 DATE-vs-DATETIME split and RRULE serialization. + + + + Task 2: GREEN — tsdav write wrappers (write.ts) + apps/api/src/broker/write.ts, apps/api/tests/broker/write.test.ts + + - apps/api/tests/broker/write.test.ts (RED stubs from Plan 01 — the contract) + - .planning/phases/03-event-write-back-pwa-install/03-RESEARCH.md (§Pattern 2 — full write.ts reference; status-code interpretation; §Pitfall 4 — etag may be null) + - apps/api/src/broker/client.ts (FastmailClient type; .js import convention; named-export style) + - .planning/phases/03-event-write-back-pwa-install/03-PATTERNS.md (§write.ts — header/imports/exports pattern) + + + RED → GREEN. With a mock FastmailClient, tests assert: + - createCalendarEvent({client, calendar, uid, icsString}) calls `client.createCalendarObject` with `filename === \`${uid}.ics\`` and the iCalString, and returns the raw Response. + - updateCalendarEvent(client, calendarObjectUrl, icsString, etag) calls `client.updateCalendarObject` with calendarObject `{ url, data, etag }` — etag drives the If-Match header. + - deleteCalendarEvent(client, calendarObjectUrl, etag) calls `client.deleteCalendarObject` with `{ url, etag }`. + - A null etag is passed through as `''` (no crash). + + + Implement `createCalendarEvent`, `updateCalendarEvent`, `deleteCalendarEvent` per RESEARCH.md Pattern 2 as named exports returning `Promise`. Import `FastmailClient` from `./client.js` and `DAVCalendar` from `tsdav`. These functions are the ONLY place outside client.ts/sync.ts/poller.ts that touch tsdav write methods (D-12 broker boundary). Do not interpret status codes here — return the raw Response so the worker (Plan 03) classifies transient/hard/conflict. If `deleteCalendarObject` requires a `data` field, pass `''`. + + + cd /home/luc/Projects/familysync && pnpm --filter @familysync/api test -- broker/write && pnpm --filter @familysync/api exec tsc --noEmit + + + - `pnpm --filter @familysync/api test -- broker/write` is GREEN. + - `grep -Eq "createCalendarObject|updateCalendarObject|deleteCalendarObject" apps/api/src/broker/write.ts` (all three present). + - tsc --noEmit passes. + + write.ts wraps all three tsdav write methods with correct filenames/If-Match wiring; tests GREEN; types compile. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| broker → Fastmail CalDAV | Only write.ts issues PUT/DELETE to Fastmail (D-12) | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-03-03 | Tampering | VEVENT field serialization (summary/location/description with special chars) | mitigate | ical.js ICAL.Component handles line-folding + escaping (commas, semicolons, newlines); never hand-roll ICS strings (RESEARCH §Don't Hand-Roll) | +| T-03-04 | Spoofing | etag forgery to bypass conflict detection | mitigate | etag is sourced server-side (calendarEvents.etag) by the worker, never accepted from the browser; write.ts only forwards what the server supplies | +| T-03-05 | Elevation of Privilege | write.ts called with another member's calendar | accept (here) | Calendar ownership is enforced at the route layer (Plan 04, V4); write.ts is a low-level primitive with no auth context | + + + +- `pnpm --filter @familysync/api test -- broker/vevent` GREEN. +- `pnpm --filter @familysync/api test -- broker/write` GREEN. +- `pnpm --filter @familysync/api exec tsc --noEmit` passes. + + + +- VEVENT builder correct for timed, all-day (DATE), and recurring events. +- tsdav write wrappers enforce the broker boundary with correct If-Match/filename wiring. + + + +Create `.planning/phases/03-event-write-back-pwa-install/03-02-SUMMARY.md` when done. + 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 new file mode 100644 index 0000000..c3a1e89 --- /dev/null +++ b/.planning/phases/03-event-write-back-pwa-install/03-03-PLAN.md @@ -0,0 +1,154 @@ +--- +phase: 03-event-write-back-pwa-install +plan: 03 +type: execute +wave: 2 +depends_on: ["03-01"] +files_modified: + - apps/api/src/routes/events.ts + - apps/api/tests/routes/events.test.ts +autonomous: true +requirements: [CAL-04, CAL-05, CAL-06, CAL-07] +user_setup: [] + +must_haves: + truths: + - "POST /api/events/create validates input, resolves the writable target calendar, enqueues a pending outbox row, and returns 202" + - "PATCH /api/events/:uid/edit and DELETE /api/events/:uid enqueue update/delete outbox rows with the cached etag" + - "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)" + artifacts: + - path: "apps/api/src/routes/events.ts" + provides: "create/edit/delete write endpoints + sync-status, all enqueue-only (broker boundary)" + contains: "/sync-status" + key_links: + - from: "apps/api/src/routes/events.ts" + to: "calendarOutbox" + via: "db.insert(calendarOutbox)" + pattern: "calendarOutbox" + - from: "apps/api/src/routes/events.ts" + to: "calendars (ownership check)" + via: "WHERE userId = currentUser.id" + pattern: "calendars\\.userId" +--- + + +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). + +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). + +Output: extended events.ts, GREEN against the create/edit/delete/sync-status tests from Plan 01. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/phases/03-event-write-back-pwa-install/03-RESEARCH.md +@.planning/phases/03-event-write-back-pwa-install/03-PATTERNS.md +@apps/api/src/routes/events.ts +@apps/api/src/routes/me.ts + + + + + + Task 1: GREEN — write endpoints (create/edit/delete) with ownership enforcement + apps/api/src/routes/events.ts, apps/api/tests/routes/events.test.ts + + - apps/api/tests/routes/events.test.ts (RED stubs from Plan 01 for create/edit/delete + 403 ownership) + - apps/api/src/routes/events.ts (existing — header invariant comment, Hono+zValidator pattern, GET handler shape to mirror) + - apps/api/src/routes/me.ts (lines ~29-49 — dev-bypass + getAuth current-user pattern; side-effect import of devBypass.js) + - .planning/phases/03-event-write-back-pwa-install/03-RESEARCH.md (§Security Domain V4/V5 — ownership check + zod bounds; §Pitfall 5 — edit-as-move pair in one transaction) + - .planning/phases/03-event-write-back-pwa-install/03-PATTERNS.md (§events.ts, §Auth guard in write route handlers, §Drizzle DB mock in tests) + + + Extend `eventsRouter` (keep the existing GET / and the broker-boundary header comment — append a note that write endpoints enqueue only). Import `calendarOutbox` from `../db/schema.js`, `and`/`eq` from drizzle-orm, and the auth helpers per me.ts (`getAuth`, side-effect `import '../auth/devBypass.js'`). Resolve the current member id via the dev-bypass `c.get('user')` path then `getAuth(c)` fallback (401 if neither). + + Define zod schemas with bounded lengths (V5): `title` 1..255, `location`/`description` optional max 2000, `allDay` boolean, `start`/`end` ISO strings, optional `recurrence` enum (`none|daily|weekly|monthly|yearly`), optional `calendarUrl`. Use `@hono/zod-validator` `zValidator('json', schema)`. + + POST `/create`: resolve the writable target calendar — if `calendarUrl` given, assert a row in `calendars WHERE url=calendarUrl AND (userId=currentUser.id OR isShared=1)`; else default to the member's personal calendar (`calendars WHERE userId=currentUser.id` first row; D-01 last-used is a frontend concern). Reject a non-owned, non-shared calendar with 403 (D-03 / V4). Insert a `calendarOutbox` row `{ userId, operation:'create', status:'pending', uid: , calendarUrl, payload: JSON of the validated event fields }`. Return `c.json({ uid }, 202)`. + + PATCH `/:uid/edit`: look up the cached event by uid joined to a calendar owned by the member; 404 if not found, 403 if not owned. Read `etag` and `objectUrl` from calendarEvents. If the request's target `calendarUrl` differs from the event's current calendar (calendar move, D-04): insert TWO outbox rows in a SINGLE `db.transaction` sharing a `groupId` — a `create` row (new calendarUrl) and a `delete` row (old calendarObjectUrl + etag). Otherwise insert one `update` row with `calendarObjectUrl`, `etag`, `payload`. Return 202. + + DELETE `/:uid`: ownership check as above; insert a `delete` outbox row with `calendarObjectUrl` + `etag`. Return 202. + + Do NOT build the VEVENT here and do NOT call Fastmail — the worker (Plan 04 wiring) does both. Wrap DB work in try/catch returning 503 per the existing pattern. + + + cd /home/luc/Projects/familysync && pnpm --filter @familysync/api test -- routes/events && pnpm --filter @familysync/api exec tsc --noEmit + + + - create/edit/delete tests GREEN, each asserting a 202 and a `db.insert(calendarOutbox)` call. + - The 403 ownership test GREEN: writing to a non-owned/non-shared calendar is rejected. + - `grep -q "db.transaction" apps/api/src/routes/events.ts` (edit-as-move pair). + - The existing GET /api/events tests remain GREEN. + + create/edit/delete endpoints enqueue outbox rows, enforce D-03 ownership, return 202, and handle the edit-as-move pair transactionally; no Fastmail call in the route. + + + + Task 2: GREEN — GET /api/events/sync-status polled endpoint (D-09) + apps/api/src/routes/events.ts, apps/api/tests/routes/events.test.ts + + - apps/api/tests/routes/events.test.ts (RED sync-status stub from Plan 01) + - .planning/phases/03-event-write-back-pwa-install/03-RESEARCH.md (§Pattern 8 — sync-status request/response shape) + + + Add `eventsRouter.get('/sync-status', zValidator('query', z.object({ uid: z.string().min(1).max(512) })), ...)`. Resolve current member (same auth pattern). Select the most recent `calendarOutbox` row `WHERE userId=currentUser.id AND uid=:uid` ordered by `createdAt` desc, limit 1. Return `c.json({ uid, status, error: lastError ?? undefined })` where status ∈ pending|done|failed|dead. If no row, return `{ uid, status: 'done' }` (nothing pending → treat as settled). Scope strictly to the member's own rows (V4 — never leak another member's outbox). + + + cd /home/luc/Projects/familysync && pnpm --filter @familysync/api test -- routes/events && grep -q "/sync-status" apps/api/src/routes/events.ts + + + - sync-status test GREEN: returns the outbox status for a given uid scoped to the member. + - `grep -c "/sync-status" apps/api/src/routes/events.ts` ≥1. + + GET /api/events/sync-status returns the member-scoped outbox status; tests GREEN. + + + + + +## Trust Boundaries + +| 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 | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-03-06 | Elevation of Privilege | write to another member's personal calendar | mitigate | Route asserts `calendars.userId === currentUser.id OR isShared=1` before enqueue; else 403 (D-03 / ASVS V4) | +| T-03-07 | Information Disclosure | sync-status leaking another member's outbox row | mitigate | sync-status query filtered `WHERE userId = currentUser.id` | +| 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 | + + + +- `pnpm --filter @familysync/api test -- routes/events` GREEN (create, edit, delete, sync-status, 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. + + + +Create `.planning/phases/03-event-write-back-pwa-install/03-03-SUMMARY.md` when done. + diff --git a/.planning/phases/03-event-write-back-pwa-install/03-04-PLAN.md b/.planning/phases/03-event-write-back-pwa-install/03-04-PLAN.md new file mode 100644 index 0000000..55e0ade --- /dev/null +++ b/.planning/phases/03-event-write-back-pwa-install/03-04-PLAN.md @@ -0,0 +1,165 @@ +--- +phase: 03-event-write-back-pwa-install +plan: 04 +type: tdd +wave: 3 +depends_on: ["03-02", "03-03"] +files_modified: + - apps/api/src/broker/outboxWorker.ts + - apps/api/src/index.ts + - apps/api/tests/broker/outboxWorker.test.ts +autonomous: true +requirements: [CAL-04, CAL-05, CAL-06, CAL-07] +user_setup: [] + +must_haves: + truths: + - "The worker drains pending outbox rows, builds the VEVENT, PUTs/DELETEs via the broker, and triggers a targeted single-calendar re-sync on success (D-06)" + - "Transient failures (5xx/network/timeout) back off exponentially within a bounded window; max attempts → dead (D-07)" + - "Hard failures (400/401/403) stop immediately as failed (D-07)" + - "412 conflicts route OUT of the retry loop into the conflict flow: mark failed, re-sync, no overwrite (D-08)" + - "Edit-as-move processes the create row before the linked delete row; create-fail aborts the delete (D-04)" + - "The worker is started from index.ts as a sibling to the ctag poller" + artifacts: + - path: "apps/api/src/broker/outboxWorker.ts" + provides: "runOutboxDrain + startOutboxWorker (state machine, retry/backoff, re-sync)" + exports: ["runOutboxDrain", "startOutboxWorker"] + min_lines: 60 + - path: "apps/api/src/index.ts" + provides: "startOutboxWorker() wired at startup" + contains: "startOutboxWorker" + key_links: + - from: "apps/api/src/broker/outboxWorker.ts" + to: "broker/write.ts" + via: "create/update/deleteCalendarEvent" + pattern: "(create|update|delete)CalendarEvent" + - from: "apps/api/src/broker/outboxWorker.ts" + to: "broker/sync.ts syncCalendar" + via: "targeted re-sync on confirm (D-06)" + pattern: "syncCalendar" + - from: "apps/api/src/index.ts" + to: "startOutboxWorker" + via: "background worker startup" + pattern: "startOutboxWorker" +--- + + +Build the outbox worker — the load-bearing async engine of D-05/06/07/08. It drains +pending `calendar_outbox` rows, builds the VEVENT (Plan 02 `vevent.ts`), writes through +the broker (Plan 02 `write.ts`), classifies the response (transient/hard/conflict), +and on success triggers a targeted single-calendar re-sync (Plan 03 endpoints filled the +queue; existing `sync.ts` re-syncs). Then wire it into `index.ts` beside the ctag poller. + +Purpose: this closes the create/edit/delete loop end-to-end — after this plan a queued +write actually reaches Fastmail and the cache becomes authoritative. Built TDD because +the state machine (backoff, dead-letter, 412 routing, edit-as-move ordering) is the +highest-risk logic in the phase. + +Output: `outboxWorker.ts` GREEN against Plan 01's state-machine tests; worker started at boot. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/phases/03-event-write-back-pwa-install/03-RESEARCH.md +@.planning/phases/03-event-write-back-pwa-install/03-PATTERNS.md +@apps/api/src/broker/poller.ts +@apps/api/src/broker/sync.ts +@apps/api/src/index.ts + + + + + + Task 1: GREEN — outbox drain state machine (outboxWorker.ts) + apps/api/src/broker/outboxWorker.ts, apps/api/tests/broker/outboxWorker.test.ts + + - apps/api/tests/broker/outboxWorker.test.ts (RED state-machine stubs from Plan 01 — the contract) + - .planning/phases/03-event-write-back-pwa-install/03-RESEARCH.md (§Pattern 4 — full runOutboxDrain reference; status classification sets; §Pitfall 5 edit-as-move; §Pitfall 7 DAVCalendar fetch for re-sync; §Pitfall 4 etag re-fetch) + - apps/api/src/broker/poller.ts (analog — runX/startX pair, node-cron schedule, per-item error isolation, decrypt-then-client pattern, Drizzle select/where/limit) + - apps/api/src/broker/sync.ts (syncCalendar signature: client, davCal, userId) + - apps/api/src/broker/write.ts (create/update/deleteCalendarEvent — from Plan 02) + - apps/api/src/broker/vevent.ts (buildVeventString — from Plan 02) + - .planning/phases/03-event-write-back-pwa-install/03-PATTERNS.md (§outboxWorker.ts — exact poller-derived patterns) + + + RED → GREEN. With mocked db, write.ts, sync.ts, and Fastmail client, tests assert: + - pending row + mock create response 204/201 → status='done' AND triggerTargetedResync called for that calendarUrl (D-06). + - mock response 412 → status='failed', re-sync triggered, NO retry, NO overwrite (D-08 conflict flow). + - mock response 500 (transient) → status stays 'pending', attemptCount incremented, nextAttemptAt advanced by the backoff schedule (D-07). + - transient failures repeated until attemptCount === MAX_ATTEMPTS → status='dead'. + - mock response 401/403/400 (hard) → status='failed' immediately, no retry (D-07). + - edit-as-move pair (shared groupId): the 'create' row is dispatched before the linked 'delete' row; if create fails, the delete is NOT executed (D-04 — duplicate is recoverable, lost event is not). + + + Implement `runOutboxDrain()` and `startOutboxWorker()` per RESEARCH.md Pattern 4. Constants: `MAX_ATTEMPTS=5`, `BACKOFF_SECONDS=[15,60,300,600,1800]`, `TRANSIENT_STATUSES={408,429,500,502,503,504}`, `HARD_FAIL_STATUSES={400,401,403}`, `CONFLICT_STATUS=412`. Select `WHERE status='pending' AND next_attempt_at <= NOW()` limit 10. For each row: load the owning member's credential+client (decrypt via crypto.js + createFastmailClient like poller.ts), build the VEVENT via `buildVeventString` from the row payload for create/update, call the matching write.ts function, classify the Response status. On success or 412 call `triggerTargetedResync(calendarUrl, userId)` which fetches calendars via `client.fetchCalendars()`, finds the DAVCalendar by url (Pitfall 7), and calls `syncCalendar` — this captures the fresh etag/objectUrl (Pitfall 4). Update outbox status with the Drizzle update pattern. Order edit-as-move: process rows ordered so a row with `operation='create'` and a groupId runs before its sibling `operation='delete'`; on create failure skip the linked delete. Per-row try/catch logs without crashing the loop; never log decrypted passwords (T-03-04). `startOutboxWorker` schedules `runOutboxDrain` every 15s (node-cron `*/15 * * * * *` or setInterval), mirroring `startBrokerPoller`. + + + cd /home/luc/Projects/familysync && pnpm --filter @familysync/api test -- broker/outboxWorker && pnpm --filter @familysync/api exec tsc --noEmit + + + - outboxWorker.test.ts GREEN for all six behaviors (done, 412-conflict, backoff, dead, hard-fail, edit-as-move order). + - `grep -q "syncCalendar" apps/api/src/broker/outboxWorker.ts` (D-06 re-sync). + - `grep -Eq "412|CONFLICT_STATUS" apps/api/src/broker/outboxWorker.ts` (D-08). + - tsc --noEmit passes. + + The outbox worker drains, writes, classifies, re-syncs, and handles backoff/dead/conflict/edit-as-move exactly per D-04/06/07/08; tests GREEN. + + + + Task 2: Wire startOutboxWorker into index.ts beside the ctag poller + apps/api/src/index.ts + + - apps/api/src/index.ts (existing — startBrokerPoller() is called near the bottom; mirror placement/import style) + - apps/api/src/broker/outboxWorker.ts (from Task 1 — exports startOutboxWorker) + + + Add `import { startOutboxWorker } from './broker/outboxWorker.js'` next to the existing poller import. Call `startOutboxWorker()` immediately after the existing `startBrokerPoller()` call, with a one-line comment noting it drains the D-05 outbox every 15s. Do not move or alter the poller, route mounts, OIDC guard, or server-start guard. + + + cd /home/luc/Projects/familysync && grep -q "startOutboxWorker()" apps/api/src/index.ts && pnpm --filter @familysync/api exec tsc --noEmit && pnpm --filter @familysync/api test + + + - `grep -c "startOutboxWorker()" apps/api/src/index.ts` ≥1. + - Full API test suite GREEN; tsc --noEmit passes. + + The outbox worker starts at API boot alongside the poller; full API suite green. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| worker → Fastmail | The worker is the only component that drains the outbox to Fastmail | +| stored payload → VEVENT | Member-supplied payload is reconstructed into an ICS PUT | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-03-11 | Repudiation | silent last-write-wins on concurrent edit | mitigate | 412 If-Match conflict routes to conflict flow (re-sync + warn), never overwrites (D-08) | +| T-03-12 | Denial of Service | a poison row retrying forever | mitigate | MAX_ATTEMPTS=5 then dead-letter; bounded backoff window (~30 min) per D-07 | +| T-03-13 | Information Disclosure | logging decrypted app password during dispatch | mitigate | Per-item catch logs `err.message` only; never the credential (poller T-03-04 pattern) | +| T-03-14 | Tampering | partial-failure data loss on edit-as-move | mitigate | create-before-delete ordering; create-fail aborts delete; delete-fail surfaces "remove manually" (D-04) | + + + +- `pnpm --filter @familysync/api test` full suite GREEN (includes outboxWorker + routes/events from Plan 03). +- `grep -c "startOutboxWorker()" apps/api/src/index.ts` ≥1. +- No tsdav import outside broker/: worker uses write.ts/client.ts only. + + + +- End-to-end backend write loop closed: endpoint → outbox → worker → Fastmail → re-sync → cache authoritative. +- D-04/D-06/D-07/D-08 all enforced and tested. + + + +Create `.planning/phases/03-event-write-back-pwa-install/03-04-SUMMARY.md` when done. + 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 new file mode 100644 index 0000000..f08475c --- /dev/null +++ b/.planning/phases/03-event-write-back-pwa-install/03-05-PLAN.md @@ -0,0 +1,179 @@ +--- +phase: 03-event-write-back-pwa-install +plan: 05 +type: execute +wave: 3 +depends_on: ["03-03"] +files_modified: + - apps/pwa/src/api/client.ts + - apps/pwa/src/store/calendarStore.ts + - apps/pwa/src/components/EventForm.tsx + - apps/pwa/src/components/CalendarShell.tsx +autonomous: true +requirements: [CAL-04, CAL-05, CAL-07] +user_setup: [] + +must_haves: + truths: + - "A member can tap 'New Event', fill the form, and save — POST /api/events/create fires and the form closes" + - "The form supports timed and all-day events, a recurrence preset (None/Daily/Weekly/Monthly/Yearly), title/location/description" + - "The calendar picker is hidden when the member has exactly one writable calendar (D-02)" + - "Edit mode pre-populates the form and calls PATCH /api/events/:uid/edit" + artifacts: + - path: "apps/pwa/src/components/EventForm.tsx" + provides: "create/edit modal form (bottom sheet on phone, dialog on desktop)" + min_lines: 80 + - path: "apps/pwa/src/api/client.ts" + provides: "createEvent, updateEvent, fetchWritableCalendars typed calls" + exports: ["createEvent", "updateEvent", "fetchWritableCalendars"] + key_links: + - from: "apps/pwa/src/components/EventForm.tsx" + to: "/api/events/create" + via: "createEvent mutation" + pattern: "createEvent" + - from: "apps/pwa/src/components/CalendarShell.tsx" + to: "EventForm" + via: "New Event FAB toggles eventFormOpen" + pattern: "eventFormOpen" +--- + + +Build the create/edit event UI: the typed write client calls, the Zustand form-state +keys, the `EventForm` modal (timed/all-day/recurring fields, conditional calendar +picker), and the "New Event" FAB/toolbar entry on the calendar shell. This is the +front half of the create and edit vertical slices — after this plan a member can +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). + +Output: EventForm + client write calls + store keys + FAB, all wired to the Plan 03 API. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/phases/03-event-write-back-pwa-install/03-UI-SPEC.md +@.planning/phases/03-event-write-back-pwa-install/03-PATTERNS.md +@apps/pwa/src/api/client.ts +@apps/pwa/src/store/calendarStore.ts +@apps/pwa/src/components/EventDetailPopover.tsx +@apps/pwa/src/components/CalendarShell.tsx + + + + + + Task 1: Typed write client calls + Zustand form-state keys + apps/pwa/src/api/client.ts, apps/pwa/src/store/calendarStore.ts + + - apps/pwa/src/api/client.ts (existing — fetch function + interface-first pattern; CalendarOccurrence shape) + - 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) + + + 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. + - 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 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 + + + - `grep -Eq "createEvent|updateEvent" apps/pwa/src/api/client.ts`. + - `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. + + + + Task 2: EventForm modal (create + edit) per UI Design Contract + apps/pwa/src/components/EventForm.tsx + + - .planning/phases/03-event-write-back-pwa-install/03-UI-SPEC.md (§EventForm — field order/types/required; §CalendarPicker D-02; §Recurrence picker; §Copywriting Contract; §Interaction Contract all-day toggle + recurrence + keyboard; §Spacing/Typography/Color tokens) + - apps/pwa/src/components/EventDetailPopover.tsx (analog — backdrop+dialog structure ~202-221, Escape+focus-trap useEffect ~143-159, responsive isPhone/dialogStyle ~165-199, design tokens, XSS plain-text rule) + - .planning/phases/03-event-write-back-pwa-install/03-PATTERNS.md (§EventForm.tsx — modal/overlay, focus trap, TanStack mutation, Zustand) + - 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. + + + 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 `