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:
+116
-13
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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<void>) => 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: {
|
||||
|
||||
Reference in New Issue
Block a user