diff --git a/apps/api/src/routes/events.ts b/apps/api/src/routes/events.ts index e250ad0..6daaaef 100644 --- a/apps/api/src/routes/events.ts +++ b/apps/api/src/routes/events.ts @@ -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) + } }) diff --git a/apps/api/tests/routes/events.test.ts b/apps/api/tests/routes/events.test.ts index a062101..d26dd96 100644 --- a/apps/api/tests/routes/events.test.ts +++ b/apps/api/tests/routes/events.test.ts @@ -1,17 +1,22 @@ /** - * RED test stubs for GET /api/events — Wave 0 state. + * Tests for GET /api/events — windowed endpoint (Plan 02 GREEN state). * - * These tests encode the contract for the evolved windowed /api/events route. - * They reference the current events route which does not yet support windowed queries, - * color joins, or the isShared flag — all tests are expected to fail (RED) until Plan 02. - * - * Contracts locked here: + * Contracts: * 1. Missing/malformed start or end params → 400 (input validation guard) - * 2. Valid window returns occurrences each with a color field and isShared flag + * 2. Window wider than 90 days → 400 (DoS cap) + * 3. Valid window returns { occurrences: [] } each with color + isShared fields when DB is empty */ import { describe, it, expect, vi } from 'vitest' +// Mock @hono/oidc-auth so tests do not need a live Authelia instance. +// The mock makes oidcAuthMiddleware a no-op passthrough. +vi.mock('@hono/oidc-auth', () => ({ + oidcAuthMiddleware: () => async (_c: unknown, next: () => Promise) => next(), + processOAuthCallback: () => async (c: { json: (v: unknown) => unknown }) => c.json({ ok: true }), + getAuth: () => null, +})) + // Mock DB to avoid real DB connections in unit tests vi.mock('../../src/db/client.js', () => ({ db: {