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:
@@ -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_
|
||||
|
||||
Reference in New Issue
Block a user