style(13-03): apply Prettier formatting across repo

Mechanical reformat — no logic changes. 398 files changed, 19125
insertions(+), 16457 deletions(-). Prettier 3.8.4 with .prettierrc
(singleQuote:true, semi:true, tabWidth:2, trailingComma:all,
printWidth:100). Isolated per D-13-08 for reviewability.
This commit is contained in:
Lucas Berger
2026-06-11 20:35:18 -04:00
parent 4bc0445173
commit 982438dc10
398 changed files with 19050 additions and 16382 deletions
+49 -26
View File
@@ -1,4 +1,5 @@
<!-- refreshed: 2026-06-09 -->
# Architecture
**Analysis Date:** 2026-06-09
@@ -50,53 +51,56 @@
└─ Fastmail CalDAV + app passwords ────────────────┘
(tsdav client, encrypted credentials)
(PROPFIND, REPORT, PUT, DELETE)
MariaDB (persistent cache)
(read on every request, written by broker)
```
## Component Responsibilities
| Component | Responsibility | File |
|-----------|----------------|------|
| **CalendarShell** | React entry point; wires TanStack Query + Schedule-X + Zustand + event modals | `apps/pwa/src/components/CalendarShell.tsx` |
| **AppNav** | Navigation header/sidebar; responsive (phone/tablet layout) | `apps/pwa/src/components/AppNav.tsx` |
| **EventDetailPopover** | Displays event details; driven by Zustand.openEventId | `apps/pwa/src/components/EventDetailPopover.tsx` |
| **EventForm** | Create/edit event modal; builds payloads for POST/PATCH endpoints | `apps/pwa/src/components/EventForm.tsx` |
| **SyncStateToast** | Toast polling `/api/events/sync-status?uid=` for optimistic-accept feedback | `apps/pwa/src/components/SyncStateToast.tsx` |
| **Zustand calendarStore** | UI-only state: selectedView, openEventId, eventFormOpen, calendarRange, deleteDialog state | `apps/pwa/src/store/calendarStore.ts` |
| **TanStack Query hooks** | Server state: fetchMe (user profile), fetchEvents (windowed occurrences), fetchSyncStatus | `apps/pwa/src/api/client.ts` + CalendarShell.tsx |
| **hydrateEvents** | Transforms raw CalendarOccurrence[] to Schedule-X CalendarType[] with Temporal dates | `apps/pwa/src/lib/hydrateEvents.ts` |
| **buildCalendarConfig** | Builds Schedule-X calendar configuration (views, plugins, columns) | `apps/pwa/src/lib/calendarConfig.ts` |
| **OIDC middleware** | Protects /api/* routes; 302-redirects unauthenticated requests to Authelia | `apps/api/src/auth/middleware.ts` |
| **devAuthBypass** | Dev-only passthrough auth for local development without Authelia | `apps/api/src/auth/devBypass.ts` |
| **upsertUser** | Identity upsert by (oidc_iss, oidc_sub); auto-assigns color from palette | `apps/api/src/auth/user.ts` |
| **eventsRouter** | GET /api/events (windowed reads), POST/PATCH/DELETE (enqueue outbox), GET /writable-calendars | `apps/api/src/routes/events.ts` |
| **meRouter** | GET /api/me — returns authenticated user profile (id, displayName, color) | `apps/api/src/routes/me.ts` |
| **sseRouter** | GET /api/sse/heartbeat — SSE smoke test for Pangolin proxy validation | `apps/api/src/routes/sse.ts` |
| **CalDAV Broker** | Decrypts credentials, manages tsdav clients, syncs calendars, drains outbox | `apps/api/src/broker/*.ts` |
| **Poller** | 5-min background job: PROPFIND → ctag comparison → skip or syncCalendar | `apps/api/src/broker/poller.ts` |
| **syncCalendar** | REPORT → ical.js parse → VEVENT upsert; prunes deleted events | `apps/api/src/broker/sync.ts` |
| **OutboxWorker** | 15-sec background job: drain pending outbox rows, build VEVENTs, PUT/DELETE to Fastmail | `apps/api/src/broker/outboxWorker.ts` |
| **expandOccurrences** | Server-side RRULE expansion via ical.js; produces concrete occurrences for UI | `apps/api/src/broker/expand.ts` |
| Component | Responsibility | File |
| ------------------------- | --------------------------------------------------------------------------------------------- | ------------------------------------------------ |
| **CalendarShell** | React entry point; wires TanStack Query + Schedule-X + Zustand + event modals | `apps/pwa/src/components/CalendarShell.tsx` |
| **AppNav** | Navigation header/sidebar; responsive (phone/tablet layout) | `apps/pwa/src/components/AppNav.tsx` |
| **EventDetailPopover** | Displays event details; driven by Zustand.openEventId | `apps/pwa/src/components/EventDetailPopover.tsx` |
| **EventForm** | Create/edit event modal; builds payloads for POST/PATCH endpoints | `apps/pwa/src/components/EventForm.tsx` |
| **SyncStateToast** | Toast polling `/api/events/sync-status?uid=` for optimistic-accept feedback | `apps/pwa/src/components/SyncStateToast.tsx` |
| **Zustand calendarStore** | UI-only state: selectedView, openEventId, eventFormOpen, calendarRange, deleteDialog state | `apps/pwa/src/store/calendarStore.ts` |
| **TanStack Query hooks** | Server state: fetchMe (user profile), fetchEvents (windowed occurrences), fetchSyncStatus | `apps/pwa/src/api/client.ts` + CalendarShell.tsx |
| **hydrateEvents** | Transforms raw CalendarOccurrence[] to Schedule-X CalendarType[] with Temporal dates | `apps/pwa/src/lib/hydrateEvents.ts` |
| **buildCalendarConfig** | Builds Schedule-X calendar configuration (views, plugins, columns) | `apps/pwa/src/lib/calendarConfig.ts` |
| **OIDC middleware** | Protects /api/\* routes; 302-redirects unauthenticated requests to Authelia | `apps/api/src/auth/middleware.ts` |
| **devAuthBypass** | Dev-only passthrough auth for local development without Authelia | `apps/api/src/auth/devBypass.ts` |
| **upsertUser** | Identity upsert by (oidc_iss, oidc_sub); auto-assigns color from palette | `apps/api/src/auth/user.ts` |
| **eventsRouter** | GET /api/events (windowed reads), POST/PATCH/DELETE (enqueue outbox), GET /writable-calendars | `apps/api/src/routes/events.ts` |
| **meRouter** | GET /api/me — returns authenticated user profile (id, displayName, color) | `apps/api/src/routes/me.ts` |
| **sseRouter** | GET /api/sse/heartbeat — SSE smoke test for Pangolin proxy validation | `apps/api/src/routes/sse.ts` |
| **CalDAV Broker** | Decrypts credentials, manages tsdav clients, syncs calendars, drains outbox | `apps/api/src/broker/*.ts` |
| **Poller** | 5-min background job: PROPFIND → ctag comparison → skip or syncCalendar | `apps/api/src/broker/poller.ts` |
| **syncCalendar** | REPORT → ical.js parse → VEVENT upsert; prunes deleted events | `apps/api/src/broker/sync.ts` |
| **OutboxWorker** | 15-sec background job: drain pending outbox rows, build VEVENTs, PUT/DELETE to Fastmail | `apps/api/src/broker/outboxWorker.ts` |
| **expandOccurrences** | Server-side RRULE expansion via ical.js; produces concrete occurrences for UI | `apps/api/src/broker/expand.ts` |
## Pattern Overview
**Overall:** Three-tier monolith (PWA frontend, Node.js backend, MariaDB), split between `apps/pwa` (React) and `apps/api` (Hono + broker).
**Overall:** Three-tier monolith (PWA frontend, Node.js backend, MariaDB), split between `apps/pwa` (React) and `apps/api` (Hono + broker).
**Request-response pattern:**
- Frontend reads from MariaDB cache via REST endpoints (GET only)
- Frontend enqueues writes to transactional outbox (POST/PATCH/DELETE return 202 immediately)
- Background broker drains outbox, calls Fastmail CalDAV, updates cache
- Real-time updates via SSE (Phase 4) and/or polling (SyncStateToast for write feedback)
**Data ownership pattern:**
- Poller owns calendar collection discovery + change detection (D-13 ctag polling)
- syncCalendar owns per-calendar event cache (REPORT → parse → upsert)
- OutboxWorker owns write-back to Fastmail (D-05 transactional outbox)
- Routes own read authorization and ownership checks (T-03-06..T-03-11)
**Key Characteristics:**
- Events endpoint shares MariaDB cache — no direct Fastmail I/O from routes (T-03-02 broker boundary)
- Write operations use optimistic-accept pattern: 202 + immediate UI response, success confirmed via polling
- All server state in TanStack Query; UI state only in Zustand (clear separation)
@@ -107,6 +111,7 @@
## Layers
**Presentation (React PWA):**
- Purpose: Display calendar, handle user interactions, manage UI state (view selection, modals, popovers)
- Location: `apps/pwa/src/`
- Contains: Components (CalendarShell, EventForm, EventDetailPopover, AppNav, SyncStateToast), UI hooks (CalendarShell's useQuery for data, Zustand for view state)
@@ -114,6 +119,7 @@
- Used by: Browser tab (Vite dev proxy or production Pangolin tunnel)
**API / Route Layer:**
- Purpose: Validate requests, enforce authorization (T-03-06..T-03-11), read from cache, enqueue writes
- Location: `apps/api/src/routes/`
- Contains: Route handlers (events.ts, me.ts, health.ts, sse.ts); Zod schemas for input validation
@@ -122,6 +128,7 @@
- Architecture invariant: Routes **never** import tsdav or call Fastmail directly (T-03-02)
**Database / ORM Layer:**
- Purpose: Type-safe query building, schema definition, migrations
- Location: `apps/api/src/db/`
- Contains: Drizzle schema (users, member_credentials, calendars, calendar_events, calendar_outbox), mysql2 client
@@ -129,6 +136,7 @@
- Used by: All route handlers, broker modules
**Broker / Background Worker Layer:**
- Purpose: Keep MariaDB calendar cache in sync with Fastmail; drain transactional outbox
- Location: `apps/api/src/broker/`
- Contains: Poller (5-min cron), syncCalendar (REPORT parse), OutboxWorker (15-sec drain), supporting utilities
@@ -137,6 +145,7 @@
- Data sources: member_credentials (encrypted), calendars, calendar_events (cache), calendar_outbox (pending writes)
**Auth / Session Layer:**
- Purpose: OIDC authentication via Authelia, user identity upsert, session cookies
- Location: `apps/api/src/auth/`
- Contains: Middleware (oidcAuthMiddleware, processOAuthCallback from @hono/oidc-auth), upsertUser color assignment, dev bypass
@@ -159,6 +168,7 @@
8. **Schedule-X render** — eventsService.set() updates calendar model; re-render with color routing (isShared ? 'shared' : String(ownerUserId))
**State Management:**
- TanStack Query caches result with key ['events', start, end]; staleTime 5 min
- Zustand calendarRange (start/end) drives query key → navigation re-fetches
- SyncStateToast polls `/api/events/sync-status?uid=` to show write-back progress
@@ -200,6 +210,7 @@
- Prune deletes: DELETE events whose uid is no longer on server (BUG B: scope by (userId, url) for shared account)
**Ownership Model (D-03, D-16):**
- Shared Fastmail account: both members' credentials fetch the same calendar collections
- Stored as (userId, url) composite unique key so each member caches the same calendar separately
- eventsRouter ownership check: calendar.userId = currentUserId OR isShared=true (writable set)
@@ -208,26 +219,31 @@
## Key Abstractions
**CalendarOccurrence:**
- Purpose: Single concrete event occurrence ready for UI (expanded from RRULE if needed)
- Examples: `apps/api/src/broker/expand.ts:CalendarOccurrence`, `apps/pwa/src/api/client.ts:CalendarOccurrence`
- Pattern: Backend expands RRULE into N occurrences; each has stable id = `${uid}::${dtstart_iso}`, allowing Schedule-X dedup and Zustand.openEventId routing
**Transactional Outbox (D-05):**
- Purpose: Decouple client request (202 response) from Fastmail write (async worker)
- Examples: `apps/api/src/db/schema.ts:calendarOutbox`
- Pattern: Write endpoint INSERTs pending row; worker POLLs and drains; status machine (pending → done/failed/dead) controls retry + backoff
**Wrapped Schema Contract (D-13):**
- Purpose: Guarantee correct DATE vs TIMESTAMP storage for all-day vs timed events
- Examples: `apps/api/src/db/schema.ts` (dtstartUtc, dtstartDate, allDay); `apps/api/src/broker/sync.ts` (storage logic); `apps/api/src/routes/events.ts` (window predicate)
- Pattern: All-day events NEVER coerce to midnight-UTC (Pitfall 2); timed events always UTC; query pre-filters both branches
**RRULE Expansion (D-09):**
- Purpose: Expand recurring masters server-side so client receives concrete occurrences only
- Examples: `apps/api/src/broker/expand.ts:expandOccurrences`, `apps/pwa/src/lib/hydrateEvents.ts` (no expansion on client)
- Pattern: Route calls expandOccurrences for each cached VEVENT; ical.js handles RRULE parsing, EXDATE exclusion, VTIMEZONE DST adjustment
**Encrypted Credentials:**
- Purpose: Store Fastmail app passwords at rest without exposing plaintext
- Examples: `apps/api/src/db/schema.ts:memberCredentials.encryptedPassword`, `apps/api/src/broker/crypto.ts:decryptPassword`
- Pattern: AES-256-GCM with per-message nonce; stored as JSON { iv, authTag, ciphertext }; decrypted only immediately before tsdav client creation (T-03-04)
@@ -235,36 +251,43 @@
## Entry Points
**Browser → PWA:**
- Location: `apps/pwa/src/main.tsx` (Vite SPA entry), `apps/pwa/src/App.tsx` (root component = CalendarShell)
- Triggers: User navigates to / (domain root) or clicks Home
- Responsibilities: Hydrate React app, mount CalendarShell, wire TanStack Query + Zustand
**PWA → API:**
- Location: `apps/pwa/src/api/client.ts` (fetch functions)
- Triggers: CalendarShell useQuery hooks on mount and navigation
- Responsibilities: Fetch events, me profile, sync status; handle OIDC redirects via maybeRedirectToLogin
**Unauthenticated User → OIDC:**
- Location: `apps/api/src/auth/middleware.ts` (oidcAuthMiddleware)
- Triggers: Unauthenticated fetch to /api/* endpoint
- Triggers: Unauthenticated fetch to /api/\* endpoint
- Responsibilities: 302-redirect to Authelia /authorize; await callback at /callback; set session JWT cookie
**OIDC Callback → API Login:**
- Location: `apps/api/src/index.ts:app.get('/callback')` and `apps/api/src/auth/middleware.ts:processOAuthCallback`
- Triggers: Authelia POST to /callback after authorization-code exchange
- Responsibilities: Exchange code for token, validate nonce, set JWT cookie with refresh token, redirect to /api/login
**API Login → SPA Boot:**
- Location: `apps/api/src/index.ts:app.get('/api/login')`
- Triggers: Top-level navigation after callback redirects here (or direct /api/login hit by PWA)
- Responsibilities: Verify session cookie valid, 302-redirect to / so SPA boots authenticated
**Background Poller:**
- Location: `apps/api/src/broker/poller.ts:startBrokerPoller`, called from `apps/api/src/index.ts` in isMainModule() guard
- Triggers: 5-min node-cron schedule starting at API boot
- Responsibilities: Load all credentials, PROPFIND calendars, compare ctag, call syncCalendar if changed
**Outbox Worker:**
- Location: `apps/api/src/broker/outboxWorker.ts:startOutboxWorker`, called from `apps/api/src/index.ts` in isMainModule() guard
- Triggers: 15-sec node-cron schedule starting at API boot
- Responsibilities: Poll outbox WHERE status='pending', drain to Fastmail via write.ts, update status, trigger refetch
@@ -319,4 +342,4 @@
---
*Architecture analysis: 2026-06-09*
_Architecture analysis: 2026-06-09_