diff --git a/apps/api/src/routes/events.ts b/apps/api/src/routes/events.ts index 1b7307a..8037922 100644 --- a/apps/api/src/routes/events.ts +++ b/apps/api/src/routes/events.ts @@ -126,6 +126,10 @@ const syncStatusQuerySchema = z.object({ // Response shape: { occurrences: CalendarOccurrence[] } // --------------------------------------------------------------------------- eventsRouter.get('/', zValidator('query', eventsQuerySchema), async (c) => { + // Resolve the current user first — only return events for owned + shared calendars (T-03-06). + const currentUserId = await resolveUserId(c) + if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401) + const { start, end } = c.req.valid('query') // --- Window span guard (T-02b-02) --- @@ -163,32 +167,39 @@ eventsRouter.get('/', zValidator('query', eventsQuerySchema), async (c) => { .innerJoin(calendars, eq(calendarEvents.calendarId, calendars.id)) .innerJoin(users, eq(calendars.userId, users.id)) .where( - or( - // Recurring masters: may have occurrences inside the window even if dtstartUtc is old. - // Two sub-cases: - // (a) Timed recurring masters: dtstartUtc < windowEnd - // (b) All-day recurring masters: dtstartUtc is NULL (DATE-only), use dtstartDate < end - // A NULL dtstartUtc causes the timed comparison to be NULL/false, so (b) carries it. - and( - sql`${calendarEvents.hasRrule} = 1`, - or( + and( + // Ownership predicate (BUG 3 fix): restrict to calendars owned by the current user + // OR shared-family calendars (isShared=true). Mirrors the /writable-calendars idiom + // (~line 509) so both endpoints agree on the authoritative writable set (D-03). + or(eq(calendars.userId, currentUserId), eq(calendars.isShared, true)), + // Date-window pre-filter (RESEARCH.md §Open Questions 3 / Pitfall 5): + or( + // Recurring masters: may have occurrences inside the window even if dtstartUtc is old. + // Two sub-cases: + // (a) Timed recurring masters: dtstartUtc < windowEnd + // (b) All-day recurring masters: dtstartUtc is NULL (DATE-only), use dtstartDate < end + // A NULL dtstartUtc causes the timed comparison to be NULL/false, so (b) carries it. + and( + sql`${calendarEvents.hasRrule} = 1`, + or( + sql`${calendarEvents.dtstartUtc} < ${windowEndDate}`, + sql`${calendarEvents.dtstartDate} < ${end}`, + ), + ), + // Non-recurring timed events: dtstartUtc falls in [windowStart, windowEnd) + and( + sql`${calendarEvents.hasRrule} = 0`, + sql`${calendarEvents.dtstartUtc} IS NOT NULL`, + sql`${calendarEvents.dtstartUtc} >= ${windowStartDate}`, sql`${calendarEvents.dtstartUtc} < ${windowEndDate}`, + ), + // All-day events: dtstartDate falls in [start, end) — DATE comparison, no time component + and( + sql`${calendarEvents.dtstartDate} IS NOT NULL`, + sql`${calendarEvents.dtstartDate} >= ${start}`, sql`${calendarEvents.dtstartDate} < ${end}`, ), ), - // Non-recurring timed events: dtstartUtc falls in [windowStart, windowEnd) - and( - sql`${calendarEvents.hasRrule} = 0`, - sql`${calendarEvents.dtstartUtc} IS NOT NULL`, - sql`${calendarEvents.dtstartUtc} >= ${windowStartDate}`, - sql`${calendarEvents.dtstartUtc} < ${windowEndDate}`, - ), - // All-day events: dtstartDate falls in [start, end) — DATE comparison, no time component - and( - sql`${calendarEvents.dtstartDate} IS NOT NULL`, - sql`${calendarEvents.dtstartDate} >= ${start}`, - sql`${calendarEvents.dtstartDate} < ${end}`, - ), ), )