feat(01-04): wire broker + routes into bootstrap, add SSE endpoint, EventProof

- Mount /api/events, /api/sse in index.ts behind oidcAuthMiddleware; /callback + /health before guard
- Call startBrokerPoller() on boot (5-min ctag-poll background schedule)
- Add sseRouter with GET /heartbeat (streamSSE, 10s interval) for Pangolin SSE smoke test (D-08, T-04-01)
- Add CAL-08 spike script (broker/spike.ts): createFastmailClient → fetchCalendars → print calendar URLs
- Add fetchEvents() to pwa/api/client.ts with typed CalendarEvent/EventsResponse shapes
- Add EventProof.tsx: React Query ['events'], renders first event title+date or empty-state (CAL-01 broker proof)
- Update App.tsx to render MemberBadge + EventProof on landing page
- Add ical.js@2.2.1 to PWA dependencies for VEVENT summary parsing in EventProof
- All 24 API unit tests green; tsc --noEmit clean in both apps/api and apps/pwa
This commit is contained in:
Lucas Berger
2026-06-04 11:16:10 -04:00
parent d2d8333bf8
commit 48f90ceca9
8 changed files with 313 additions and 4 deletions
+6
View File
@@ -1,5 +1,6 @@
import { useQuery } from '@tanstack/react-query'
import { fetchMe, type MeUser } from './api/client'
import { EventProof } from './components/EventProof'
interface HealthResponse {
ok: boolean
@@ -85,6 +86,11 @@ export default function App() {
<MemberBadge user={meQuery.data.user} />
)}
{/* Broker proof: one cached Fastmail event (CAL-01) */}
<div style={{ marginBottom: '1rem' }}>
<EventProof />
</div>
{/* Stack health indicator (from Plan 01) */}
<div
style={{
+38
View File
@@ -32,3 +32,41 @@ export async function fetchMe(): Promise<MeResponse> {
return res.json() as Promise<MeResponse>
}
/**
* A single cached calendar event from the broker's MariaDB cache.
* Mirrors the calendarEvents table schema from apps/api/src/db/schema.ts.
*/
export interface CalendarEvent {
id: number
calendarId: number
uid: string
etag: string | null
rawVevent: string
dtstartUtc: string | null
dtstartDate: string | null
allDay: boolean
updatedAt: string | null
}
export interface EventsResponse {
events: CalendarEvent[]
}
/**
* Fetches all cached calendar events from the broker's MariaDB cache.
* No live Fastmail call — the 5-min poller keeps the cache fresh.
*
* Used by EventProof to display the first cached event as broker proof (CAL-01).
*/
export async function fetchEvents(): Promise<EventsResponse> {
const res = await fetch('/api/events', {
credentials: 'include',
})
if (!res.ok) {
throw new Error(`GET /api/events failed: ${res.status}`)
}
return res.json() as Promise<EventsResponse>
}
+142
View File
@@ -0,0 +1,142 @@
/**
* EventProof — renders one cached event from /api/events as broker proof.
*
* This is the Phase 1 landing page's broker-proof component: it shows a
* single cached event title and date, confirming the CalDAV broker has
* successfully fetched and cached at least one real Fastmail event (CAL-01).
*
* Data flow:
* React Query ['events'] → fetchEvents() → GET /api/events
* → renders first event's summary (parsed from rawVevent) and date
*
* Empty state: "No cached events yet" — shown when the poller hasn't run yet.
* This is expected on first boot before any Fastmail credentials are loaded.
*/
import { useQuery } from '@tanstack/react-query'
import { fetchEvents, type CalendarEvent } from '../api/client'
import ICAL from 'ical.js'
/**
* Extracts the event summary (title) from a raw VCALENDAR/VEVENT string.
* Falls back to 'Untitled event' if parsing fails or SUMMARY is absent.
*/
function extractSummary(rawVevent: string): string {
try {
const parsed = ICAL.parse(rawVevent)
const comp = new ICAL.Component(parsed)
const vevent = comp.getFirstSubcomponent('vevent')
if (!vevent) return 'Untitled event'
return (vevent.getFirstPropertyValue('summary') as string | null) ?? 'Untitled event'
} catch {
return 'Untitled event'
}
}
/**
* Formats the event date for display.
* Uses dtstartDate (for all-day) or dtstartUtc (for timed events).
*/
function formatDate(event: CalendarEvent): string {
if (event.allDay && event.dtstartDate) {
return event.dtstartDate
}
if (event.dtstartUtc) {
try {
return new Date(event.dtstartUtc).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
})
} catch {
return event.dtstartUtc
}
}
return 'Date unknown'
}
export function EventProof() {
const eventsQuery = useQuery({
queryKey: ['events'],
queryFn: fetchEvents,
retry: false,
staleTime: 5 * 60 * 1000, // 5 min — matches broker poll interval
})
if (eventsQuery.isLoading) {
return (
<div
style={{
padding: '1rem',
borderRadius: '8px',
background: '#f5f5f5',
color: '#666',
fontSize: '0.875rem',
}}
>
Loading calendar events...
</div>
)
}
if (eventsQuery.isError) {
return (
<div
style={{
padding: '0.75rem 1rem',
borderRadius: '8px',
background: '#fee2e2',
color: '#991b1b',
fontSize: '0.875rem',
}}
>
Could not load events
</div>
)
}
const events = eventsQuery.data?.events ?? []
if (events.length === 0) {
return (
<div
style={{
padding: '0.75rem 1rem',
borderRadius: '8px',
background: '#fef9c3',
color: '#713f12',
fontSize: '0.875rem',
}}
>
No cached events yet broker poller will run in the next 5 minutes.
</div>
)
}
const first = events[0]
const summary = extractSummary(first.rawVevent)
const date = formatDate(first)
return (
<div
style={{
padding: '0.75rem 1rem',
borderRadius: '8px',
background: '#f0fdf4',
border: '1px solid #bbf7d0',
fontSize: '0.875rem',
}}
>
<div style={{ fontWeight: 600, color: '#166534', marginBottom: '0.25rem' }}>
Broker proof 1 event cached
</div>
<div style={{ color: '#15803d' }}>
{summary}
</div>
<div style={{ color: '#4ade80', fontSize: '0.75rem', marginTop: '0.25rem' }}>
{date}
{events.length > 1 && ` (+${events.length - 1} more)`}
</div>
</div>
)
}