---
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'
---

<objective>
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.
</objective>

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

<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
</context>

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

<tasks>

<task type="checkpoint:human-verify" gate="blocking-human">
  <name>Task 1: [BLOCKING] Verify vite-plugin-pwa package legitimacy before install</name>
  <read_first>
    - .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)
  </read_first>
  <action>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).</action>
  <what-built>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).</what-built>
  <how-to-verify>
    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).
  </how-to-verify>
  <acceptance_criteria>
    - `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.
  </acceptance_criteria>
  <resume-signal>Type "approved" to proceed with install, or describe a mismatch.</resume-signal>
</task>

<task type="auto">
  <name>Task 2: Extend Drizzle schema — calendarOutbox table + calendarEvents.objectUrl; install vite-plugin-pwa</name>
  <files>apps/api/src/db/schema.ts, apps/pwa/package.json</files>
  <read_first>
    - 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)
  </read_first>
  <action>
    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).

  </action>
  <verify>
    <automated>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</automated>
  </verify>
  <acceptance_criteria>
    - `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.
  </acceptance_criteria>
  <done>calendarOutbox table and calendarEvents.objectUrl exist in schema.ts; vite-plugin-pwa installed; types compile.</done>
</task>

<task type="auto">
  <name>Task 3: Populate calendarEvents.objectUrl in sync.ts</name>
  <files>apps/api/src/broker/sync.ts</files>
  <read_first>
    - 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)
  </read_first>
  <action>
    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.
  </action>
  <verify>
    <automated>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</automated>
  </verify>
  <acceptance_criteria>
    - `grep -c "objectUrl: obj.url" apps/api/src/broker/sync.ts` returns exactly 2 (values + onDuplicateKeyUpdate).
    - Existing `broker/sync` test suite stays green.
  </acceptance_criteria>
  <done>sync.ts stores obj.url into calendarEvents.objectUrl on every upsert; sync tests pass.</done>
</task>

<task type="auto" tdd="true">
  <name>Task 4: Create Wave 0 RED test scaffold for all Phase 3 behaviors</name>
  <files>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</files>
  <read_first>
    - .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)
  </read_first>
  <behavior>
    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.
  </behavior>
  <action>
    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.
  </action>
  <verify>
    <automated>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")</automated>
  </verify>
  <acceptance_criteria>
    - All five test files exist.
    - `pnpm --filter @familysync/api test -- broker/vevent` reports failures or unresolved imports (RED — implementation not present).
    - The events.test.ts scaffold includes a `writable-calendars` describe block (`grep -c "writable-calendars" apps/api/tests/routes/events.test.ts` ≥1).
    - The existing GET /api/events describe block is still present in events.test.ts (`grep -c "GET /api/events" apps/api/tests/routes/events.test.ts` ≥1).
  </acceptance_criteria>
  <done>Five RED test files exist and fail because their target modules are unimplemented; existing tests preserved.</done>
</task>

<task type="checkpoint:human-action" gate="blocking-human">
  <name>Task 5: [BLOCKING] Push schema to MariaDB (drizzle-kit push)</name>
  <read_first>
    - 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)
  </read_first>
  <action>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.</action>
  <what-built>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.</what-built>
  <how-to-verify>
    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';"`.
  </how-to-verify>
  <acceptance_criteria>
    - `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.
  </acceptance_criteria>
  <resume-signal>Type "pushed" once the table and column exist in MariaDB, or report a destructive-change warning.</resume-signal>
</task>

</tasks>

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

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

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

<output>
Create `.planning/phases/03-event-write-back-pwa-install/03-01-SUMMARY.md` when done.
</output>
