Files
familysync/.planning/phases/03-event-write-back-pwa-install/03-01-PLAN.md
T

18 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, user_setup, must_haves
phase plan type wave depends_on files_modified autonomous requirements user_setup must_haves
03-event-write-back-pwa-install 01 execute 1
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
false
CAL-04
CAL-05
CAL-06
CAL-07
PWA-01
PWA-02
truths artifacts key_links
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)
path provides contains
apps/api/src/db/schema.ts calendarOutbox table + calendarEvents.objectUrl column calendarOutbox
path provides
apps/api/tests/broker/vevent.test.ts RED stubs for VEVENT builder (CAL-04/CAL-07)
path provides
apps/api/tests/broker/outboxWorker.test.ts RED stubs for outbox state machine (D-07/D-08/D-04)
from to via pattern
apps/api/src/broker/sync.ts calendarEvents.objectUrl upsert sets objectUrl from obj.url 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.

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>

@.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

<artifacts_this_phase_produces> 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, 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
  • 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 </artifacts_this_phase_produces>
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; 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. 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 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. 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.

<threat_model>

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
</threat_model>
- `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).

<success_criteria>

  • 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. </success_criteria>
Create `.planning/phases/03-event-write-back-pwa-install/03-01-SUMMARY.md` when done.