docs(03): add code review fix report (--auto, 2 fix passes)

Auto-fix loop converged after 2 fix iterations + a final verifying re-review:
- Pass 1: 13/14 findings fixed (3 Critical, 6 Warning, 4 Info).
- Re-review surfaced 1 new Critical (move-path RRULE data loss) + 4 lower.
- Pass 2: 8/8 fixed, including the move-path RRULE forwarding.
- Final re-review: 0 Critical. Remaining 2 Warning / 2 Info are documented
  v1 scope cuts (recurrence-editing deferred), not defects.

Test suites green throughout: api 108, pwa 145; both tsc --noEmit clean.
Per-iteration REVIEW/REVIEW-FIX snapshots retained as audit trail.
This commit is contained in:
Lucas Berger
2026-06-09 11:12:09 -04:00
parent b8c186491b
commit 7c687ea413
6 changed files with 728 additions and 102 deletions
@@ -34,145 +34,73 @@ files_reviewed_list:
- apps/pwa/vite.config.ts
- apps/pwa/vitest.config.ts
findings:
critical: 3
warning: 6
info: 5
total: 14
critical: 0
warning: 2
info: 2
total: 4
status: issues_found
---
# Phase 3: Code Review Report
# Phase 3: Code Review Report (Re-Review, Iteration 3 — final --auto pass)
**Reviewed:** 2026-06-09
**Depth:** standard
**Files Reviewed:** 29
**Status:** issues_found
**Status:** issues_found (no blockers — remaining items are accepted v1 limitations)
## 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.
Final re-review of the event write-back + PWA-install phase after the iteration-2 fix pass. I traced each iteration-2 fix end-to-end against its implementation and tests. All iteration-2 fixes are correct and introduce no regressions. The prior BLOCKER (CR-01: edit-as-move strips the RRULE) is now **resolved and correct**.
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.
### Iteration-2 fixes — verified
## Narrative Findings (AI reviewer)
- **Move-path RRULE forwarding (CR-01) — VERIFIED FIXED.** `events.ts` now selects `rawVevent` in the edit lookup (events.ts:338) and, in the move branch, extracts the source RRULE and stashes it as `_preservedRrule` on the create payload **only when the edit carried no explicit recurrence** (events.ts:388-395). The worker create branch reads it back: `hasExplicitRecurrence` is computed via `hasOwnProperty(fields,'recurrence')` (outboxWorker.ts:336), and `rruleString` resolves to `preservedRrule ?? rruleFromPayload` only when there is no explicit recurrence (outboxWorker.ts:341-353). The two sides agree: an EDIT omits `recurrence`, so `hasExplicitRecurrence=false` and the stashed RRULE is applied; an explicit `recurrence` (including `'none'`) still wins. `JSON.stringify` on the move payload drops the absent `recurrence` key, so `hasOwnProperty` is correctly `false` after the round-trip. Covered by events.test.ts:422-469 (route stashes RRULE) and outboxWorker.test.ts:311-358 (worker re-applies; explicit `'none'` still emits no RRULE). No regression to the same-calendar `update` preserve path (outboxWorker.ts:281-285).
## Critical Issues
- **Outbox payload re-validation (IN-03) — VERIFIED FIXED.** Both the `update` and `create` branches parse the stored JSON, then `outboxPayloadSchema.safeParse` it (outboxWorker.ts:231-235, 323-327). A schema-invalid row is hard-failed (no retry, no CalDAV dispatch). The schema mirrors `eventFieldsSchema` and uses `.passthrough()` so `_preservedRrule` survives validation (outboxWorker.ts:70-82). Covered by outboxWorker.test.ts:288-306 (missing title → hard-fail, never dispatched).
### CR-01: Event edit/delete ownership check resolves an arbitrary member's row (uid-only lookup)
- **Sync-status failed-row ranking (WR-04) — VERIFIED FIXED.** `sync-status` orders by a status-priority CASE (`failed`/`dead`=0, `pending`=1, else=2) then `createdAt DESC` (events.ts:549-552), so an earlier failed/dead row for a uid outranks a later `done` row. Covered by events.test.ts:556-589, which also asserts the CASE expression is present in the ORDER BY chunks.
**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.
- **Helper-text / all-day toggle clamp (WR-01/WR-02 UI) — VERIFIED FIXED.** The recurrence `<select>` is disabled in edit mode with explanatory helper text (EventForm.tsx:789-800), and `handleAllDayToggle` clamps `endDate` to `max(startDate,endDate)` on toggle-on and clears stale time errors (EventForm.tsx:296-305).
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.
- **All-day inclusive/exclusive DTEND symmetry (CR-03) — STILL CORRECT.** `vevent.ts:116-123` rolls the inclusive end forward one UTC day on write; `EventForm.tsx:86-96` rolls it back on pre-fill. Symmetric; covered by vevent.test.ts:140-160.
### 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.
The two findings below are **carried-forward, deliberately-accepted v1 limitations** (documented in code), not regressions; they are recorded for completeness. There are no blockers in this phase.
## Warnings
### WR-01: Recurrence is silently reset to `none` on every edit — data loss on recurring events
### WR-01: Missing cached etag still produces an unconditional PUT/DELETE (D-08 gap)
**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 `<select>` and omit `recurrence` from the update payload (so the worker preserves the existing RRULE) when editing a known-recurring event.
**File:** `apps/api/src/broker/write.ts:74-78, 103-107`; `apps/api/src/routes/events.ts:406, 432, 507`
### WR-02: Default-calendar selection on create is non-deterministic (no ORDER BY)
**Issue:** When `eventRow.etag` is null (event cached before an etag was captured, or Fastmail omitted it), the outbox row's `etag` is `undefined`, and `write.ts` maps null/`''` to "no If-Match header" — an **unconditional** PUT/DELETE. That defeats D-08 conflict detection for exactly the rows most likely to be stale: a concurrent external edit is silently overwritten with no 412. The iteration-1 fix added a `console.warn` so the path is observable (write.ts:75-77, 104-106), but the unconditional write itself is unchanged — observability is not prevention. Only triggers when the cached etag is missing, so Warning, not Blocker.
**File:** `apps/api/src/routes/events.ts:260-268`
**Issue:** When `calendarUrl` is omitted, the handler picks `const [calRow] = await db.select(...).where(eq(calendars.userId, currentUserId))` with no `orderBy` and no `limit(1)`. A member with multiple personal calendars gets an arbitrary "first" calendar that can change between requests. D-01 intends a stable default. The PWA mitigates by sending `calendarUrl` when `writableCalendars.length > 1`, but the result is undefined-ordered whenever this path is reached.
**Fix:** Add deterministic order and limit: `.orderBy(calendars.id).limit(1)`, or prefer a calendar flagged as default.
**Fix:** When no etag is available, fetch the current etag (REPORT/GET) before writing, or skip the write and force a targeted re-sync so the next attempt carries a real etag. At minimum, document that the no-etag path is an accepted unconditional-write window for v1.
### WR-03: `parseDateTime` all-day check uses the raw `iso`, not the cleaned string
### WR-02: Edit cannot change or remove an RRULE; "no recurrence field" is overloaded as "keep existing"
**File:** `apps/pwa/src/components/EventForm.tsx:88-93`
**Issue:** `clean` strips the `[IANA]` suffix, but the all-day regex test runs against the original `iso` and the early return returns `{ date: iso }` (raw). For a true all-day 'YYYY-MM-DD' this is fine, but a date-only value carrying a bracket suffix would skip the all-day branch and fall through to `new Date(clean)`. The variable used contradicts the "Strip IANA bracket suffix" intent documented one line above.
**Fix:** Test and return `clean`: `if (/^\d{4}-\d{2}-\d{2}$/.test(clean)) return { date: clean, time: '09:00' }`.
**File:** `apps/pwa/src/components/EventForm.tsx:361-371, 768-800`; `apps/api/src/broker/outboxWorker.ts:281-285, 336-353`
### WR-04: Worker cron schedules start on bare module import — pollutes the test process
**Issue:** The recurrence `<select>` is hard-disabled on edit and the payload always omits `recurrence` on edit (EventForm.tsx:367). The server treats an absent `recurrence` as "preserve the stored RRULE" (both the same-calendar update and the move path). The consequence is that a user can never (a) change a recurring event's frequency, nor (b) intentionally make a recurring event non-recurring — there is no way to express "remove the RRULE" through the edit form, because "omit recurrence" is reserved to mean "unchanged." Helper text now surfaces the constraint (EventForm.tsx:789-800), which is the iteration-2 mitigation, so this is a documented v1 scope cut rather than a silent trap. Recorded because the overloaded semantics will need disentangling when recurrence editing ships (a sentinel distinct from "omitted" will be required to express "remove").
**File:** `apps/api/src/index.ts:63-67`
**Issue:** `startBrokerPoller()` and `startOutboxWorker()` are called at top level, so importing `./index.js` (the route tests import `app` from here) registers real `node-cron` schedules. They will fire drains/polls during the test run, touch the mocked DB/CalDAV layers nondeterministically, and keep open handles that prevent clean process exit.
**Fix:** Move worker startup inside the direct-run guard (see WR-05) or gate it behind `if (process.env.NODE_ENV !== 'test')`.
### WR-05: `index.ts` direct-run guard is fragile and can mis-fire
**File:** `apps/api/src/index.ts:79`
**Issue:** `import.meta.url.endsWith(process.argv[1].replace(/^.*\//, ''))` compares the module URL tail to the basename of argv[1]. A symlinked entrypoint or a differently-located file with the same basename can make this either fail to start the server in production or start it during an unrelated import.
**Fix:** Use a robust check, e.g. `fileURLToPath(import.meta.url) === realpathSync(process.argv[1])`.
### WR-06: Edit-as-move create-412 dead-ends the move with no retry path
**File:** `apps/api/src/broker/outboxWorker.ts:401-411` + `:361-396`
**Issue:** For edit-as-move the create runs first; on 412 it is marked `failed`, the durable gate later marks the paired delete `failed` ("original preserved"). No data is lost (original event survives), but the PWA set `lastSyncedUid` to the NEW uid (`EventForm.tsx:373`), whose only outbox row is `failed` — so the toast shows a conflict and there is no path to retry the move; the move is silently abandoned.
**Fix:** Surface that the move did not apply (distinct from a same-calendar conflict) and guide the user to re-open and re-save.
**Fix:** When recurrence editing lands, introduce an explicit "remove recurrence" signal distinct from an omitted field (e.g. `recurrence: 'none'` already overrides — wire the edit form to send it when the user clears the schedule), and parse-and-modify the stored RECUR in place rather than replacing it with a bare preset (see IN-01).
## Info
### IN-01: `triggerTargetedResync` re-loads and re-decrypts the credential per row
### IN-01: `RRULE_PRESETS` round-trip is lossy for any parameterized RRULE
**File:** `apps/api/src/broker/outboxWorker.ts:108-111`
**Issue:** Each successful/conflicted row independently calls `loadClientForUser` (DB read + AES-GCM decrypt) inside the drain loop, widening the window the decrypted password is held in memory.
**Fix:** Optionally cache the client per userId within a single drain cycle.
**File:** `apps/api/src/broker/vevent.ts:49-54`; `apps/api/src/broker/outboxWorker.ts:245-248, 337-340`
### IN-02: Unknown-status responses retried for the full backoff window before giving up
**Issue:** `RRULE_PRESETS` maps only to bare `FREQ=DAILY|WEEKLY|MONTHLY|YEARLY`. `extractRruleString` correctly preserves the full stored RECUR (which may carry `BYDAY`/`INTERVAL`/`COUNT`/`UNTIL`), and both preserve paths keep that rich rule. But if a `recurrence` preset value is ever applied to a previously-rich rule, it collapses the rule to the bare preset — silently dropping qualifiers. This cannot happen in v1 (the picker offers only the four bare presets and is disabled on edit), so it is latent, not active. The limitation is now documented at the `RRULE_PRESETS` definition (vevent.ts:39-48).
**File:** `apps/api/src/broker/outboxWorker.ts:288-295`
**Issue:** Any unmapped non-ok status (e.g. 405, 409, 422) is classified `transient` and retried to MAX_ATTEMPTS then dead-lettered. Safe (no data loss) but slow to settle for a permanent 4xx.
**Fix:** Treat unmapped 4xx (except 408/429) as hard fail; keep transient only for 5xx/network/unknown.
**Fix:** When recurrence editing ships, parse the existing RECUR and modify it in place instead of replacing it with a preset.
### IN-03: `InstallPrompt` reads `localStorage` synchronously in `useState` initializer without try/catch
### IN-02: Move-path RRULE preservation depends silently on `rawVevent` being non-empty
**File:** `apps/pwa/src/components/InstallPrompt.tsx:282-284`
**Issue:** Unlike `calendarStore.ts`, this access is unguarded; in private-mode/SSR contexts where `localStorage` throws it crashes the component on mount. `dismiss()` (`:298`) is likewise unguarded.
**Fix:** Wrap in try/catch returning `false`, mirroring the store's pattern.
**File:** `apps/api/src/routes/events.ts:388-391`
### IN-04: `resolveUserId` typed as `any`
**Issue:** In the move branch, `preservedRrule = payload.recurrence === undefined ? extractRruleString(eventRow.rawVevent ?? '') : undefined`. If `eventRow.rawVevent` is ever null/empty (it is selected at events.ts:338 and `calendar_events.rawVevent` is `notNull` per schema.ts:106, so this is not currently reachable), `extractRruleString('')` returns `undefined` and the move silently drops the RRULE with no diagnostic. The schema NOT NULL constraint makes this safe today; the fragility is that the preserve path has no observability if that invariant ever changes (unlike write.ts:75-77 which logs the analogous no-etag gap).
**File:** `apps/api/src/routes/events.ts:59`
**Issue:** The Hono context is `any` (eslint-disabled), losing type safety on `c.get('user')` and `getAuth`.
**Fix:** Type as `Context<{ Variables: { user?: { id: number } } }>`.
### IN-05: `deleteCalendarEvent` relies on tsdav ignoring `data: ''`
**File:** `apps/api/src/broker/write.ts:93`
**Issue:** Passes an empty `data` placeholder because tsdav requires the `DAVCalendarObject` shape. Relies on tsdav internals; a future version validating `data` would break this silently.
**Fix:** None required for v1; note on the tsdav upgrade checklist.
**Fix:** Optional — log a warning when a move with no explicit recurrence finds no extractable RRULE on a recurring-looking source, so a future schema/contract change that empties `rawVevent` is diagnosable rather than silent.
---