Milestone v1.0: FamilySync MVP #1
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user