diff --git a/apps/api/src/routes/events.ts b/apps/api/src/routes/events.ts index bc8ae19..3a795cc 100644 --- a/apps/api/src/routes/events.ts +++ b/apps/api/src/routes/events.ts @@ -308,6 +308,15 @@ eventsRouter.patch('/:uid/edit', zValidator('json', eventFieldsSchema), async (c // Join calendarEvents → calendars so we can read calendars.url and calendars.userId // in the same query. Without the join, referencing calendars.* produces invalid SQL // (Drizzle throws at toSQL() time) → 503. Mirrors the GET / join idiom at line 153. + // + // CR-01: calendar_events is keyed (calendarId, uid), NOT uid alone (schema.ts:121). + // With a shared Fastmail account (D-16) the SAME uid is cached once per member's + // calendar, so a uid-only lookup returns 2+ rows and an arbitrary [0] (typically the + // OTHER member's row). Scope the lookup to the acting member's writable set + // (own calendars OR shared) so the etag/objectUrl/ownership we act on belong to the + // right calendar. Order so the current user's OWN row ranks before a shared/other row + // — when both a personal and a shared copy of the uid exist, the acting member's copy + // is authoritative for the write target. limit(1) makes the pick deterministic. const [eventRow] = await db .select({ uid: calendarEvents.uid, @@ -319,13 +328,22 @@ eventsRouter.patch('/:uid/edit', zValidator('json', eventFieldsSchema), async (c }) .from(calendarEvents) .innerJoin(calendars, eq(calendarEvents.calendarId, calendars.id)) - .where(eq(calendarEvents.uid, uid)) + .where( + and( + eq(calendarEvents.uid, uid), + or(eq(calendars.userId, currentUserId), eq(calendars.isShared, true)), + ), + ) + .orderBy(sql`(${calendars.userId} = ${currentUserId}) desc`) + .limit(1) if (!eventRow) { return c.json({ error: 'Event not found' }, 404) } - // Ownership check: must be the calendar owner or shared (T-03-06) + // Ownership check: must be the calendar owner or shared (T-03-06). + // The WHERE above already restricts to the writable set, so any returned row is + // either the user's own calendar or a shared one — re-verify defensively. if (eventRow.userId !== currentUserId) { // Check if the calendar is shared (shared calendars are writable by all household members) const [calRow] = await db @@ -408,6 +426,10 @@ eventsRouter.delete('/:uid', async (c) => { // Look up the event — join calendars so calendars.url / calendars.userId are accessible. // Same innerJoin idiom as the GET / handler (line 153). Without this join, Drizzle // throws at toSQL() time → 503. + // + // CR-01: scope to the acting member's writable set and pick deterministically — a + // shared Fastmail account (D-16) caches the same uid once per member's calendar, so a + // uid-only lookup would otherwise act on an arbitrary member's etag/objectUrl. const [eventRow] = await db .select({ uid: calendarEvents.uid, @@ -419,7 +441,14 @@ eventsRouter.delete('/:uid', async (c) => { }) .from(calendarEvents) .innerJoin(calendars, eq(calendarEvents.calendarId, calendars.id)) - .where(eq(calendarEvents.uid, uid)) + .where( + and( + eq(calendarEvents.uid, uid), + or(eq(calendars.userId, currentUserId), eq(calendars.isShared, true)), + ), + ) + .orderBy(sql`(${calendars.userId} = ${currentUserId}) desc`) + .limit(1) if (!eventRow) { return c.json({ error: 'Event not found' }, 404)