--- phase: 03-event-write-back-pwa-install reviewed: 2026-06-09T00:00:00Z depth: standard files_reviewed: 29 files_reviewed_list: - apps/api/src/broker/outboxWorker.ts - apps/api/src/broker/sync.ts - apps/api/src/broker/vevent.ts - apps/api/src/broker/write.ts - apps/api/src/db/schema.ts - apps/api/src/index.ts - apps/api/src/routes/events.ts - apps/api/tests/broker/outboxWorker.test.ts - apps/api/tests/broker/vevent.test.ts - apps/api/tests/broker/write.test.ts - apps/api/tests/routes/events.test.ts - apps/pwa/index.html - apps/pwa/package.json - apps/pwa/src/api/client.test.ts - apps/pwa/src/api/client.ts - apps/pwa/src/components/CalendarShell.tsx - apps/pwa/src/components/DeleteConfirmationDialog.test.tsx - apps/pwa/src/components/DeleteConfirmationDialog.tsx - apps/pwa/src/components/EventDetailPopover.test.tsx - apps/pwa/src/components/EventDetailPopover.tsx - apps/pwa/src/components/EventForm.test.tsx - apps/pwa/src/components/EventForm.tsx - apps/pwa/src/components/InstallPrompt.test.tsx - apps/pwa/src/components/InstallPrompt.tsx - apps/pwa/src/components/SyncStateToast.test.tsx - apps/pwa/src/components/SyncStateToast.tsx - apps/pwa/src/store/calendarStore.ts - apps/pwa/vite.config.ts - apps/pwa/vitest.config.ts findings: critical: 3 warning: 6 info: 5 total: 14 status: issues_found --- # Phase 3: Code Review Report **Reviewed:** 2026-06-09 **Depth:** standard **Files Reviewed:** 29 **Status:** issues_found ## Summary The phase-3 write-back path (events router → outbox → outboxWorker → CalDAV write wrappers) and the PWA write/install UI are generally well-structured, with thorough comments documenting prior fixes (BUG A/B, CR-xx, WR-xx). However the adversarial pass surfaced a recurring class of defect the comments missed: **`calendar_events` is keyed `(calendarId, uid)`, not `uid` alone, yet several lookups query by `uid` only.** Because both household members share one Fastmail account (D-16) and each member gets their own `calendars`/`calendar_events` rows for the same collection URL, a single UID exists in MULTIPLE rows. Three query sites take an arbitrary `[0]` row from that set, producing wrong-member ownership checks, wrong etag selection, and cross-member writes. This is the same `(userId, url)` scoping bug class that schema.ts comment "BUG B" already documents for `calendars` — it was not propagated to the event-row lookups. Additional findings: an all-day end-date inclusivity inconsistency that compounds on re-edit, a recurrence silently reset to `none` on every edit (data loss), a non-deterministic default-calendar pick, and worker cron schedules that fire on bare module import. ## Narrative Findings (AI reviewer) ## Critical Issues ### CR-01: Event edit/delete ownership check resolves an arbitrary member's row (uid-only lookup) **File:** `apps/api/src/routes/events.ts:311-322` (edit) and `:411-422` (delete) **Issue:** Both handlers look up the event with `.where(eq(calendarEvents.uid, uid))` and destructure `const [eventRow]`. The unique key is `(calendarId, uid)` (`schema.ts:121`), and with a shared Fastmail account (D-16) the SAME uid is cached once per member's calendar — so this query returns 2+ rows and `[0]` is whichever the DB returns first (lowest id = typically the OTHER member). Consequences: - The ownership check `eventRow.userId !== currentUserId` can compare against the wrong member's calendar row, then fall through to the `isShared` branch and either wrongly 403 a legitimate owner or wrongly authorize against a different calendar. - The enqueued outbox row carries `eventRow.calendarUrl / objectUrl / etag` from the arbitrary row, so the write can target the wrong member's object URL / etag. The `GET /` handler correctly scopes by `or(eq(calendars.userId, currentUserId), eq(calendars.isShared, true))`; the write lookups do not. This is the exact bug class schema.ts "BUG B" warns about, un-propagated to the event lookups. **Fix:** Scope the lookup to the current user's writable set and disambiguate deterministically: ```ts const [eventRow] = await db .select({ /* …same cols… */ }) .from(calendarEvents) .innerJoin(calendars, eq(calendarEvents.calendarId, calendars.id)) .where(and( eq(calendarEvents.uid, uid), or(eq(calendars.userId, currentUserId), eq(calendars.isShared, true)), )) .limit(1) ``` Prefer the current user's own row over a shared/other row if both match (e.g. order so `calendars.userId = currentUserId` ranks first), so the etag/objectUrl chosen belongs to the acting member. ### CR-02: Outbox WR-02 "freshest etag" re-read also queries uid-only — can pick the wrong member's etag **File:** `apps/api/src/broker/outboxWorker.ts:205-211` **Issue:** Before a PUT, the worker re-reads the freshest etag with `db.select({ etag }).from(calendarEvents).where(eq(calendarEvents.uid, row.uid))` and takes `freshEtagRows[0].etag`. Same uid-collision problem as CR-01: for a shared-account uid this returns multiple rows and `[0]` may be the OTHER member's etag. Using a foreign etag in `If-Match` will either spuriously 412 (false conflict → the edit is marked `failed` with no retry, D-08, user sees the conflict toast and the edit is dropped) or, worse, match by coincidence and overwrite. The intended WR-02 behavior (avoid stale-etag 412 on rapid edits) is undermined. **Fix:** Scope the re-read to the row's own calendar. The outbox row knows `calendarUrl` and `userId`; join through `calendars`: ```ts const freshEtagRows = await db .select({ etag: calendarEvents.etag }) .from(calendarEvents) .innerJoin(calendars, eq(calendarEvents.calendarId, calendars.id)) .where(and( eq(calendarEvents.uid, row.uid), eq(calendars.userId, row.userId), eq(calendars.url, row.calendarUrl), )) .limit(1) ``` ### CR-03: All-day end date is exclusive on write but inclusive on edit pre-fill — span grows one day per re-edit **File:** `apps/api/src/broker/vevent.ts:83-90` vs `apps/pwa/src/components/EventForm.tsx:178-187` / `apps/api/src/broker/expand.ts` **Issue:** `buildVeventString` advances the all-day DTEND by one calendar day to satisfy RFC-5545's exclusive-end rule (`vevent.ts:86-87`), treating the form's `end` as the inclusive last day. But on **edit**, the form pre-populates `endDate` from `occurrence.end` (`EventForm.tsx:181,187`), and `occurrence.end` for an all-day event coming back from sync/expand is the **exclusive** DTEND ('YYYY-MM-DD') that Fastmail stored. Round-tripping an edit therefore re-advances the already-exclusive end by another day on each save, silently growing multi-day all-day events by one day per edit. Even a no-op title edit corrupts the date span. **Fix:** Make the inclusive/exclusive contract explicit and symmetric. Either (a) keep `occurrence.end` exclusive everywhere and subtract one day before pre-filling the all-day end-date input in `EventForm`, or (b) expose an inclusive end on the occurrence and convert to exclusive only at the ICS boundary. Add a regression test that edits an all-day multi-day event twice and asserts the span is stable. ## Warnings ### WR-01: Recurrence is silently reset to `none` on every edit — data loss on recurring events **File:** `apps/pwa/src/components/EventForm.tsx:188-196` **Issue:** `occurrence.recurrence` is not part of the `CalendarOccurrence` contract, so the edit form casts to `any`, reads `undefined`, and defaults `recurrence` to `'none'` (comment acknowledges this). Saving an edit to a recurring event then enqueues `recurrence: 'none'`, and `outboxWorker` builds a VEVENT with no RRULE — converting a weekly series into a single event on Fastmail. Any edit to a recurring event (e.g. fixing a typo) destroys the recurrence. Flagged WARNING only because v1 may not yet expose editing recurring events through this surface — confirm; otherwise promote to BLOCKER. **Fix:** Either expose recurrence on the occurrence/expand contract and pre-fill it, or disable the recurrence `