fix(03): correct event-write timezone + per-user calendar identity (Gate 2 Part D)

BUG A — timed events written 4h off: EventForm sent a naive local wall-clock
string with no offset; the UTC API container parsed it via new Date() as UTC, so
09:00 America/Toronto serialized to DTSTART:...090000Z. Fix: new
apps/pwa/src/lib/eventDateTime.ts serializes timed events to an unambiguous UTC
instant in the browser (where the operator's zone is known); all-day stays a DATE
string. No backend change.

BUG B — created events attached to the wrong user's calendar + duplicate calendar
rows per poll: calendars had no unique key on url, and poller/sync matched
calendars by url alone — so under the shared single Fastmail account (D-16) one
member's collection resolved to the other member's row. Fix: composite
unique(user_id, url); scope poller lookup + sync select to (userId, url); hand
migration 0001 (dedup + add key), applied to the live DB.

Regression tests fail against the buggy url-only predicate. API 98/98, PWA 140/140,
tsc clean both packages.
This commit is contained in:
Lucas Berger
2026-06-06 22:32:10 -04:00
parent 505f64ed93
commit a9d3de658e
9 changed files with 281 additions and 9 deletions
+7 -3
View File
@@ -16,7 +16,7 @@
*/
import { schedule } from 'node-cron'
import { eq } from 'drizzle-orm'
import { and, eq } from 'drizzle-orm'
import { db } from '../db/client.js'
import { memberCredentials, calendars } from '../db/schema.js'
import { decryptPassword } from './crypto.js'
@@ -44,11 +44,15 @@ export async function runPoll(): Promise<void> {
const davCalendars = await client.fetchCalendars()
for (const davCal of davCalendars) {
// Look up the stored calendar row to get the known ctag (D-13)
// Look up the stored calendar row to get the known ctag (D-13).
// BUG B: scope by (userId, url). The two members share one Fastmail account
// (D-16), so the same collection URL exists for both. A url-only predicate
// matched the OTHER member's row → wrong ctag compared, and syncCalendar
// wrote events under the wrong calendar. Match on this member's row only.
const [stored] = await db
.select()
.from(calendars)
.where(eq(calendars.url, davCal.url))
.where(and(eq(calendars.userId, cred.userId), eq(calendars.url, davCal.url)))
.limit(1)
// ctag/syncToken: defensive null handling (Pitfall #6)