From a596f520b45a9a745c635bcabe5e5afe63075e0f Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Tue, 9 Jun 2026 10:36:18 -0400 Subject: [PATCH] fix(03): CR-02 scope freshest-etag re-read to the writing member's calendar --- apps/api/src/broker/outboxWorker.ts | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/apps/api/src/broker/outboxWorker.ts b/apps/api/src/broker/outboxWorker.ts index 08acf63..1d9c96d 100644 --- a/apps/api/src/broker/outboxWorker.ts +++ b/apps/api/src/broker/outboxWorker.ts @@ -26,7 +26,7 @@ import { schedule } from 'node-cron' import { and, eq, lte } from 'drizzle-orm' import { db } from '../db/client.js' -import { calendarEvents, calendarOutbox, memberCredentials } from '../db/schema.js' +import { calendarEvents, calendarOutbox, calendars, memberCredentials } from '../db/schema.js' import { createFastmailClient } from './client.js' import { decryptPassword } from './crypto.js' import { syncCalendar } from './sync.js' @@ -201,11 +201,26 @@ async function dispatchRow(row: OutboxRow): Promise { // Using the freshest cached etag here prevents the spurious conflict toast // while still preserving genuine conflict detection (D-08): a real external // change updates calendarEvents.etag differently from any pending row's etag. + // CR-02: calendar_events is keyed (calendarId, uid), and a shared Fastmail + // account (D-16) caches the same uid once per member's calendar. A uid-only + // re-read returns multiple rows and an arbitrary [0] — potentially the OTHER + // member's etag, which would spuriously 412 (false conflict → edit dropped, + // D-08) or coincidentally match and overwrite. Scope the re-read to THIS row's + // own calendar by joining through calendars on the outbox row's userId + + // calendarUrl so the freshest etag belongs to the writing member. let etagForPut: string | null = row.etag ?? null const freshEtagRows = (await db .select({ etag: calendarEvents.etag }) .from(calendarEvents) - .where(eq(calendarEvents.uid, row.uid))) as Array<{ etag: string | null }> + .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)) as Array<{ etag: string | null }> if (freshEtagRows.length > 0 && freshEtagRows[0].etag != null) { etagForPut = freshEtagRows[0].etag }