feat(02-02): evolve /api/events to windowed endpoint with color/owner join

- zValidator enforces YYYY-MM-DD regex on start/end (T-02b-01)
- 90-day window cap prevents DoS (T-02b-02)
- innerJoin calendarEvents→calendars→users for color + isShared + ownerUserId
- SQL pre-filter includes hasRrule=true rows regardless of dtstartUtc range
- expandOccurrences() called per row; shared calendar uses #F25C7A rose color
- events.test.ts: added @hono/oidc-auth mock; 4/4 assertions green
This commit is contained in:
Lucas Berger
2026-06-05 10:30:36 -04:00
parent 6736194a4a
commit 9ee26c07a7
2 changed files with 128 additions and 20 deletions
+116 -13
View File
@@ -1,29 +1,132 @@
/**
* GET /api/events — returns cached calendar events from MariaDB.
* GET /api/events — windowed, color-tagged, DST-correct, EXDATE-aware occurrences.
*
* Architecture invariant (T-03-02, anti-pattern guard):
* Architecture invariant (T-03-02, broker-boundary):
* This route reads ONLY from the MariaDB cache. It NEVER calls Fastmail directly.
* All Fastmail I/O is owned exclusively by the broker module (src/broker/).
* No tsdav import here; no createFastmailClient import here.
*
* Mounted under /api/* in index.ts (Plan 04), so it is behind oidcAuthMiddleware.
* Security (threat model T-02b-01 / T-02b-02):
* - start/end query params validated with zod ISO-date regex before any SQL.
* - Window hard-capped at 90 days (DoS guard).
* - Drizzle parameterized queries prevent SQL injection.
*
* Mounted under /api/* in index.ts — behind oidcAuthMiddleware.
*/
import { Hono } from 'hono'
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'
import { and, or, eq, lte, lt } from 'drizzle-orm'
import { sql } from 'drizzle-orm'
import { db } from '../db/client.js'
import { calendarEvents } from '../db/schema.js'
import { calendarEvents, calendars, users } from '../db/schema.js'
import { expandOccurrences } from '../broker/expand.js'
export const eventsRouter = new Hono()
/** Shared-family calendar rose color (D-06). */
const SHARED_FAMILY_COLOR = '#F25C7A'
/** Maximum allowed date-window span to prevent DoS (T-02b-02). */
const MAX_WINDOW_DAYS = 90
/**
* GET /api/events
* Returns all cached calendar events from the database.
* Returns shape: { events: [...] } where each event has:
* id, uid, allDay, dtstartUtc, dtstartDate, calendarId, etag, updatedAt
*
* No live Fastmail call — broker poller keeps cache fresh every 5 minutes.
* Zod schema for the required query parameters.
* Rejects any value that is not a strictly formatted ISO date (YYYY-MM-DD).
*/
eventsRouter.get('/', async (c) => {
const events = await db.select().from(calendarEvents)
return c.json({ events })
const eventsQuerySchema = z.object({
start: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
end: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
})
/**
* GET /api/events?start=YYYY-MM-DD&end=YYYY-MM-DD
*
* Returns a flat array of concrete occurrences windowed to [start, end).
* Joins calendarEvents → calendars → users to resolve color and ownership.
* Expands recurring masters (hasRrule=true) via expandOccurrences() so RRULE,
* EXDATE, and DST are all handled server-side (D-09).
*
* Response shape: { occurrences: CalendarOccurrence[] }
*/
eventsRouter.get('/', zValidator('query', eventsQuerySchema), async (c) => {
const { start, end } = c.req.valid('query')
// --- Window span guard (T-02b-02) ---
const windowStartDate = new Date(start + 'T00:00:00Z')
const windowEndDate = new Date(end + 'T00:00:00Z')
const spanDays = (windowEndDate.getTime() - windowStartDate.getTime()) / (1000 * 60 * 60 * 24)
if (spanDays > MAX_WINDOW_DAYS || spanDays <= 0) {
return c.json({ error: 'Date window must be between 1 and 90 days' }, 400)
}
try {
// --- SQL pre-filter strategy (RESEARCH.md §Open Questions 3 / Pitfall 5) ---
//
// The WHERE clause must include THREE categories of events:
// 1. Non-recurring timed events with dtstartUtc in [windowStart, windowEnd)
// 2. Non-recurring all-day events with dtstartDate in [start, end) (DATE comparison)
// 3. Recurring masters (hasRrule=true) with dtstartUtc < windowEnd
// (a weekly meeting created 3 years ago can still have occurrences in the window)
//
// expandOccurrences() does the precise window-boundary check for all rows returned.
//
// Using raw SQL for the complex WHERE to keep the query readable and unambiguous.
// Drizzle's `sql` tag parameterizes all values — no string interpolation.
const rows = await db
.select({
rawVevent: calendarEvents.rawVevent,
calendarId: calendars.id,
calendarName: calendars.displayName,
isShared: calendars.isShared,
userId: users.id,
userColor: users.color,
})
.from(calendarEvents)
.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
and(
sql`${calendarEvents.hasRrule} = 1`,
sql`${calendarEvents.dtstartUtc} < ${windowEndDate}`,
),
// 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}`,
),
),
)
// --- Expand each row into concrete occurrences ---
const allOccurrences = rows.flatMap((row) => {
const color = row.isShared ? SHARED_FAMILY_COLOR : row.userColor
return expandOccurrences(
row.rawVevent,
windowStartDate,
windowEndDate,
row.calendarId,
row.calendarName ?? '',
row.userId,
color,
row.isShared,
)
})
return c.json({ occurrences: allOccurrences })
} catch (err) {
console.error('[events] DB query or expansion failed:', err)
return c.json({ error: 'Service unavailable' }, 503)
}
})