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)
+10 -3
View File
@@ -18,7 +18,7 @@
import type { DAVCalendar } from 'tsdav'
import type { FastmailClient } from './client.js'
import ICAL from 'ical.js'
import { eq } from 'drizzle-orm'
import { and, eq } from 'drizzle-orm'
import { db } from '../db/client.js'
import { calendars, calendarEvents } from '../db/schema.js'
@@ -56,10 +56,17 @@ export async function syncCalendar(
})
// 2. Select the calendar row to get its DB id (insertId is unreliable on ON DUPLICATE KEY UPDATE).
const [cal] = await db.select().from(calendars).where(eq(calendars.url, davCal.url)).limit(1)
// BUG B: scope by (userId, url) — the same collection URL exists for both members
// (shared Fastmail account, D-16). A url-only lookup returned the OTHER member's
// row (lowest id), so events were cached under the wrong calendarId.
const [cal] = await db
.select()
.from(calendars)
.where(and(eq(calendars.userId, userId), eq(calendars.url, davCal.url)))
.limit(1)
if (!cal) {
// Should never happen — we just upserted it
throw new Error(`syncCalendar: could not find calendar row for url=${davCal.url}`)
throw new Error(`syncCalendar: could not find calendar row for userId=${userId} url=${davCal.url}`)
}
// 3. Fetch all calendar objects (REPORT calendar-query).
@@ -0,0 +1,41 @@
-- BUG B — calendars: add composite unique key (user_id, url).
--
-- Context (D-16): the two household members share ONE Fastmail account, so the
-- SAME collection URL is polled by both credentials. The calendar upsert keyed on
-- url alone never triggered onDuplicateKeyUpdate (no unique key on url), so every
-- poll inserted a fresh calendar row; the url-only lookup then resolved to the
-- other member's row, caching events under the wrong calendarId.
--
-- This migration is hand-written (not drizzle-kit generated) because drizzle-kit
-- push is unsafe on populated MariaDB (false destructive diffs) and there is no
-- migrations baseline. Apply it directly to the live DB before/with the image
-- rebuild that ships the schema + broker fixes.
--
-- Order matters: duplicate (user_id, url) rows must be collapsed BEFORE the unique
-- key is added, or ADD UNIQUE fails. We keep the LOWEST id per (user_id, url),
-- repoint any cached events from the loser rows onto the keeper, then delete losers.
-- 1. Repoint calendar_events from duplicate calendar rows onto the keeper
-- (lowest id) for each (user_id, url) group.
UPDATE calendar_events ce
JOIN calendars dup ON dup.id = ce.calendar_id
JOIN (
SELECT user_id, url, MIN(id) AS keep_id
FROM calendars
GROUP BY user_id, url
) keeper ON keeper.user_id = dup.user_id AND keeper.url = dup.url
SET ce.calendar_id = keeper.keep_id
WHERE ce.calendar_id <> keeper.keep_id;
-- 2. Delete the duplicate (loser) calendar rows, keeping the lowest id per group.
DELETE c FROM calendars c
JOIN (
SELECT user_id, url, MIN(id) AS keep_id
FROM calendars
GROUP BY user_id, url
) keeper ON keeper.user_id = c.user_id AND keeper.url = c.url
WHERE c.id <> keeper.keep_id;
-- 3. Add the composite unique key that makes the upsert idempotent per (user_id, url).
ALTER TABLE calendars
ADD CONSTRAINT uniq_calendar_user_url UNIQUE (user_id, url);
+10 -1
View File
@@ -73,7 +73,16 @@ export const calendars = mysqlTable(
lastSyncedAt: timestamp('last_synced_at'),
isShared: boolean('is_shared').default(false).notNull(),
},
(t) => [index('idx_calendars_user_id').on(t.userId)],
(t) => [
index('idx_calendars_user_id').on(t.userId),
// BUG B: calendar identity is (userId, url), not url alone. The two household
// members share one Fastmail account (D-16), so the SAME collection URL is
// polled by both credentials. Without this unique key the calendar upsert's
// onDuplicateKeyUpdate never fired → a new row per poll, and the url-only
// lookup resolved to the other member's row → events cached under the wrong
// calendarId. Keying on (userId, url) makes the upsert idempotent per member.
unique('uniq_calendar_user_url').on(t.userId, t.url),
],
)
/**
+38
View File
@@ -194,6 +194,44 @@ describe('broker poller — runPoll', () => {
expect(mockSyncCalendar).not.toHaveBeenCalled()
})
it('BUG B: scopes the stored-calendar lookup to (userId, url), not url alone', async () => {
// Capture the predicate passed to db.select().from(calendars).where(...).
// The buggy code passed eq(url) only; the fix passes and(eq(userId), eq(url)).
// We serialize the predicate and assert it references the member's user_id column.
const capturedWhere: unknown[] = []
mockSelectWhere.mockImplementation((pred: unknown) => {
capturedWhere.push(pred)
return { limit: mockSelectLimit }
})
const { runPoll } = await import('../../src/broker/poller.js')
mockCredentialsSelectResult.push({
id: 7,
userId: 42,
fastmailEmail: 'lucas@fastmail.com',
encryptedPassword: 'enc',
})
mockFetchCalendars.mockResolvedValue([
{ url: 'https://caldav.fastmail.com/cal/', displayName: 'Calendar', ctag: 'c', syncToken: null },
])
await runPoll()
expect(capturedWhere.length).toBeGreaterThan(0)
// A composite and(...) predicate exposes multiple queryChunks; a single eq does not
// contain a nested SQL referencing the user_id column. Serialize and inspect.
const pred = capturedWhere[0] as { queryChunks?: unknown[] }
const serialized = JSON.stringify(pred, (_k, v) =>
typeof v === 'object' && v !== null && 'name' in (v as Record<string, unknown>)
? (v as { name?: unknown }).name
: v,
)
expect(serialized).toContain('user_id')
expect(serialized).toContain('url')
})
it('processes all member credentials in a poll cycle', async () => {
const { runPoll } = await import('../../src/broker/poller.js')
+46
View File
@@ -247,6 +247,52 @@ describe('syncCalendar', () => {
expect(eventUpdateArg.set).toHaveProperty('hasRrule', true)
})
it('BUG B: scopes the calendar-row select to (userId, url), not url alone', async () => {
// Capture the predicate passed to db.select().from(calendars).where(...).limit(1).
const capturedWhere: unknown[] = []
mockWhere.mockImplementation((pred: unknown) => {
capturedWhere.push(pred)
return { limit: mockLimit }
})
const { syncCalendar } = await import('../../src/broker/sync.js')
const mockClient = { fetchCalendarObjects: vi.fn().mockResolvedValue([]) }
const mockDavCal = {
url: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/',
displayName: 'Test',
ctag: 'v1',
syncToken: null,
}
await syncCalendar(mockClient as never, mockDavCal as never, 99)
expect(capturedWhere.length).toBeGreaterThan(0)
const serialized = JSON.stringify(capturedWhere[0], (_k, v) =>
typeof v === 'object' && v !== null && 'name' in (v as Record<string, unknown>)
? (v as { name?: unknown }).name
: v,
)
expect(serialized).toContain('user_id')
expect(serialized).toContain('url')
})
it('BUG B: calendar upsert is idempotent — onDuplicateKeyUpdate fires for the calendar row', async () => {
const { syncCalendar } = await import('../../src/broker/sync.js')
const mockClient = { fetchCalendarObjects: vi.fn().mockResolvedValue([]) }
const mockDavCal = {
url: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/',
displayName: 'Test',
ctag: 'v1',
syncToken: null,
}
await syncCalendar(mockClient as never, mockDavCal as never, 1)
// The calendar insert (call 0) must use onDuplicateKeyUpdate so the (userId,url)
// unique key makes re-polls update-in-place instead of inserting duplicate rows.
expect(mockOnDuplicateKeyUpdate).toHaveBeenCalled()
const calUpdateArg = mockOnDuplicateKeyUpdate.mock.calls[0][0]
expect(calUpdateArg.set).toHaveProperty('ctag')
})
it('updates the calendar ctag/syncToken after a successful sync', async () => {
const { syncCalendar } = await import('../../src/broker/sync.js')