diff --git a/.planning/codebase/ARCHITECTURE.md b/.planning/codebase/ARCHITECTURE.md new file mode 100644 index 0000000..8ca2d16 --- /dev/null +++ b/.planning/codebase/ARCHITECTURE.md @@ -0,0 +1,322 @@ + +# Architecture + +**Analysis Date:** 2026-06-09 + +## System Overview + +```text +┌─────────────────────────────────────────────────────────────┐ +│ PWA Frontend (React 19) │ +│ CalendarShell + Schedule-X calendar + EventForm + UI state │ +│ TanStack Query (server state) + Zustand (UI-only state) │ +│ `apps/pwa/src/` │ +└────────┬──────────────────────────────────────────────────┬─┘ + │ │ + │ fetch (with credentials) │ SSE + │ (OIDC session cookie) │ + ▼ ▼ +┌──────────────────────────────────────────────────────────────┐ +│ Backend API (Hono + Node.js) — Port 3000 │ +│ ┌──────────────────────────────────────────────────────────┐ │ +│ │ Auth Layer (OIDC + Authelia) │ │ +│ │ `apps/api/src/auth/middleware.ts`, `devBypass.ts` │ │ +│ └──────────────────────────────────────────────────────────┘ │ +│ ┌──────────────────────────────────────────────────────────┐ │ +│ │ Route Handlers — Read from MariaDB cache only │ │ +│ │ GET /api/events — windowed occurrences via expand.ts │ │ +│ │ GET /api/me — current user profile + color │ │ +│ │ POST /api/events/create, PATCH /:uid/edit — enqueue │ │ +│ │ DELETE /:uid — enqueue delete to outbox │ │ +│ │ GET /api/sse/heartbeat — SSE smoke test │ │ +│ │ `apps/api/src/routes/` │ │ +│ └──────────────────────────────────────────────────────────┘ │ +│ ┌──────────────────────────────────────────────────────────┐ │ +│ │ DB Layer (Drizzle ORM + mysql2) │ │ +│ │ Schema: users, member_credentials, calendars, │ │ +│ │ calendar_events, calendar_outbox │ │ +│ │ `apps/api/src/db/` │ │ +│ └──────────────────────────────────────────────────────────┘ │ +│ ┌──────────────────────────────────────────────────────────┐ │ +│ │ Background Broker (CalDAV sync & write-back) │ │ +│ │ - Poller (5-min): PROPFIND → ctag change detect │ │ +│ │ - Sync (per-cal): REPORT → ical.js → MariaDB upsert │ │ +│ │ - OutboxWorker (15-sec): drain pending writes to │ │ +│ │ Fastmail (PUT/DELETE via tsdav) │ │ +│ │ `apps/api/src/broker/` │ │ +│ └──────────────────────────────────────────────────────────┘ │ +└────────┬──────────────────────────────────────────────────┬──┘ + │ │ + └─ 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` | + +## Pattern Overview + +**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) +- User identity keyed on (oidc_iss, oidc_sub) not email (D-10); color auto-assigned (D-06) +- All-day events stored as DATE, timed events as TIMESTAMP UTC (D-13 schema contract) +- Recurring events expanded server-side (D-09); client receives concrete occurrences only + +## 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) +- Depends on: Schedule-X (calendar library), @tanstack/react-query (server state), Zustand (UI state), TanStack utilities +- 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 +- Depends on: Hono framework, Drizzle ORM, @hono/zod-validator, auth middleware +- Used by: PWA frontend (fetch with OIDC cookie), load balancer redirects +- 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 +- Depends on: mysql2 driver, Drizzle ORM +- 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 +- Depends on: tsdav (CalDAV client), ical.js (VEVENT parsing), node-cron (scheduling), Drizzle ORM +- Used by: Scheduled background jobs (started in index.ts only when module is main) +- 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 +- Depends on: @hono/oidc-auth, Drizzle ORM for user upsert +- Used by: Hono middleware stack, route handlers via getAuth(c) or c.get('user') + +## Data Flow + +### Primary Request Path (GET /api/events) + +1. **Client request** — CalendarShell's eventsQuery fires when meQuery succeeds +2. **OIDC guard** (`apps/api/src/auth/middleware.ts:oidcAuthMiddleware`) — 302-redirect if unauthenticated; session cookie checked +3. **Route handler** (`apps/api/src/routes/events.ts:eventsRouter.get('/')`) — validate start/end dates, resolve userId via getAuth + upsertUser +4. **SQL pre-filter** — Select from calendar_events JOIN calendars JOIN users; WHERE matches: + - Ownership: current user's own calendars OR shared-family calendar (isShared=true) + - Date window: recurring masters (hasRrule=1) OR non-recurring timed (dtstartUtc in range) OR all-day (dtstartDate in range) +5. **Expansion** (`apps/api/src/broker/expand.ts:expandOccurrences`) — For each row, parse rawVevent with ical.js, expand RRULE into occurrences, emit CalendarOccurrence[] with stable IDs +6. **Response** — JSON { occurrences: CalendarOccurrence[] } +7. **Client hydration** (`apps/pwa/src/lib/hydrateEvents.ts`) — Convert occurrence ISO strings to Temporal.ZonedDateTime for Schedule-X +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 + +### Write Path (POST /api/events/create) + +1. **User interaction** — EventForm.onSubmit calls POST /api/events/create with CreateEventPayload +2. **OIDC guard** — Session verified +3. **Route validation** (`apps/api/src/routes/events.ts:eventsRouter.post('/create')`) — Zod validates payload (title, start, end, location, description, recurrence) +4. **Calendar ownership check** — If calendarUrl supplied, verify it's owned by currentUser OR isShared; else default to user's first calendar +5. **Outbox enqueue** — INSERT into calendar_outbox with status='pending', operation='create', uid=randomUUID +6. **202 response** — Return immediately with { uid } (optimistic-accept, D-05) +7. **UI toast** — Zustand setLastSyncedUid; SyncStateToast polls sync-status for this uid +8. **Background drain** — OutboxWorker (15-sec cron): + - SELECT outbox WHERE status='pending' AND next_attempt_at <= NOW() + - Decrypt credential from member_credentials + - Call `/broker/write.ts:createCalendarEvent` — builds VEVENT from payload, PUT to Fastmail + - On 2xx: mark done, trigger targeted sync (syncCalendar) to refetch the calendar + - On 412 conflict: mark failed (no retry), trigger sync (UI sees server state) + - On 5xx/408/429: exponential backoff, mark dead after 5 attempts + - On 400/401/403: mark failed immediately +9. **Cache update** — syncCalendar upserts calendar_events from REPORT; GET /api/events now includes the new event +10. **Client refetch** — SyncStateToast sees status='done'; TanStack Query invalidateQueries refetches events + +### Calendar Sync (Background Poller → syncCalendar) + +1. **Poller fires** — node-cron 5-min schedule calls runPoll() +2. **Load credentials** — SELECT member_credentials; decrypt each app password (T-03-04 — never log plaintext) +3. **Per-credential**: Create tsdav client, PROPFIND to discover calendars +4. **Per-calendar**: + - Look up known ctag from calendar_events join + - If ctag unchanged and not null: SKIP (no DB write, no Fastmail round-trip) + - If ctag changed or null: call syncCalendar +5. **syncCalendar** (`apps/api/src/broker/sync.ts`): + - Upsert calendars row with new ctag/syncToken + - REPORT (calendar-query) → tsdav.fetchCalendarObjects() → array of { data, etag, url } + - For each: Parse with ICAL.parse(), extract VEVENT, build dtstartUtc/dtstartDate per schema contract (D-13) + - Upsert calendar_events with onDuplicateKeyUpdate (idempotency key: calendarId + uid) + - 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) +- poller lookup: AND(userId, url) to fetch the right member's cached version + +## 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) + +## 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 +- 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 + +## Architectural Constraints + +- **Threading:** Single-threaded event loop (Node.js). Broker poller and outbox worker run in the same process; scheduled tasks do not block request handling. +- **Global state:** None in routes (all state passed via c context). Broker modules keep DB client as singleton. tsdav clients created per-credential per-poll (not cached). +- **Circular imports:** None detected. Routes import from routes only; broker imports from db + auth; auth imports from db; no cycles. +- **Request handling:** Synchronous route completion (routes do not wait for broker background tasks). Writes are optimistic-accept (202); client polls for confirmation. +- **Session cookies:** Signed JWT stored in httpOnly cookie; refresh token included in JWT payload; @hono/oidc-auth handles rotation every 15 min by default. +- **Shared Fastmail account:** Both members' credentials fetch the same calendar collections. Ownership tracked per-user via (userId, url) composite key to avoid cross-member cache contamination (BUG B fix). +- **Database transactions:** Explicit tx() used for edit-as-move (D-04) — delete + create pair atomic. All other operations single-statement (upserts via onDuplicateKeyUpdate). + +## Anti-Patterns + +### Direct Fastmail calls from routes + +**What happens:** Routes call tsdav or make fetch requests directly to Fastmail CalDAV endpoints +**Why it's wrong:** Routes would block on network I/O; Fastmail errors would fail the request immediately instead of retrying via outbox; credential decryption happens on every request instead of once per poller cycle; no centralized write ordering (concurrent POSTs can collide) +**Do this instead:** Routes enqueue outbox rows (202) and let broker handle Fastmail I/O. See `apps/api/src/routes/events.ts:eventsRouter.post('/create')` and `apps/api/src/routes/events.ts:eventsRouter.delete('/:uid')` — both INSERT outbox, never call tsdav. + +### Storing displayName as identity key + +**What happens:** User row lookup is by email or displayName instead of OIDC issuer+subject +**Why it's wrong:** Email changes (user migrates providers); displayName is user-editable and can collide (two Lucases). If Authelia email claim changes mid-login, the user gets a duplicate row. +**Do this instead:** Key by (oidc_iss, oidc_sub) composite, never email. See `apps/api/src/auth/user.ts:upsertUser` — identity lookup is always by (oidcIss, oidcSub), then displayName is updated as a display hint on re-upsert. + +### Caching tsdav clients across polls + +**What happens:** Broker reuses the same tsdav client instance for multiple credential sessions +**Why it's wrong:** DAVClient maintains HTTP connection state; reusing across credential changes can cross-contaminate requests or leak auth headers. +**Do this instead:** Create a fresh client per credential per poll. See `apps/api/src/broker/poller.ts:runPoll` — each credential iteration calls `createFastmailClient()` fresh. + +### Windowed event query without pre-filter for recurring masters + +**What happens:** SQL query only selects non-recurring events in the date window; recurring masters are not included +**Why it's wrong:** A weekly meeting created 3 years ago has dtstartUtc < window start, so it's filtered out. But it has RRULE so it has occurrences in the window (RESEARCH.md Pitfall 5). +**Do this instead:** OR-combine three sub-predicates: (1) non-recurring timed in window, (2) non-recurring all-day in window, (3) recurring masters with dtstartUtc < windowEnd. See `apps/api/src/routes/events.ts` lines 173–200 for the full predicate. + +### Storing all-day events as midnight-UTC datetime + +**What happens:** All-day event is stored as '2026-06-01T00:00:00Z' (datetime) instead of '2026-06-01' (date) +**Why it's wrong:** When the viewer is in a different timezone (e.g., UTC-04:00), the date column renders as 2026-05-31 (one day off). Timezone conversion applies to DATETIME but not DATE. +**Do this instead:** Store all-day events in the DATE column only; timed events in TIMESTAMP UTC. See `apps/api/src/db/schema.ts` (dtstartUtc vs dtstartDate) and `apps/api/src/broker/sync.ts` lines 100–112 for the schema contract enforcement. + +### Relying on 200 response to mean write success + +**What happens:** Route marks an event as written and notifies the client success before verifying the outbox row completed +**Why it's wrong:** Client UI state gets out of sync with server; if the outbox worker later fails, the client never knows. +**Do this instead:** Return 202 Accepted immediately, then client polls `/api/events/sync-status?uid=` to track the outbox status. See `apps/api/src/routes/events.ts:eventsRouter.post('/create')` returns 202, and `apps/pwa/src/components/SyncStateToast.tsx` polls until done/failed/dead. + +--- + +*Architecture analysis: 2026-06-09* diff --git a/.planning/codebase/CONCERNS.md b/.planning/codebase/CONCERNS.md new file mode 100644 index 0000000..4180c6d --- /dev/null +++ b/.planning/codebase/CONCERNS.md @@ -0,0 +1,258 @@ +# Codebase Concerns + +**Analysis Date:** 2026-06-09 + +## Tech Debt + +**Drizzle-kit push unsafe on MariaDB 11:** +- Issue: `drizzle-kit push` emits false destructive DDL on MariaDB 11 (mysql dialect) — misreads table metadata and schedules column truncation in the migration diff. This destroys production data if applied blindly. +- Files: `apps/api/src/db/schema.ts`, `apps/api/drizzle.config.ts`, `.planning/STATE.md` (D-Task5-DDL) +- Impact: Any schema change requires manual validation. Automated push pipelines are unsafe. +- Current mitigation: All additive DDL hand-applied. Database migrations live in `apps/api/src/db/migrations/` (SQL files). Documented in STATE.md. +- Fix approach: Adopt `drizzle-kit generate+migrate` workflow for all future schema changes — generate the diff, manually review the SQL, then apply via migration file. Never use `push` on MariaDB without field-by-field validation. If multi-replica deployment is needed, consider PostgreSQL migration at that point. + +**Dev-auth bypass lacks production guard redundancy:** +- Issue: The `DEV_AUTH_BYPASS` environment variable is guarded by a `NODE_ENV !== 'production'` check in `index.ts` (line 19), but relies on correct deployment configuration. If `NODE_ENV` is accidentally omitted from the production Docker Compose, the bypass could activate. +- Files: `apps/api/src/index.ts` (lines 19–26), `apps/api/src/auth/devBypass.ts` +- Impact: Unauthenticated access to the API in production if misconfigured. +- Current mitigation: The `docker-compose.yml` should explicitly set `NODE_ENV=production`; `.env.example` has `DEV_AUTH_BYPASS` commented out. Documented in `docs/deployment.md` (line 266–268). +- Fix approach: Add a startup assertion that logs an error and exits if `NODE_ENV !== 'production'` and `DEV_AUTH_BYPASS=true` are both detected. Consider a secondary check in the oidcAuthMiddleware instantiation. + +**Event datetime serialization was timezone-naive (FIXED in Phase 3):** +- Issue: The PWA's `EventForm` previously sent naive local wall-clock strings (no UTC offset) to the API; the outbox worker's `new Date(string)` parsed them in the container's UTC timezone, resulting in events written 4 hours early/late. Fixed in Phase 3 quick 260607-l6l. +- Files: `apps/pwa/src/lib/eventDateTime.ts` (new), `apps/pwa/src/components/EventForm.tsx` (updated) +- Impact: FIXED. Regression test added (`apps/pwa/src/lib/eventDateTime.test.ts`). +- Fix status: Closed via commit 2870413 (2026-06-07). Serialization now uses `localWallClockToUtcIso()` to convert to UTC `Z` instant in the browser before sending to the API. + +**Calendar row deduplication cross-user bug (FIXED in Phase 3):** +- Issue: The poller and sync used `url`-only predicates to lookup calendar rows, but the two household members share one Fastmail account — the same collection URL exists for both. This caused events to be cached under the wrong member's calendar and duplicate rows accumulated on every poll. Fixed in Phase 3 via commit 2870413 and migration `0001_calendars_user_url_unique.sql`. +- Files: `apps/api/src/broker/poller.ts` (line 52–56), `apps/api/src/broker/sync.ts` (line 62–66), `apps/api/src/db/schema.ts` (line 84), `apps/api/src/db/migrations/0001_calendars_user_url_unique.sql` +- Impact: FIXED. Unique constraint `uniq_calendar_user_url` enforces (userId, url) identity; all predicates scoped correctly. +- Fix status: Closed. Migration applied to live DB; regression tests added to `poller.test.ts` and `sync.test.ts`. + +--- + +## Known Bugs + +**GET /api/events missing userId/isShared filter (IDENTIFIED, RESOLVED via 260607-l6l):** +- Symptoms: GET /api/events returned events from all users (including stale spike data), not just owned + shared calendars. +- Files: `apps/api/src/routes/events.ts` (line 127–129 now filters correctly via resolveUserId) +- Trigger: Any `/api/events` call without the ownership/isShared predicate in the JOIN. +- Status: FIXED in commit 2870413. The route now filters: `WHERE currentUserId = userId OR isShared=1`. + +**Stale spike user + calendar data in production DB:** +- Symptoms: User id=1 ("Dev User", obsolete spike identity `oidc_iss='spike://cal-08'`) remains in the DB with 508 cached events under the now-deduplicated calendar row id=1. This is stale data, not a code bug. +- Files: Live MariaDB (data only, not source code) +- Impact: Low — new events written by the real users go to the correct rows (id=2, id=3 calendars). The spike data is not served to the app because the route filters by currentUserId. Safe to clean via a manual DB DELETE, but non-blocking. +- Fix approach: Post-deployment cleanup task: `DELETE FROM users WHERE oidc_iss='spike://cal-08'; DELETE FROM calendar_events WHERE calendar_id=1;` if confident no real events are under id=1. Safer: check `calendars.url` to confirm id=1 is the spike duplicate before deletion. + +--- + +## Security Considerations + +**Fastmail app password exposure risk:** +- Risk: The API loads and decrypts Fastmail app passwords from `member_credentials.encrypted_password`. If the encryption key is leaked or the decryption is implemented incorrectly, all calendar access is compromised. +- Files: `apps/api/src/broker/crypto.ts`, `apps/api/src/broker/poller.ts` (line 41), `deployment.md` (Step 2 — key generation) +- Current mitigation: AES-256-GCM encryption, key stored in `.env` (gitignored). Decrypted password never logged (T-03-04). Decryption happens only in `poller.ts` and `outboxWorker.ts`, not in HTTP routes. +- Recommendations: (1) Ensure `.env` is marked .gitignore in CI/CD (already done). (2) Rotate encryption key monthly + re-encrypt all passwords — design a rotation mechanism before multi-replica deployment. (3) Monitor access logs for repeated failed calendar syncs (sign of credential tampering). (4) Consider a secrets manager (e.g., Docker Compose secrets) for the encryption key in production. + +**OIDC claim extraction fragility (Authelia defaults):** +- Risk: Authelia v4.39+ omits `name`, `email`, `preferred_username` from the ID token by default — requires a `claims_policy` config. The app's `deriveDisplayName()` (auth/user.ts) falls back through `name` → `preferred_username` → `email` → `sub`, but if Authelia is not configured with claims, all users appear as "Member" in the legend (observed in Phase 2). This is a configuration issue, not a code bug, but fragile. +- Files: `apps/api/src/auth/user.ts` (lines 8–19), `docs/deployment.md` (Authelia client config, line 91–92 does NOT show claims_policy) +- Current mitigation: The identity is keyed on `iss+sub` (never email), so display name is cosmetic. The legend displays correctly after identity is established. +- Recommendations: (1) Add a `claims_policy` block to the example Authelia configuration in `docs/deployment.md` (or a separate `authelia-familysync-claims.yml` example). (2) Document that without claims, all users show as "Member" and that's non-blocking for v1 (they still get distinct colors via their `sub`). (3) Test Authelia claim extraction before Phase 5 push notifications are built (notification titles will need displayName). + +**SSE heartbeat endpoint carries no secrets but could be abuse vector:** +- Risk: `/api/sse/heartbeat` is authenticated (behind oidcAuthMiddleware) but emits only timestamps — no sensitive data. However, a malicious actor with a valid session could hold open many concurrent heartbeat streams, consuming server resources (DoS). +- Files: `apps/api/src/routes/sse.ts` +- Current mitigation: The endpoint is single-purpose (testing transport viability); Phase 4 will add real list-change SSE with per-user subscriptions. Resource limits are absent. +- Recommendations: (1) For Phase 4, implement per-user connection limits (max 3 concurrent SSE streams per user). (2) Add heartbeat-timeout tracking: if a client doesn't read for 120s, close the stream. (3) Monitor stream creation rate in logs (spike = potential abuse). + +--- + +## Performance Bottlenecks + +**Calendar windowed query without pagination (acceptable for v1, scales to ~5000 events):** +- Problem: GET `/api/events?start=X&end=Y` returns all occurrences in the window with no pagination. The query is efficient (indexes on `dtstart_utc`, `dtstart_date`, `hasRrule`), but response size grows with window span and recurrence expansion. +- Files: `apps/api/src/routes/events.ts` (line 126–170) +- Cause: No pagination implemented. For a 2-person household with ~500 events/person and heavy recurring series, a month-view response is ~2–5 KB (acceptable). +- Improvement path: (1) Monitor response time in Phase 4 (live sync will add per-user subscriptions). (2) If response >100 KB, add cursor-based pagination to the events endpoint. (3) Consider server-side caching of expansion results per (userId, window) for frequently-accessed ranges (e.g., current month). + +**Broker poller is full-scan every 5 minutes (acceptable for <10 members, mitigated by ctag):** +- Problem: `poller.ts` loops all member_credentials and calls `fetchCalendars()` on each, then compares ctag. For a 2-person household with 2 Fastmail accounts (shared calendars + personal), this is ~2–4 PROPFIND/REPORT calls per cycle. Scales poorly to >10 members. +- Files: `apps/api/src/broker/poller.ts` (line 35–77) +- Cause: No selective polling per calendar; all calendars checked every 5 minutes. +- Improvement path: (1) For v1 (2–4 members), current approach is fine — ~10 req/min to Fastmail. (2) For Phase 1.x (N-member expansion, per STATE.md note): track last-known ctag per calendar and skip polling if unchanged; implement WebDAV-Sync (sync-token) for delta-only fetches (RFC 6578). (3) Monitor Fastmail API rate-limit headers (`X-RateLimit-*`) in logs. + +**Outbox worker retries backoff reaches 30 min max (acceptable, prevents spam):** +- Problem: The outbox retry window for a failed write is capped at ~30 min (BACKOFF_SECONDS: 15+60+300+600+1800). A transient Fastmail outage lasting >30 min will abandon the write as "dead" without user notification. +- Files: `apps/api/src/broker/outboxWorker.ts` (line 46, MAX_ATTEMPTS=5) +- Cause: Exponential backoff with a fixed cap to prevent infinite queuing. +- Improvement path: (1) For v1, 30 min is acceptable (household is US-based, Fastmail SLA is high). (2) For Phase 4, add a `dead-letter-queue` processor that logs unsent writes and optionally re-queues them manually. (3) Consider extending MAX_ATTEMPTS to 7–8 for a longer retry window (2–3 hours) if outages are observed. + +--- + +## Fragile Areas + +**CalDAV event write-back lacks conflict resolution (D-08 mitigation exists, risk remains):** +- Files: `apps/api/src/broker/write.ts`, `apps/api/src/broker/outboxWorker.ts` (line 180–190), `docs/deployment.md` (Pitfall 14) +- Why fragile: When a user edits an event in the app and another user edits it concurrently in the native Fastmail app, the outbox worker receives a 412 (If-Match conflict). The current behavior is to mark the outbox row as "failed" and trigger a re-sync. This is correct but provides no UI feedback to the user — they don't know their edit was rejected. If this happens repeatedly, the user will see the calendar diverge unpredictably. +- Safe modification: (1) Add a `syncStatus` subscription in the PWA (already designed in Phase 3 Plan 03-06). The UI shows "sync conflict — your edit was rejected, event reloaded from server" in a toast. (2) If the outbox row is marked "failed", the next re-sync will pull the current server state. (3) For Phase 4+, consider implementing a "merge/overwrite" UI where the user can choose to force their edit if they're confident it's the right state. For v1, reject-and-reload is acceptable. + +**Recurring event expansion via rrule + EXDATE is CPU-sensitive (mitigated by window cap):** +- Files: `apps/api/src/broker/expand.ts`, `apps/api/src/routes/events.ts` (line 45, MAX_WINDOW_DAYS=90) +- Why fragile: Expanding a 5-year-old weekly recurring event to a 90-day window generates ~50 occurrences. Expanding to a 1-year window generates ~250. If a user requests a 365-day window (not capped), the expansion becomes CPU-bound. +- Safe modification: The MAX_WINDOW_DAYS=90 guard is in place (T-02b-02, DoS protection). No change needed. If Phase 6 adds a "year view", re-evaluate the expansion window and consider caching expanded results per (event.uid, window). + +**OIDC session middleware dependency on @hono/oidc-auth (tied to Authelia version):** +- Files: `apps/api/src/auth/middleware.ts`, package.json (@hono/oidc-auth: 1.8.3) +- Why fragile: @hono/oidc-auth v1.8.3 assumes a specific OIDC metadata contract. If Authelia makes a breaking change in its .well-known/openid-configuration response, the middleware could fail silently (e.g., missing `token_endpoint`, `userinfo_endpoint`). +- Safe modification: (1) Add a startup health check that fetches Authelia's OIDC metadata and logs an error if critical fields are missing. (2) Monitor Authelia release notes for OIDC spec changes. (3) Pin @hono/oidc-auth to 1.8.x in package.json (already done). (4) Test Authelia upgrades in a staging environment before deploying to production. + +--- + +## Scaling Limits + +**Single-process deployment concurrency guard in outbox worker:** +- Current capacity: The outbox worker's drain-concurrency guard (CR-05, line 87–100) uses a module-level boolean flag. This is safe for a single-process Docker container but breaks if scaled to multiple API replicas. +- Limit: If the API is deployed as N replicas behind a load balancer, the drain cycles can overlap and double-dispatch the same outbox row to Fastmail, causing duplicate writes. +- Scaling path: (1) For v1 (single Unraid container), no change needed. (2) For multi-replica or Kubernetes: replace the module-level guard with a durable DB row claim (`UPDATE calendar_outbox SET status='processing' WHERE id=? AND status='pending'`). The first replica to claim wins; others skip that row. (3) Add a "processing" timeout (5 min) to prevent dead-replica claims from blocking the queue indefinitely. + +**In-memory SSE fan-out via EventEmitter (Phase 4 dependency, acceptable for single process):** +- Current capacity: Phase 4 will add live list-change SSE that broadcasts to connected clients. If implemented as a simple Node EventEmitter, each replica process maintains its own in-memory subscriptions. A member on replica A updates a list; the SSE fires on replica A but replica B's connections don't see it (if the member's browser is routed to replica B after the update). +- Limit: Limited to single-process deployment or requires Redis Pub/Sub for fan-out across replicas. +- Scaling path: (1) For v1 (single container), EventEmitter is fine. (2) For Phase 4+, if multi-replica is needed: design the SSE layer to use Redis Pub/Sub for cross-process broadcasts. Add ioredis to package.json (it's already recommended in CLAUDE.md). See PITFALLS.md §Pitfall 15 for sequence-number replay strategy. + +**Redis not yet installed (Phase 4 dependency, scheduled for list sync):** +- Current status: The app has no Redis dependency. Phase 4 will require Redis for pub/sub (list-change broadcasts across processes/replicas). +- Impact: v1 is single-process; live sync works fine without Redis. Phase 4+ requires it. +- Remediation: Add Redis to docker-compose.yml in Phase 4. ioredis client already in package.json recommendations (CLAUDE.md, Table 1). Configure connection pooling (ioredis default: 8 connections). + +--- + +## Dependencies at Risk + +**@hono/oidc-auth peer dependency on Authelia RFC compliance:** +- Risk: @hono/oidc-auth relies on Authelia conforming to OIDC RFC 6749/6234. If Authelia introduces a non-standard endpoint or claim format, the middleware may fail. +- Impact: OIDC login would break; users cannot access the app. +- Migration plan: If Authelia breaks OIDC compatibility, replace @hono/oidc-auth with `openid-client` (a lower-level OIDC library). Estimated effort: 2–3 days to wire custom middleware. openid-client is already in CLAUDE.md as an escape hatch (Table 1, row 3). + +**tsdav maintained by single contributor (NateLinDev/tsdav):** +- Risk: The CalDAV client library `tsdav@2.2.2` has low maintenance activity. If a Fastmail CalDAV protocol change occurs or a critical bug is found, the library may not be updated promptly. +- Impact: Calendar sync could break (PROPFIND, REPORT, PUT all depend on tsdav). +- Migration plan: (1) For v1, tsdav is stable and proven in this codebase. (2) If maintenance becomes a blocker, the next option is to implement CalDAV PROPFIND/REPORT directly via fetch + xml2js (Pitfall 1 explicitly warns against this, but it's doable). Estimated effort: 1 week to implement a minimal CalDAV client. (3) Monitor tsdav GitHub issues and PRs. + +**ical.js reference implementation (kewisch/ical.js):** +- Risk: ical.js is the Mozilla-maintained RRULE/iCalendar reference implementation, but Mozilla does not actively develop calendar software. If a new RFC 5545 edge case is discovered (e.g., an RRULE rule that breaks ical.js), it may not be fixed quickly. +- Impact: Recurring events could expand incorrectly (rare, but affects display). +- Migration plan: (1) For v1, ical.js is the most reliable available. (2) If a bug is found, open an issue on GitHub; Mozilla is responsive to reference-implementation bugs. (3) Fallback: use `rrule` library only (lighter weight) if ical.js is abandoned, but rrule is less comprehensive for EXDATE/RECURRENCE-ID handling. + +--- + +## Missing Critical Features + +**Single-occurrence recurring event override (deferred to v1.x):** +- Problem: A user cannot edit or delete a single occurrence of a recurring event (e.g., "skip next Tuesday's meeting"). The edit-as-move write path (D-04) supports full-series edits only. +- Blocks: Users frustrated when they want to reschedule one instance. +- Deferred reason: Requires RECURRENCE-ID write-back (RFC 5545) and complex VCALENDAR patching. Estimated effort: 2–3 days of implementation + testing. For v1, edit-all is acceptable for a 2-person household. +- Resolution approach: Phase 6 or v1.x — implement a "Edit this and all following" option that re-dates the RRULE UNTIL and creates a new series from the edit date onward. + +**Notification subscription health-check (CRITICAL for Phase 5, deferred to Phase 5 implementation):** +- Problem: iOS silently revokes Web Push subscriptions after 3 silent push events (Pitfall 9). The app must detect this and re-subscribe automatically. +- Blocks: Phase 5 (push notifications) cannot be considered production-ready without this. +- Missing implementation: No subscription health-check exists in the PWA yet. The service worker needs to call `pushManager.getSubscription()` on every page open and compare the endpoint to the server's stored endpoint; if they differ, re-subscribe. +- Resolution approach: Phase 5 must include health-check implementation as a prerequisite, not a polish task. + +--- + +## Test Coverage Gaps + +**Events API route (GET /api/events, POST /create, PATCH /edit, DELETE /delete) has integration-level testing but lacks edge cases:** +- What's not tested: (1) Window boundary conditions (start=end, off-by-one day shifts). (2) Recurring all-day events with complex EXDATE. (3) Concurrent edit conflict (412 handling). (4) Ownership assertions with mixed owned + shared calendars. +- Files: `apps/api/tests/routes/events.test.ts` (126 lines, covers happy paths + 400/403 error cases) +- Risk: Edge cases in expansion or ownership filtering could silently pass tests and break in production. +- Priority: MEDIUM — add 10–15 test cases before Phase 4 (live sync will depend on ownership filtering being bulletproof). + +**Outbox worker state machine (retry backoff, edit-as-move ordering, dead-letter) has unit tests but lacks end-to-end CalDAV integration:** +- What's not tested: (1) Outbox row with a real Fastmail endpoint (mocked in tests). (2) 412 conflict response from Fastmail + re-sync flow. (3) Concurrent outbox rows from the same list (edit+delete pair ordering under network failures). (4) Recovery after a multi-hour Fastmail outage. +- Files: `apps/api/tests/broker/outboxWorker.test.ts` (state-machine tests only) +- Risk: Silent data loss if outbox row ordering is wrong under failures; list sync will depend on correct write ordering. +- Priority: HIGH — add integration tests before Phase 4. Mock Fastmail CalDAV responses (conflict, transient, success) and verify state transitions. + +**PWA EventForm timezone serialization (fixed in Phase 3, regression test exists but limited scope):** +- What's not tested: (1) Daylight Saving Time transitions (create event on March 12, spring-forward boundary). (2) Cross-timezone consistency (create event in Toronto, verify UTC serialization, reload in UTC, confirm display is Toronto wall-clock). (3) All-day event edge cases (midnight boundary serialization). +- Files: `apps/pwa/src/lib/eventDateTime.test.ts` (5 cases: timed → UTC, all-day → DATE, round-trip) +- Risk: Similar timezone bug could reappear if eventDateTime.ts is refactored without comprehensive DST testing. +- Priority: MEDIUM — add 5–10 DST/all-day edge cases to the test suite before Phase 6 (UX polish will touch date/time handling). + +**PWA service worker and offline behavior untested:** +- What's not tested: (1) Service worker install, activation, and update lifecycle. (2) Offline calendar view (reads from cache). (3) Offline list mutation (queues for sync). (4) Cache expiration strategy. +- Files: Service worker is auto-generated by vite-plugin-pwa; offline behavior is unimplemented in Phase 1–3. +- Risk: Phase 4's offline queue and Phase 5's background sync depend on correct SW lifecycle. Silent failures in SW updates could leave the wife on a stale version. +- Priority: MEDIUM — Phase 4 should include SW unit tests (simulate offline, verify cache reads, verify mutation queue behavior). + +**Mobile-specific behavior (iOS push, PWA standalone mode, permissions) untested by vitest:** +- What's not tested: (1) iOS 16.4+ push subscription (requires real device). (2) Standalone PWA launch (requires Add-to-Home-Screen). (3) Permission request flow (requires user gesture). (4) Camera/location permissions (out of scope for v1, but worth listing). +- Files: Not applicable (device-only testing). +- Risk: High impact if broken (wife can't install, can't receive notifications). Mitigated by human UAT (Phase 3 Gate 2 item 4). +- Priority: MEDIUM — document a manual iOS test checklist in Phase 5 (must run before ship). Playwright can test browser-side behavior; device-side requires manual verification. + +--- + +## Architectural Constraints & Anti-Patterns + +**Single-process assumption in outbox drain guard (CR-05, documented but constrains scaling):** +- Constraint: The module-level boolean flag `let isProcessing = false` in outboxWorker.ts assumes a single Node.js process. This is correct for the Unraid single-container deployment but breaks if scaled horizontally. +- Consequence: Multi-replica deployments MUST implement a durable DB claim (UPDATE … WHERE status='processing') before the API is horizontally scaled. +- Workaround: Documented in code comment (line 91–100). Clear and easy to address when scaling is needed. + +**No pagination on calendar events endpoint (acceptable for v1, design assumption):** +- Constraint: GET /api/events returns all occurrences in the window with no pagination. Designed for a 90-day max window and <1000 occurrences per window (acceptable for 2-person household). +- Consequence: Very large windows or households with hundreds of recurring events could generate multi-MB responses. +- Workaround: MAX_WINDOW_DAYS=90 guard prevents DoS. For Phase 4+, if response size exceeds 500 KB, add cursor pagination. + +**Dev-auth bypass is development-only but deployment-critical (configuration risk):** +- Constraint: The bypass is designed for local development (NODE_ENV !== 'production' + DEV_AUTH_BYPASS=true). If the bypass is accidentally enabled in production, the OIDC guard is completely bypassed. +- Consequence: Unauthenticated API access if misconfigured. +- Workaround: (1) .env.example has DEV_AUTH_BYPASS commented out. (2) docker-compose.yml MUST NOT include DEV_AUTH_BYPASS in env. (3) Documented in docs/deployment.md. Recommended: add a startup assertion to double-check. + +--- + +## Infrastructure & Deployment Concerns + +**Drizzle migrations require manual SQL review (no auto-apply in Docker):** +- Issue: The app does not auto-migrate on startup. The `drizzle-kit push` command is unsafe on MariaDB. Manual `drizzle-kit migrate` must be run once per DB version before the app starts. +- Files: `apps/api/src/db/migrations/`, `docs/deployment.md` (Step 3: `drizzle-kit push` is the documented command, but should be `migrate` or `generate+migrate` for production safety) +- Impact: If the operator forgets to migrate after pulling a new schema, the app will crash on startup (missing tables). The error message should be clear. +- Fix approach: (1) Update `docs/deployment.md` Step 3 to use `migrate` instead of `push`. (2) Add a startup health check in `src/db/client.ts` that verifies all expected tables exist; fail with a clear message if any are missing. (3) Document the migration process in a DEPLOYMENT.md subsection. + +**Pangolin SSE idle timeout dependency (D-14, issue #1034) verified but residual risk remains:** +- Issue: SSE streams can be cut by proxy idle-timeout. The Phase 4 entry gate smoke test PASSED (6 min without cut), but only tested on the test domain `familysync-dev.bergerhouse.net`. +- Files: `docs/deployment.md` (line 165–170), `.planning/phases/04-shared-lists-live-sync/04-CONTEXT.md` +- Impact: If the production Pangolin idle-timeout is lower than the test rig, SSE will be cut during live list sync. Users will experience brief disconnects (mitigated by reconnect logic in Phase 4). +- Current mitigation: Documented in deployment.md. The operator must set Pangolin's idle-timeout to ≥120s (recommended 300s) when deploying to production. +- Residual risk: If Pangolin is misconfigured and SSE is cut, the fallback (D-12 polling every 5s) will maintain sync but with degraded latency (5s vs real-time). Phase 4 must implement the polling fallback. + +--- + +## Known Limitations (Documented as Design Decisions) + +**Personal calendar sharing requires manual Fastmail setup (D-16 CAL-08 spike result):** +- Limitation: The two household members' personal Fastmail calendars are accessed via per-member app passwords (not a shared broker token). This requires each member to generate an app password and register it in the app. +- Impact: Acceptable. The unified view works correctly and scales to shared + personal calendars. +- Status: GO decision (CAL-08-DECISION.md, Phase 1). + +**Recurring event edit supports edit-all only (single-occurrence override deferred to v1.x):** +- Limitation: The write path does not support RECURRENCE-ID overrides. Editing a recurring event changes all future occurrences. +- Impact: Users cannot reschedule a single meeting. For a 2-person household, edit-all is acceptable. +- Status: Documented in STATE.md (deferred items), Phase 6 planning. + +**EU DMA compliance risk for EU-based households (Pitfall 11):** +- Limitation: iOS 17.4+ in EU countries removes standalone PWA mode and push support due to Digital Markets Act. FamilySync's push notifications would not work for an EU user. +- Impact: If the household moves to EU or uses EU Apple IDs, notifications are unavailable. +- Status: This is a Canadian household (me@lucasberger.ca, .ca domain, Unraid self-hosted). Documented as not applicable but worth flagging for future. +- Fix approach: Monitor for EU regulatory changes; if the household moves, switch to email or in-app notification fallback for v1.x. + +--- + +*Concerns audit: 2026-06-09* diff --git a/.planning/codebase/CONVENTIONS.md b/.planning/codebase/CONVENTIONS.md new file mode 100644 index 0000000..69a8220 --- /dev/null +++ b/.planning/codebase/CONVENTIONS.md @@ -0,0 +1,330 @@ +# Coding Conventions + +**Analysis Date:** 2026-06-09 + +## Naming Patterns + +**Files:** +- Backend route handlers: `camelCase.ts` — `events.ts`, `me.ts`, `health.ts` (`apps/api/src/routes/`) +- Broker modules: `camelCase.ts` — `poller.ts`, `sync.ts`, `write.ts`, `expand.ts` (`apps/api/src/broker/`) +- Frontend components: `PascalCase.tsx` — `EventForm.tsx`, `CalendarShell.tsx`, `InstallPrompt.tsx` (`apps/pwa/src/components/`) +- Frontend utilities: `camelCase.ts` — `colorUtils.ts`, `hydrateEvents.ts`, `eventDateTime.ts`, `loginRedirect.ts` (`apps/pwa/src/lib/`) +- Tests: `{filename}.test.ts` or `.test.tsx` co-located with source + +**Functions:** +- Private helpers (not exported): `camelCase` — `claimStr()`, `getBreakpointGroup()`, `viewStorageKey()`, `resolveUserId()` +- Exported async handlers: `camelCase` — `fetchMe()`, `createEvent()`, `expandOccurrences()`, `upsertUser()` +- React hooks (Zustand): `useCalendarStore`, `useXxxx` pattern — follows React convention +- Type guard / coercion functions: `camelCase` — `deriveDisplayName()`, `claimStr()` + +**Variables:** +- Constants (module-level): `SCREAMING_SNAKE_CASE` — `MAX_WINDOW_DAYS`, `SHARED_FAMILY_COLOR`, `COLOR_PALETTE`, `FIXTURES` +- Local state: `camelCase` — `currentUserId`, `targetCalendarUrl`, `windowStartDate`, `eventRow` +- Zustand store methods: `camelCase` setters — `setSelectedView()`, `setEventForm()`, `setLastSyncedUid()` +- Store state keys: `camelCase` — `selectedView`, `openEventId`, `eventFormOpen`, `deleteDialogUid` +- Destructured auth claims: `camelCase` — `iss`, `sub`, `email`, `displayName` +- Database column mappings: `snake_case` in schema → `camelCase` in TypeScript (Drizzle handles mapping) + +**Types/Interfaces:** +- TypeScript interfaces: `PascalCase` — `MeUser`, `MeResponse`, `CalendarOccurrence`, `WritableCalendar`, `CalendarStore`, `SyncStatus` +- Zod schemas: `camelCase` + `Schema` suffix — `eventsQuerySchema`, `eventFieldsSchema`, `syncStatusQuerySchema` +- Union types (enums): `PascalCase` or quoted literals in types — `'create' | 'update' | 'delete'`, `'pending' | 'done' | 'failed' | 'dead'` +- Database table names: `snake_case` — `calendar_events`, `calendar_outbox`, `member_credentials` +- DB column names: `snake_case` — `dtstart_utc`, `dtstart_date`, `oidc_iss`, `oidc_sub` + +**Drizzle ORM tables:** +- Table function: `mysqlTable('table_name', {...})` +- Column names in schema def: use snake_case strings — `int('user_id')`, `varchar('oidc_iss', ...)` +- TypeScript field names (destructured queries): auto-convert to camelCase via Drizzle's default mode +- Primary keys: `id: int().primaryKey().autoincrement()` (all tables follow this) +- Foreign keys: `references(() => targetTable.id, { onDelete: 'cascade' })` (explicit cascade behavior) +- Indexes: named with `idx_` prefix — `idx_calendar_events_dtstart_utc`, `idx_outbox_user_status` +- Unique constraints: named with `uniq_` prefix — `uniq_oidc_identity`, `uniq_calendar_uid`, `uniq_calendar_user_url` + +## Code Style + +**Formatting:** +- No explicit ESLint or Prettier config files in the codebase (uses project defaults) +- 2-space indentation (inferred from source code) +- Single quotes for strings (`'string'`, not `"string"`) +- Semicolons at end of statements +- No trailing commas in function calls; trailing commas in object/array literals (modern style) + +**Linting:** +- TypeScript: `strict: true` in both backend and frontend `tsconfig.json` +- Module resolution: `NodeNext` (backend), `Bundler` (frontend) +- No `any` types — use `Context` from Hono where typing is available + +**Example formatting (from `routes/events.ts` line 64):** +```typescript +async function resolveUserId(c: Context): Promise { + const devUser = c.get('user') as { id: number } | undefined + if (devUser) return devUser.id + + const auth = await getAuth(c) + if (!auth) return null + + const iss = (auth.iss as string | undefined) ?? '' + const sub = auth.sub ?? '' + // ... +} +``` + +## Import Organization + +**Order:** +1. Node.js built-ins (`import { ... } from 'node:...'`) +2. Third-party packages (`import { ... } from 'hono'`, `import { ... } from 'drizzle-orm'`) +3. Local absolute imports (backend: none; frontend: none visible — no path aliases configured) +4. Local relative imports (`import { ... } from '../dir/file.js'` or `../../...`) +5. Side-effect imports (import without destructuring, placed last) — `import '../auth/devBypass.js'` + +**Path extensions:** +- All imports use explicit `.js` extensions — `from './index.js'`, `from '../db/client.js'` +- Applies to both backend and frontend (ESM module resolution) + +**Example (from `routes/events.ts` lines 24–38):** +```typescript +import { randomUUID } from 'node:crypto' // Node.js built-in +import { Hono } from 'hono' // Third-party +import type { Context } from 'hono' +import { zValidator } from '@hono/zod-validator' // Third-party (Hono ecosystem) +import { z } from 'zod' +import { and, or, eq, desc } from 'drizzle-orm' +import { sql } from 'drizzle-orm' +import { db } from '../db/client.js' // Relative local import +import { calendarEvents, calendars, ... } from '../db/schema.js' +import { expandOccurrences } from '../broker/expand.js' +import { getAuth } from '../auth/middleware.js' +import { upsertUser, deriveDisplayName } from '../auth/user.js' +import '../auth/devBypass.js' // Side-effect import (last) +``` + +## Error Handling + +**Patterns:** + +**Backend (Hono routes):** +- Early return with typed `c.json(...)` on validation or auth failure — `return c.json({ error: 'message' }, statusCode)` +- Try-catch blocks wrap DB/external I/O, catch logs error + returns 503 Service Unavailable +- No unhandled rejections — every async operation has explicit error handling +- Auth failures: return 401 Unauthorized; authorization failures: return 403 Forbidden; missing resource: return 404 +- Validation failures: return 400 Bad Request with error envelope + +**Example (from `routes/events.ts` lines 126–225):** +```typescript +eventsRouter.get('/', zValidator('query', eventsQuerySchema), async (c) => { + const currentUserId = await resolveUserId(c) + if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401) + + const { start, end } = c.req.valid('query') + + 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 { + const rows = await db.select(...).from(...).where(...) + const allOccurrences = rows.flatMap((row) => expandOccurrences(...)) + return c.json({ occurrences: allOccurrences }) + } catch (err) { + console.error('[events] DB query or expansion failed:', err) + return c.json({ error: 'Service unavailable' }, 503) + } +}) +``` + +**Frontend (React + TanStack Query):** +- Fetch client throws on non-ok response; caller handles redirect logic (`maybeRedirectToLogin()`) +- API client checks `res.type === 'opaqueredirect'` and `res.status === 401` to detect auth failure (CORS-safe 302 handling) +- Component state via Zustand; server state via React Query +- No inline try-catch in components — defer to query error states + +**Example (from `api/client.ts` lines 28–53):** +```typescript +export async function fetchMe(): Promise { + const res = await fetch('/api/me', { + credentials: 'include', + redirect: 'manual', + }) + + if (res.type === 'opaqueredirect' || res.status === 401) { + throw new Error('GET /api/me: authentication required') + } + + if (!res.ok) { + throw new Error(`GET /api/me failed: ${res.status}`) + } + + return res.json() as Promise +} +``` + +## Logging + +**Framework:** Console methods only (`console.log`, `console.error`, `console.warn`) + +**Patterns:** +- Errors logged with context prefix in square brackets — `console.error('[events]', message)`, `console.error('[broker/sync]', message)` +- Startup messages logged at info level — `console.log('FamilySync API running on ...')` +- Dev-mode warnings prefixed with warning emoji-ish symbol — `console.warn('⚠ DEV_AUTH_BYPASS active ...')` +- No structured logging (JSON); plain text OK for small household app +- Errors include the full exception object for stack trace — `console.error('[events] DB query failed:', err)` + +**Example (from `index.ts` lines 23, 111):** +```typescript +if (devBypassActive) { + console.warn('⚠ DEV_AUTH_BYPASS active — OIDC guard DISABLED. Never use in production.') +} +// ... +serve({ fetch: app.fetch, port: 3000 }, (info) => { + console.log(`FamilySync API running on http://localhost:${info.port}`) +}) +``` + +## Comments + +**When to Comment:** +- Complex algorithms or non-obvious business logic — e.g., window date filtering in `routes/events.ts` (lines 142–151) +- Security assertions or threat-model references — e.g., ownership checks (T-03-06), CSRF-token patterns +- Architectural invariants — e.g., "broker boundary: this route reads ONLY from cache" (routes/events.ts:4) +- Non-standard patterns — e.g., `isMainModule()` check to gate cron startup (index.ts:81–99) +- Workarounds and why they exist — e.g., "WR-04: carrier/groupId for edit-as-move txn" (routes/events.ts:373) + +**JSDoc/TSDoc:** +- Used for public exported functions, not for every function +- Single-line for simple functions; multi-line with `@param` and `@returns` for complex signatures +- Comments on types (interfaces) to document contract — e.g., `CalendarOccurrence` interface (api/client.ts:71–87) + +**Example (from `auth/user.ts` lines 25–32):** +```typescript +/** + * Accessible, visually-distinct palette for per-member color assignment. + * A new member is given the first entry not already in use (see upsertUser). + * + * Ordering matters: the shared-family calendar is reserved rose (#F25C7A, D-06), + * so the warm near-rose hues (coral, amber) are placed LAST. Early members get + * cool colors (blue, green, teal) that read clearly distinct from the shared + * lane — otherwise a member's coral was mistaken for the shared rose. + * Values are Claude's choice per D-06. + */ +export const COLOR_PALETTE: string[] = [...] +``` + +## Function Design + +**Size:** Prefer short, single-responsibility functions. Route handlers are the exception — they bundle validation, ownership check, and response assembly (pragmatism for Hono idiom). + +**Parameters:** +- Use Hono's `Context` type rather than destructuring everything — `async (c: Context)` +- Explicit parameters for helper functions; Hono context passed implicitly where possible +- Zod validators return typed objects via `c.req.valid('json')` or `c.req.valid('query')` + +**Return Values:** +- Async functions return typed values or throw — `Promise` or `Promise` +- Error responses returned explicitly (not thrown) — callers handle 4xx/5xx in same try-catch +- Database queries return typed Drizzle result objects; destructure as needed + +**Example (from `auth/user.ts` lines 79–142):** +```typescript +export async function upsertUser( + oidcIss: string, + oidcSub: string, + displayName?: string | null, +) { + // 1. Look up by composite identity key... + const existing = await db.select().from(users).where(...).limit(1) + if (existing[0]) { + // Update displayName if changed + if (displayName != null && displayName !== existing[0].displayName) { + await db.update(users).set({ displayName }).where(...) + return { ...existing[0], displayName } + } + return existing[0] + } + + // 2. Assign color from palette... + // 3. Insert new row... + // 4. Re-select and return +} +``` + +## Module Design + +**Exports:** +- Named exports for functions and types — `export const TABLE`, `export function handler()`, `export interface Type` +- No default exports (exception: SPA app shell `App.tsx` uses default export) +- Re-export from middleware modules for convenience — `auth/middleware.ts` re-exports `@hono/oidc-auth` functions + +**Barrel Files:** +- No wildcard re-exports (`export * from ...`) — explicit named exports only +- Top-level index files not used (each module imported directly) + +**Example (from `auth/middleware.ts` lines 24–26):** +```typescript +export { oidcAuthMiddleware, processOAuthCallback, getAuth } from '@hono/oidc-auth' +``` + +## Database Patterns + +**Drizzle conventions (critical):** +- Schema definition: `mysqlTable('name', { id: int().primaryKey().autoincrement(), ... }, (t) => [...])` +- Foreign keys: ALWAYS include `{ onDelete: 'cascade' }` to propagate deletes cleanly +- Indexes: Explicit index names with `idx_` prefix on frequently filtered columns +- Unique constraints: Explicit unique names with `uniq_` prefix on identity/natural keys +- Never use `db:push` on populated MariaDB (false destructive diffs) — ALWAYS use `generate + migrate` + +**Query patterns:** +- Use Drizzle's type-safe query builder: `db.select(...).from(table).where(...).limit(...)` +- Raw SQL via `` sql`...` `` for complex predicates (e.g., multi-condition OR chains in events.ts:167–201) +- Parameterized values via `sql` template tag prevent SQL injection +- Joins: explicitly `innerJoin()` or `leftJoin()` with `.on(eq(...))` conditions + +**Example (from `db/schema.ts` lines 96–123):** +```typescript +export const calendarEvents = mysqlTable( + 'calendar_events', + { + id: int().primaryKey().autoincrement(), + calendarId: int('calendar_id') + .notNull() + .references(() => calendars.id, { onDelete: 'cascade' }), + uid: varchar('uid', { length: 512 }).notNull(), + // ... more columns + }, + (t) => [ + index('idx_calendar_events_dtstart_utc').on(t.dtstartUtc), + index('idx_calendar_events_has_rrule').on(t.hasRrule), + unique('uniq_calendar_uid').on(t.calendarId, t.uid), + ], +) +``` + +## Reactive State (Frontend) + +**TanStack Query (Server State):** +- All calendar events, lists, user profile live in React Query +- Queries keyed by API endpoint + windowing params — `['events', { start, end }]` +- Mutations handle POST/PATCH/DELETE; invalidate cache on success +- Use `useQuery` for reads, `useMutation` for writes; never mix server state into Zustand + +**Zustand (UI State):** +- Owns only UI-shape state: `selectedView`, `openEventId`, `eventFormOpen`, `deleteDialogOpen`, etc. +- Persists breakpoint-scoped `selectedView` to `localStorage` +- Never store server data (user profile, events) — keep it in React Query +- Setters are synchronous; no side effects (except localStorage in `setSelectedView`) + +**Example (from `store/calendarStore.ts` lines 1–25):** +```typescript +/** + * Zustand UI-state store for the calendar shell. + * + * Owns ONLY UI-shape state — no server data ever enters this store. + * Server state (events, user profile) lives in TanStack Query. + */ +``` + +--- + +*Convention analysis: 2026-06-09* diff --git a/.planning/codebase/INTEGRATIONS.md b/.planning/codebase/INTEGRATIONS.md new file mode 100644 index 0000000..75f6367 --- /dev/null +++ b/.planning/codebase/INTEGRATIONS.md @@ -0,0 +1,156 @@ +# External Integrations + +**Analysis Date:** 2026-06-09 + +## APIs & External Services + +**CalDAV (Fastmail):** +- Fastmail CalDAV endpoint - Calendar read/write for all household calendars + - SDK/Client: tsdav 2.2.2 (`apps/api/src/broker/client.ts`) + - Auth: Basic auth with Fastmail app password (per-member, stored encrypted in `member_credentials` table) + - Endpoint: `https://caldav.fastmail.com` + - Operations: PROPFIND (discover calendars), REPORT (fetch events), PUT (create/update), DELETE (remove events) + - Principal URL pattern: `https://caldav.fastmail.com/dav/principals/user/{email}/` + - Parse responses via ical.js; expand recurrence with rrule + +**OIDC (Authelia):** +- Authelia OIDC identity provider - User authentication and session management + - SDK/Client: @hono/oidc-auth 1.8.3 (`apps/api/src/auth/middleware.ts`) + - Auth method: Authorization-code flow with PKCE (S256 challenge method) + - Token auth: client_secret_basic (plaintext secret, NOT pbkdf2 hash) + - Required env vars: OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET + - Session: Storage-less JWT cookies; refresh via stored refresh token every 15 min (default OIDC_AUTH_REFRESH_INTERVAL) + - Requested scopes: `openid profile email offline_access` (customize via OIDC_SCOPES env var) + - Metadata discovery: Fetches `/.well-known/openid-configuration` from issuer + - Callback: `/callback` route in Hono app; redirects to `/api/login` → `/` on success + +## Data Storage + +**Databases:** +- MariaDB 11 - Primary relational database (required; PostgreSQL not available) + - Connection: Environment vars (DB_HOST, DB_PORT 3306, DB_USER, DB_PASSWORD, DB_NAME) + - Client: mysql2 3.22.4 (native driver via Drizzle ORM) + - Schema: `apps/api/src/db/schema.ts` (Drizzle mysqlTable definitions) + - Tables: users, member_credentials, calendars, calendarEvents, calendarOutbox + - Connection pool: 10 connections max (mysql2 createPool) + - Migrations: Generated by drizzle-kit; stored in `apps/api/src/db/migrations/` + - Local dev: Docker service `mariadb` with healthcheck; data persisted to `mariadb_data` volume + +**File Storage:** +- Local filesystem only - PWA static assets built by Vite + - Location: Built output copied to `apps/api/dist/public` (Dockerfile pwa-builder stage) + - Served by Hono via serveStatic middleware on the same :3000 port + - No external cloud storage (S3, GCS, etc.) + +**Caching:** +- Redis 7-Alpine - Declared in docker-compose.yml but unused in Phase 1 + - Reserved for Phase 4 live list sync (pub/sub for broadcasting list-change events across Node processes) + - Local dev: Docker service `redis` on port 6379 + - Client: ioredis (not yet added to dependencies; planned for Phase 4) + +## Authentication & Identity + +**Auth Provider:** +- Authelia (self-hosted, pre-deployed on Unraid host) + - Implementation: RFC-compliant OIDC provider + - User identity: Composite key of oidc_iss + oidc_sub (never email, per D-10 in schema) + - Session flow: Browser top-level nav to /api/login → 302 redirect to Authelia authorize → user logs in → POST to /callback → JWT session cookie set → browser redirected to / + - Invalid XHR redirects: Browser blocks cross-origin redirects from fetch/XHR to external IdP; PWA handles via maybeRedirectToLogin() (top-level navigation) + - Claims policy: Authelia 4.39+ required for name/email/preferred_username in ID token (otherwise defaults to "Member" display name) + +**Dev Bypass (non-production only):** +- DEV_AUTH_BYPASS environment variable (NODE_ENV !== 'production') + - When enabled: Skips @hono/oidc-auth middleware; injects DEV_USER into context + - Allows local development without live Authelia instance + - Implementation: `apps/api/src/auth/devBypass.ts` + +## Monitoring & Observability + +**Error Tracking:** +- Not detected - Errors logged to console; no external service integration + +**Logs:** +- Console-based - Events logged to stdout/stderr + - Backend (Hono): Startup message, CalDAV poller errors (per-credential logging, T-03-04), outbox worker status + - Frontend: React error boundaries catch component errors + +**Health Check:** +- GET /health endpoint (unauthenticated) + - Endpoint: `apps/api/src/routes/health.ts` + - Used by Docker Compose healthcheck for mariadb service + - MariaDB test: `healthcheck.sh --connect --innodb_initialized` + +## CI/CD & Deployment + +**Hosting:** +- Docker on Unraid host (self-hosted) + - Container image: Single production image from Dockerfile (API + PWA on port :3000) + - Orchestration: Docker Compose (docker-compose.yml + docker-compose.dev.yml overrides) + - Environment: Split-DNS internal domain; private IPs internally; external access via Pangolin/Newt tunnel + +**CI Pipeline:** +- Not detected - No GitHub Actions, GitLab CI, or similar configured + +**Build Output:** +- Docker multi-stage build: + - API: TypeScript compiled to `apps/api/dist/` by tsc + - PWA: Vite bundles to `apps/pwa/dist/`; copied to `apps/api/dist/public` in production image + - Single container serves both layers on :3000 + +## Environment Configuration + +**Required env vars (Backend):** +- Database: DB_HOST, DB_PORT (default 3306), DB_USER, DB_PASSWORD, DB_NAME, DB_ROOT_PASSWORD +- OIDC: OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, OIDC_REDIRECT_URI, OIDC_AUTH_EXTERNAL_URL (mandatory for Pangolin redirects) +- Session: OIDC_AUTH_SECRET (32+ chars for JWT cookie signing) +- Scopes: OIDC_SCOPES (default: `openid profile email offline_access`) +- Encryption: APP_PASSWORD_ENCRYPTION_KEY (AES-256-GCM key for encrypting Fastmail app passwords) +- Environment: NODE_ENV (production/development) +- Dev override: DEV_AUTH_BYPASS (set to 'true' to disable OIDC; dev-only, NODE_ENV !== 'production') + +**Secrets location:** +- `.env` file (local development) — not committed; pattern documented in docker-compose.yml +- Docker Compose environment variables — injected at runtime from `.env` or deployment config +- Member app passwords: Encrypted in DB (member_credentials.encryptedPassword) using APP_PASSWORD_ENCRYPTION_KEY +- OIDC client secret: Plain text in env var (NOT the pbkdf2 hash from Authelia config) + +**Optional env vars:** +- OIDC_AUTH_EXTERNAL_URL - MANDATORY behind Pangolin for correct redirect_uri construction (Pitfall 1) +- DEV_AUTH_BYPASS - Dev-only; local testing without Authelia + +## Webhooks & Callbacks + +**Incoming:** +- /callback - OIDC authorization-code exchange endpoint + - Mounted in `apps/api/src/index.ts` before oidcAuthMiddleware + - Receives POST from Authelia after user login; exchanges code for tokens + - Sets session JWT cookie; redirects to /api/login (continues to /) + - Critical: Must not be intercepted by service worker (navigateFallbackDenylist in vite.config.ts) + +**Outgoing:** +- None detected - No third-party webhooks triggered by the app +- Fastmail CalDAV: Changes are POLLED (5-min cron poller), not webhook-driven +- List sync (Phase 4): Will use SSE (server-sent events) for client push, not webhooks + +## Network & Transport + +**HTTPS/TLS:** +- Mandatory for OIDC flows +- Pangolin/Newt tunnel provides HTTPS reverse proxy +- Internal domain: Split-DNS routes internal requests directly to private IP +- External requests: Routed through Pangolin tunnel + +**Server-Sent Events (SSE):** +- GET /api/sse/heartbeat - Test endpoint for Pangolin compatibility + - Endpoint: `apps/api/src/routes/sse.ts` + - Uses Hono's streamSSE helper + - Test procedure (D-08): `curl -N https://familysync./api/sse/heartbeat` + - Phase 4 will extend this for live list sync + +**CORS:** +- Credentials: 'include' for all fetch calls (session cookie sent cross-origin in dev proxy) +- redirect: 'manual' for /api/me to detect OIDC redirect (prevents fetch hang on cross-origin 302 to Authelia) + +--- + +*Integration audit: 2026-06-09* diff --git a/.planning/codebase/STACK.md b/.planning/codebase/STACK.md new file mode 100644 index 0000000..647d5d4 --- /dev/null +++ b/.planning/codebase/STACK.md @@ -0,0 +1,130 @@ +# Technology Stack + +**Analysis Date:** 2026-06-09 + +## Languages + +**Primary:** +- TypeScript 5.5.x - Full stack: backend (`apps/api/src`), frontend (`apps/pwa/src`), shared types +- JavaScript - Package tooling (node-cron, vite config, drizzle config) + +**Secondary:** +- CSS - Styling (imported via Vite; Schedule-X provides default theme) +- HTML - PWA manifest generation via vite-plugin-pwa + +## Runtime + +**Environment:** +- Node.js 22 LTS (`FROM node:22-alpine` in Dockerfile) +- Browser: ES2023 target; iOS 16.4+ (PWA home-screen install required) + +**Package Manager:** +- pnpm 11.5.1 +- Lockfile: `pnpm-lock.yaml` present +- Workspace: `pnpm-workspace.yaml` with `apps/*` packages + +## Frameworks + +**Core (Backend):** +- Hono 4.12.23 - HTTP framework with Web Standards API; `@hono/node-server` for Node.js runtime +- @hono/oidc-auth 1.8.3 - OIDC session middleware (Authelia integration; storage-less JWT cookies) +- @hono/zod-validator 0.8.0 - Request body/query validation in route handlers + +**Core (Frontend):** +- React 19.x - PWA frontend with concurrent features +- Vite 8.0.16 - Build tooling (dev server with HMR, production bundler) +- vite-plugin-pwa 1.3.0 - Service worker registration, PWA manifest generation, Workbox 7 integration + +**Calendar UI:** +- @schedule-x/react 4.1.0 - Calendar component wrapper +- @schedule-x/calendar 4.6.0 - Core calendar rendering +- @schedule-x/event-modal 4.6.0 - Event detail/edit modal +- @schedule-x/events-service 4.6.0 - Event data management +- @schedule-x/calendar-controls 4.6.0 - Month/week navigation +- @schedule-x/theme-default 4.6.0 - Default theme (CSS overridden by `apps/pwa/src/styles/tokens.css`) + +**Client State:** +- @tanstack/react-query 5.101.0 - Server state fetching, caching, background refetch, invalidation +- zustand 5.0.14 - UI-only state (selected date range, color assignments, drawer states) + +**Testing (Backend):** +- Vitest 4.1.8+ - Unit + integration test runner; config: `apps/api/vitest.config.ts` (environment: node, globals: true) + +**Testing (Frontend):** +- Vitest 4.1.8+ - Unit test runner; config: `apps/pwa/vitest.config.ts` (environment: jsdom, TZ=UTC for deterministic date tests) +- @testing-library/react 16.3.0 - Component testing utilities +- @testing-library/jest-dom 6.6.3+ - Jest DOM matchers + +**Build/Dev:** +- @vitejs/plugin-react 4.3.0+ - JSX transform, React Fast Refresh + +## Key Dependencies + +**Critical (CalDAV):** +- tsdav 2.2.2 - CalDAV client for Fastmail integration; fetches calendars (PROPFIND) and events (REPORT); handles Basic auth +- ical.js 2.2.1 - iCalendar (.ics) parsing on both backend (CalDAV responses) and frontend (event hydration); Mozilla-maintained reference implementation +- rrule 2.8.1 - Not yet declared; RRULE expansion for recurring event expansion (Phase 2 calendar view) + +**Critical (Database):** +- drizzle-orm 0.45.2 - Type-safe SQL ORM; MySQL dialect targeting MariaDB; zero runtime overhead +- drizzle-kit 0.31.10 - Schema migration generator (generates SQL from `apps/api/src/db/schema.ts`) +- mysql2 3.22.4 - Native MariaDB/MySQL driver; Promises API; used by Drizzle + +**Critical (Validation):** +- zod 3.25.0+ - Schema validation (event payloads, API requests) + +**Supporting (Backend):** +- node-cron 4.2.1+ - Cron scheduling for CalDAV poller (5-min), outbox worker (15-sec) +- temporal-polyfill 0.3.2 - Temporal API polyfill for date/time operations (ISO 8601 handling) + +**Supporting (Frontend):** +- temporal-polyfill 0.3.2 - Same Temporal polyfill; imported before Schedule-X at `apps/pwa/src/main.tsx:7` +- lucide-react 1.17.0 - Icon library +- idb 7.1.1 - IndexedDB wrapper (optional; available but not yet wired) + +**Development Only:** +- @types/node 22.x - Node.js type definitions +- @types/react 19.x - React type definitions +- @types/react-dom 19.x - React DOM type definitions +- jsdom 26.1.0+ - DOM simulation for frontend tests + +## Configuration + +**Environment (Backend — `apps/api`):** +- `.env` - Local secrets (DB credentials, OIDC settings, encryption key); pattern in `docker-compose.yml` +- `drizzle.config.ts` - Dialect: mysql; schema path: `./src/db/schema.ts`; migrations: `./src/db/migrations` +- `tsconfig.json` - Target: ES2023; module: NodeNext; strict: true + +**Environment (Frontend — `apps/pwa`):** +- `vite.config.ts` - React plugin, PWA plugin (Workbox config with navigateFallback and denylist for /callback, /api/*, /health) +- `tsconfig.json` - Target: ES2023; lib: [ES2023, DOM, DOM.Iterable]; jsx: react-jsx; strict: true + +**Build (Docker):** +- Multi-stage Dockerfile (`apps/api/Dockerfile`): + - `base` - Node 22 Alpine with pnpm enabled + - `builder` - TypeScript compilation for API only + - `pwa-builder` - Vite build for PWA (produces `dist/`) + - `dev` - Development image with hot-reload via `node --watch` + - `production` - Single port (:3000) serving both API and PWA static files + +## Platform Requirements + +**Development:** +- Node.js 22 LTS +- pnpm 11.5.1 +- Docker + Docker Compose (for local MariaDB + Redis) +- MariaDB 11 (via `docker-compose.yml`) +- Redis 7-Alpine (via `docker-compose.yml`, present but unused in Phase 1) +- Vite dev server proxy: `localhost:3000` for /api, /callback, /health + +**Production:** +- Node.js 22 LTS runtime in Docker container +- Authelia OIDC provider (pre-deployed; configured via env vars) +- MariaDB 11 database +- Redis 7 (optional; reserved for Phase 4 live list sync pub/sub) +- Pangolin/Newt tunnel for secure external access (no open ports) +- Split-DNS internal domain resolution + +--- + +*Stack analysis: 2026-06-09* diff --git a/.planning/codebase/STRUCTURE.md b/.planning/codebase/STRUCTURE.md new file mode 100644 index 0000000..f56d263 --- /dev/null +++ b/.planning/codebase/STRUCTURE.md @@ -0,0 +1,273 @@ +# Codebase Structure + +**Analysis Date:** 2026-06-09 + +## Directory Layout + +``` +familysync/ +├── apps/ +│ ├── api/ +│ │ ├── src/ +│ │ │ ├── index.ts # Hono app + HTTP server + broker startup +│ │ │ ├── auth/ +│ │ │ │ ├── middleware.ts # OIDC guard via @hono/oidc-auth +│ │ │ │ ├── user.ts # Identity upsert + color assignment +│ │ │ │ └── devBypass.ts # DEV_AUTH_BYPASS middleware (local dev) +│ │ │ ├── db/ +│ │ │ │ ├── client.ts # mysql2 + Drizzle instance +│ │ │ │ ├── schema.ts # Drizzle table definitions +│ │ │ │ └── migrations/ # drizzle-kit migration files +│ │ │ ├── routes/ +│ │ │ │ ├── events.ts # GET /api/events (windowed), POST/PATCH/DELETE (enqueue) +│ │ │ │ ├── me.ts # GET /api/me (current user) +│ │ │ │ ├── health.ts # GET /health (unauthenticated) +│ │ │ │ └── sse.ts # GET /api/sse/heartbeat (SSE test) +│ │ │ └── broker/ +│ │ │ ├── poller.ts # 5-min cron: PROPFIND → ctag detect +│ │ │ ├── sync.ts # REPORT → ical.js → upsert (per-calendar) +│ │ │ ├── outboxWorker.ts # 15-sec cron: drain pending writes to Fastmail +│ │ │ ├── write.ts # PUT/DELETE builders for tsdav +│ │ │ ├── expand.ts # Server-side RRULE expansion +│ │ │ ├── vevent.ts # VEVENT builder + RRULE extraction +│ │ │ ├── client.ts # tsdav client factory +│ │ │ ├── crypto.ts # AES-256-GCM encrypt/decrypt +│ │ │ └── spike.ts # Proof-of-concept (unused, historical) +│ │ ├── tests/ +│ │ │ ├── routes/ # Unit tests for route handlers +│ │ │ ├── broker/ # Unit tests for broker modules +│ │ │ ├── auth/ # Unit tests for auth +│ │ │ ├── fixtures/ # Test data factories +│ │ │ └── helpers/ # Test utilities (mock db, etc.) +│ │ ├── package.json # Backend dependencies +│ │ ├── tsconfig.json # TypeScript config (strict mode) +│ │ └── dist/ # Compiled JavaScript (gitignored) +│ └── pwa/ +│ ├── src/ +│ │ ├── main.tsx # Vite entry point +│ │ ├── App.tsx # Root component (CalendarShell) +│ │ ├── components/ +│ │ │ ├── CalendarShell.tsx # Schedule-X wiring + TanStack Query + Zustand +│ │ │ ├── AppNav.tsx # Header/sidebar navigation +│ │ │ ├── EventDetailPopover.tsx # Event detail display + edit/delete actions +│ │ │ ├── EventForm.tsx # Create/edit event modal +│ │ │ ├── DeleteConfirmationDialog.tsx # Delete confirm modal +│ │ │ ├── SyncStateToast.tsx # Write-back status toast +│ │ │ ├── ColorLegend.tsx # Calendar color legend +│ │ │ ├── InstallPrompt.tsx # PWA install prompt +│ │ │ ├── SkeletonCalendar.tsx # Loading skeleton +│ │ │ ├── ErrorBoundary.tsx # Error boundary wrapper +│ │ │ └── *.test.tsx # Component tests +│ │ ├── api/ +│ │ │ ├── client.ts # Typed fetch wrappers (fetchMe, fetchEvents, fetchCreateEvent, etc.) +│ │ │ └── client.test.ts # API client tests +│ │ ├── store/ +│ │ │ └── calendarStore.ts # Zustand UI-state store +│ │ ├── lib/ +│ │ │ ├── hydrateEvents.ts # Occurrence[] → Schedule-X CalendarType[] +│ │ │ ├── calendarConfig.ts # Schedule-X config builder +│ │ │ ├── colorUtils.ts # Hex color utilities +│ │ │ ├── eventDateTime.ts # Date/time formatting + parsing +│ │ │ ├── loginRedirect.ts # OIDC redirect handler (maybeRedirectToLogin) +│ │ │ └── *.test.ts # Utility tests +│ │ └── styles/ +│ │ └── tokens.ts # CSS-in-JS design tokens (colors, spacing) +│ ├── public/ +│ │ ├── index.html # PWA shell HTML +│ │ ├── manifest.webmanifest # PWA metadata +│ │ ├── sw.js # Service worker entry (generated by vite-plugin-pwa) +│ │ ├── icon-192.png # PWA icon (192x192) +│ │ └── icon-512.png # PWA icon (512x512) +│ ├── package.json # Frontend dependencies +│ ├── tsconfig.json # TypeScript config +│ ├── vite.config.ts # Vite + vite-plugin-pwa configuration +│ └── dist/ # Built PWA (gitignored) +├── packages/ +│ └── shared/ # Shared types (currently placeholder) +├── package.json # Monorepo root (pnpm workspaces) +├── pnpm-lock.yaml # Dependency lock file +└── .planning/ + └── codebase/ # This document +``` + +## Directory Purposes + +**`apps/api/src/`** — Backend HTTP server and background broker +- **Routes** respond to client requests (GET reads cache only; POST/PATCH/DELETE enqueue outbox) +- **Broker** runs background jobs (poller syncs with Fastmail; outbox worker drains writes) +- **Auth** handles OIDC session + user identity upsert +- **DB** defines schema and provides Drizzle ORM client + +**`apps/pwa/src/`** — React PWA frontend +- **Components** render UI and handle user interactions +- **API** wraps typed fetch calls to backend endpoints +- **Store** owns UI-only state (view selection, modal open/close) via Zustand +- **Lib** provides utilities for date handling, color assignment, event hydration, login redirect +- **Public** contains PWA manifest, service worker config, and static assets +- **Styles** defines design tokens (colors, spacing, typography) + +**`apps/api/tests/`** — Unit tests for backend +- **Routes** test endpoint validation, authorization, DB queries +- **Broker** test CalDAV sync logic, RRULE expansion, outbox draining +- **Auth** test user upsert, color assignment, OIDC claim handling +- **Fixtures** provide test data factories (mock users, credentials, events) +- **Helpers** provide test utilities (mock Drizzle, mock tsdav clients) + +**`packages/shared/`** — Shared types (future expansion for N-member) +- Currently a placeholder; will contain cross-app TypeScript interfaces when multi-member features need shared definitions + +## Key File Locations + +**Entry Points:** + +| File | Purpose | +|------|---------| +| `apps/api/src/index.ts` | Hono app definition, middleware stack, route registration, broker startup | +| `apps/pwa/src/main.tsx` | Vite entry point; React.createRoot, hydrate App | +| `apps/pwa/src/App.tsx` | Root component; renders CalendarShell | + +**Configuration:** + +| File | Purpose | +|------|---------| +| `apps/api/package.json` | Backend dependencies (Hono, Drizzle, tsdav, ical.js, rrule, node-cron, zod, @hono/zod-validator, @hono/oidc-auth, mysql2) | +| `apps/pwa/package.json` | Frontend dependencies (React 19, Vite, @tanstack/react-query, Zustand, @schedule-x/react, lucide-react, etc.) | +| `apps/api/tsconfig.json` | strict: true; lib: es2022; module: es2022 | +| `apps/pwa/tsconfig.json` | strict: true; jsx: react-jsx; lib: es2022, dom | +| `apps/pwa/vite.config.ts` | Vite plugins (react, VitePWA); dev proxy to :3000; PWA manifest config | + +**Core Logic:** + +| File | Purpose | +|------|---------| +| `apps/api/src/db/schema.ts` | Drizzle table definitions (users, member_credentials, calendars, calendar_events, calendar_outbox) | +| `apps/api/src/routes/events.ts` | GET /api/events (windowed + expanded), write endpoints (POST/PATCH/DELETE), sync-status polling, writable-calendars | +| `apps/api/src/broker/poller.ts` | 5-min background job; PROPFIND → ctag change detection | +| `apps/api/src/broker/sync.ts` | REPORT → ical.js parse → calendar_events upsert; prune deletes | +| `apps/api/src/broker/outboxWorker.ts` | 15-sec drain pending outbox rows; PUT/DELETE to Fastmail; exponential backoff | +| `apps/api/src/broker/expand.ts` | ical.js RecurExpansion; emit concrete occurrences (with VTIMEZONE + RRULE handled) | +| `apps/pwa/src/components/CalendarShell.tsx` | TanStack Query (events, me), Zustand (range, view), Schedule-X wiring | +| `apps/pwa/src/store/calendarStore.ts` | Zustand store; selectedView, openEventId, calendarRange, eventFormOpen, deleteDialogOpen | +| `apps/pwa/src/api/client.ts` | Typed fetch wrappers; MeResponse, CalendarOccurrence, CreateEventPayload interfaces | +| `apps/pwa/src/lib/hydrateEvents.ts` | Occurrence[] → Schedule-X CalendarEvent[] with Temporal.ZonedDateTime conversion | + +**Testing:** + +| File | Purpose | +|------|---------| +| `apps/api/tests/routes/events.test.ts` | Unit tests for route handlers (validation, ownership checks, SQL correctness) | +| `apps/api/tests/broker/expand.test.ts` | Unit tests for RRULE expansion (VTIMEZONE, EXDATE, DST) | +| `apps/pwa/src/components/CalendarShell.test.tsx` | Component integration test; mocked React Query + Zustand | +| `apps/pwa/src/lib/hydrateEvents.test.ts` | Unit tests for Temporal conversion logic | + +## Naming Conventions + +**Files:** + +| Pattern | Example | Where | +|---------|---------|-------| +| Kebab-case for route/route groups | `events.ts`, `health.ts` | `apps/api/src/routes/` | +| Kebab-case for modules | `poller.ts`, `sync.ts`, `outbox-worker.ts` (or camelCase `outboxWorker.ts`) | `apps/api/src/broker/` | +| PascalCase for React components | `CalendarShell.tsx`, `EventDetailPopover.tsx` | `apps/pwa/src/components/` | +| Kebab-case for utility functions | `hydrateEvents.ts`, `colorUtils.ts` | `apps/pwa/src/lib/` | +| `.test.ts` / `.test.tsx` for tests | `events.test.ts`, `CalendarShell.test.tsx` | Colocated with source | + +**Functions:** + +| Pattern | Example | +|---------|---------| +| camelCase for functions | `fetchEvents`, `expandOccurrences`, `upsertUser`, `syncCalendar` | +| PascalCase for React components | `CalendarShell`, `EventForm`, `SyncStateToast` | +| UPPER_CASE for module-level constants | `MAX_WINDOW_DAYS`, `COLOR_PALETTE`, `TRANSIENT_STATUSES` | +| Leading `$` for Drizzle special methods | `.$returningId()`, `.onDuplicateKeyUpdate()` | + +**Variables:** + +| Pattern | Example | +|---------|---------| +| camelCase for variables | `currentUserId`, `calendarRange`, `eventsQuery` | +| `is`/`has` prefix for booleans | `isShared`, `hasRrule`, `eventFormOpen` | +| Trailing `Id` for foreign keys | `userId`, `calendarId`, `groupId` | +| Descriptive names for arrays | `seenUids`, `usedColors`, `occurrences` | + +**Types:** + +| Pattern | Example | +|---------|---------| +| PascalCase for interfaces | `CalendarOccurrence`, `MeResponse`, `CreateEventPayload` | +| PascalCase for type aliases | `RecurrencePreset`, `BreakpointGroup` | +| Trailing `Schema` for Zod/validation | `eventsQuerySchema`, `eventFieldsSchema` | +| Trailing `Response` for API responses | `MeResponse`, `OccurrencesResponse` | + +## Where to Add New Code + +**New Feature:** + +| Feature Type | Primary Code | Tests | Configuration | +|--------------|--------------|-------|---------------| +| Calendar event operation (read-only) | `apps/api/src/routes/events.ts` (new GET endpoint) | `apps/api/tests/routes/events.test.ts` | `apps/pwa/src/api/client.ts` (new fetchFn) | +| Calendar event operation (write) | `apps/api/src/routes/events.ts` (new POST/PATCH/DELETE) + `apps/api/src/broker/write.ts` (new builder) | Route tests + outbox drain tests | `apps/pwa/src/components/EventForm.tsx` (new field) | +| Recurring event handling | `apps/api/src/broker/expand.ts` (expansion logic) | `apps/api/tests/broker/expand.test.ts` | N/A (no UI change needed) | +| Shared list sync | `apps/api/src/routes/lists.ts` (new router) + `apps/api/src/broker/listsSync.ts` (if background job needed) | `apps/api/tests/routes/lists.test.ts` | `apps/pwa/src/api/client.ts` (new interfaces) | +| UI component (calendar display) | `apps/pwa/src/components/` | `apps/pwa/src/components/*.test.tsx` | N/A | +| UI component (modal/dialog) | `apps/pwa/src/components/` + `apps/pwa/src/store/calendarStore.ts` (add state if needed) | Component test | N/A | + +**New Endpoint:** + +1. Create router file in `apps/api/src/routes/` (or add to existing) +2. Define Zod schema for input validation +3. Implement handler(s): call resolveUserId, validate input, check authorization, query DB or enqueue outbox +4. Mount in `apps/api/src/index.ts` via `app.route('/api/...', newRouter)` +5. Export typed fetch function from `apps/pwa/src/api/client.ts` +6. Call from CalendarShell or component via useQuery/useMutation +7. Write unit tests in `apps/api/tests/routes/` + +**New Component:** + +1. Create `.tsx` file in `apps/pwa/src/components/` +2. Use TanStack Query for server state (via useQuery hook) +3. Use Zustand selectors for UI state (via useCalendarStore) +4. Export from CalendarShell or parent component +5. Add `.test.tsx` file with Vitest + React Testing Library +6. Mock useQuery and useCalendarStore in tests + +**New Utility:** + +1. Create `.ts` file in `apps/pwa/src/lib/` (frontend) or `apps/api/src/broker/` (backend) +2. Export functions with clear names and JSDoc comments +3. Add `.test.ts` file with test cases +4. Import where needed (no circular dependencies) + +## Special Directories + +**`apps/api/src/db/migrations/`:** +- Purpose: drizzle-kit-generated SQL migration files +- Generated: Yes (via `drizzle-kit generate:mysql`) +- Committed: Yes (must be version-controlled for reproducibility) +- How to add: Run `drizzle-kit generate:mysql` after modifying `schema.ts`; commit the `.sql` file +- How to apply: Run `drizzle-kit migrate:mysql` to execute pending migrations against MariaDB + +**`apps/pwa/public/`:** +- Purpose: PWA static assets served at root (manifest.webmanifest, service worker, icons, index.html) +- Generated: `sw.js` and `registerSW.js` are generated by vite-plugin-pwa; others are committed +- Committed: Yes (except dist/ and generated service worker code — PWA plugin handles registration) +- How to add: Place assets here; vite build copies to dist/ and serves at / + +**`apps/api/dist/` and `apps/pwa/dist/`:** +- Purpose: Compiled output (JavaScript, CSS, bundled PWA) +- Generated: Yes (via build scripts) +- Committed: No (gitignored) + +**`node_modules/`:** +- Purpose: pnpm-installed dependencies +- Generated: Yes (via `pnpm install`) +- Committed: No (gitignored; use `pnpm-lock.yaml` for reproducibility) + +**`.planning/codebase/`:** +- Purpose: Auto-generated codebase analysis documents (this file, ARCHITECTURE.md, TESTING.md, etc.) +- Generated: Yes (by `/gsd-map-codebase` orchestrator) +- Committed: Yes (reference documentation for future phases) + +--- + +*Structure analysis: 2026-06-09* diff --git a/.planning/codebase/TESTING.md b/.planning/codebase/TESTING.md new file mode 100644 index 0000000..a9e47fe --- /dev/null +++ b/.planning/codebase/TESTING.md @@ -0,0 +1,400 @@ +# Testing Patterns + +**Analysis Date:** 2026-06-09 + +## Test Framework + +**Runner:** +- Backend: Vitest 4.1.8, Node environment +- Frontend: Vitest 4.1.8, jsdom environment +- Config: `apps/api/vitest.config.ts`, `apps/pwa/vitest.config.ts` + +**Assertion Library:** +- Vitest built-in `expect()` +- Testing Library (`@testing-library/react`, `@testing-library/jest-dom`) for component DOM assertions +- `jest-dom` matchers extended via `apps/pwa/src/test-setup.ts` + +**Run Commands:** +```bash +# Run all tests +pnpm test + +# Run tests in watch mode +pnpm --filter @familysync/api test:watch +pnpm --filter @familysync/pwa test:watch + +# Run with coverage (not configured yet) +vitest run --coverage +``` + +## Test File Organization + +**Location:** +- Backend: `apps/api/tests/` parallel to `apps/api/src/` — mirrors source structure +- Frontend: Co-located with source files — `src/components/Foo.tsx` → `src/components/Foo.test.tsx` + +**Naming:** +- Test files: `{module}.test.ts` or `.test.tsx` +- Fixtures: `apps/api/tests/fixtures/` — fixture files (e.g., `weekly-dst.ics`) loaded by test helpers + +**Structure:** +``` +apps/api/tests/ +├── health.test.ts # End-to-end test for GET /health +├── auth/ +│ ├── devBypass.test.ts +│ └── user.test.ts +├── broker/ +│ ├── expand.test.ts # expandOccurrences() unit tests +│ ├── poller.test.ts +│ ├── outboxWorker.test.ts +│ ├── sync.test.ts +│ ├── vevent.test.ts +│ ├── write.test.ts +│ └── crypto.test.ts +├── routes/ # Route handler tests TBD +├── helpers/ # Test utility functions +└── fixtures/ + ├── weekly-dst.ics # DST test fixture (weekly recurrence) + └── allday-birthday.ics # All-day recurrence fixture + +apps/pwa/src/ +├── api/client.test.ts +├── lib/ +│ ├── colorUtils.test.ts +│ ├── eventDateTime.test.ts +│ ├── hydrateEvents.test.ts +│ ├── loginRedirect.test.ts +│ └── calendarConfig.test.ts +├── components/ +│ ├── InstallPrompt.test.tsx +│ └── ... +└── store/ + └── (Zustand store tested via client.test.ts) +``` + +## Test Structure + +**Suite Organization:** +```typescript +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' + +describe('GET /health', () => { + it('returns 200 with { ok: true, db: "up" } when DB round-trip succeeds', async () => { + // Arrange + const { app } = await import('../src/index.js') + + // Act + const res = await app.request('/health') + + // Assert + expect(res.status).toBe(200) + const body = await res.json() as { ok: boolean; db: string } + expect(body.ok).toBe(true) + }) + + it('returns 503 when DB round-trip throws', async () => { + // Arrange + const { db } = await import('../src/db/client.js') + vi.mocked(db.execute).mockRejectedValueOnce(new Error('DB connection failed')) + + // Act + const { app } = await import('../src/index.js') + const res = await app.request('/health') + + // Assert + expect(res.status).toBe(503) + }) +}) +``` + +**Patterns:** +- Async test functions with full await chain +- Hono request testing: `app.request(path)` returns a Response object +- Mock setup in `beforeEach`; cleanup in `afterEach` with `vi.unstubAllGlobals()` or `vi.clearAllMocks()` +- Descriptive test names following "should [action] when [condition]" or "[verb] [noun]" pattern +- Arrange-Act-Assert (AAA) comment structure for multi-step tests + +## Mocking + +**Framework:** Vitest `vi` object (`vi.mock`, `vi.mocked`, `vi.fn`, `vi.stubGlobal`) + +**Module Mocking:** +```typescript +// Hoist vi.mock() calls to the top of the module (Vitest requirement) +vi.mock('../src/db/client.js', () => ({ + db: { + execute: vi.fn().mockResolvedValue([[{ '1': 1 }]]), + }, +})) +``` + +**Function Mocking:** +```typescript +const mockFetch = vi.mocked(fetch) +mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ uid: 'test-uid' }), +} as Response) + +// Call the function under test +await createEvent(payload) + +// Assert the mock was called correctly +expect(mockFetch).toHaveBeenCalledWith( + '/api/events/create', + expect.objectContaining({ + method: 'POST', + credentials: 'include', + }), +) +``` + +**Global Stubs (Frontend):** +```typescript +beforeEach(() => { + vi.stubGlobal('fetch', vi.fn()) +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) +``` + +**What to Mock:** +- External I/O: database (via `vi.mock` on `src/db/client.js`) +- Network calls: `fetch` (via `vi.stubGlobal('fetch', ...)`) +- Environment-dependent code: `window.matchMedia` (jsdom polyfill, see test-setup.ts) +- Time-dependent code: `Date`, `setTimeout` (if needed; not used currently) + +**What NOT to Mock:** +- Pure utility functions — test them directly (colorUtils, eventDateTime, hydrateEvents) +- Zod validation schemas — test with real payloads +- Zustand stores — instantiate real store, call real methods +- Hono app logic — use `app.request()` to test end-to-end +- iCalendar parsing (ical.js) — test with real .ics fixtures, not mocks + +## Fixtures and Factories + +**Test Data (Backend):** +Fixture files are `.ics` (iCalendar) strings stored in `apps/api/tests/fixtures/`: + +```typescript +// Load fixture file +const rawVevent = readFileSync(join(FIXTURES, 'weekly-dst.ics'), 'utf8') + +// Use in test +const occurrences = expandOccurrences( + rawVevent, + new Date('2026-03-01T00:00:00Z'), + new Date('2026-04-01T00:00:00Z'), + 1, + 'My Calendar', + 1, + 'Alice', + '#4A90D9', + false, +) +``` + +**Test Data (Frontend):** +Inline mock objects in test files (no factory pattern needed yet): + +```typescript +vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + calendars: [ + { url: 'https://caldav.fastmail.com/cal1', displayName: 'My Calendar', color: '#4A90D9', isShared: false }, + { url: 'https://caldav.fastmail.com/cal2', displayName: 'Family', color: '#F25C7A', isShared: true }, + ], + }), +} as Response) +``` + +**Location:** +- Fixture files: `apps/api/tests/fixtures/` — raw .ics strings for iCalendar tests +- Mock payloads: inline in test files (`api/client.test.ts`, etc.) + +## Coverage + +**Requirements:** None enforced (no coverage thresholds in vitest.config.ts) + +**Current State:** +- Backend: Partial coverage — broker modules (expand, sync, write, crypto, vevent) tested; route handlers mostly untested +- Frontend: Good coverage of utility functions (colorUtils, eventDateTime, hydrateEvents, calendarConfig) and API client + +**View Coverage:** +```bash +# Generate coverage report (requires @vitest/coverage-v8) +vitest run --coverage +``` + +## Test Types + +**Unit Tests:** +- Scope: Single function or small module in isolation (mocks external dependencies) +- Approach: Test input → output contracts, edge cases, error conditions +- Examples: `lib/colorUtils.test.ts`, `broker/crypto.test.ts`, `api/client.test.ts` + +**Integration Tests:** +- Scope: Multi-module behavior (e.g., route handler + DB + auth middleware) +- Approach: Test realistic user flows using `app.request()` for HTTP semantics +- Examples: `health.test.ts` (GET /health with mocked DB) +- No external API calls (Fastmail, Authelia mocked) + +**E2E Tests:** +- Not implemented; would require running a real server + browser +- Currently using `playwright-cli` skill for browser-based smoke tests of UI (per project CLAUDE.md) + +## Common Patterns + +**Async Testing:** +```typescript +it('returns { uid } on success', async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ uid: 'returned-uid' }), + } as Response) + + const { createEvent } = await import('./client.js') + const result = await createEvent(payload) + + expect(result).toEqual({ uid: 'returned-uid' }) +}) +``` + +**Error Testing:** +```typescript +it('throws on non-ok response', async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: false, + status: 400, + json: async () => ({ error: 'Bad Request' }), + } as Response) + + const { createEvent } = await import('./client.js') + + await expect( + createEvent({ title: '', ... }) + ).rejects.toThrow() +}) +``` + +**Status Code Testing:** +```typescript +it('returns 503 when DB round-trip throws', async () => { + const { db } = await import('../src/db/client.js') + vi.mocked(db.execute).mockRejectedValueOnce(new Error('DB connection failed')) + + const { app } = await import('../src/index.js') + const res = await app.request('/health') + + expect(res.status).toBe(503) +}) +``` + +**Fixture-Based Testing:** +```typescript +describe('expandOccurrences — DST correctness', () => { + it('returns 10:00 America/New_York wall-clock time on BOTH sides of March 2026 DST boundary', () => { + const rawVevent = loadFixture('weekly-dst.ics') + const windowStart = new Date('2026-03-01T00:00:00Z') + const windowEnd = new Date('2026-04-01T00:00:00Z') + + const occurrences = expandOccurrences( + rawVevent, + windowStart, + windowEnd, + 1, 'My Calendar', 1, 'Alice', '#4A90D9', false, + ) + + // Check DST correctness: all occurrences must show hour === 10 local time + for (const occ of occurrences) { + expect(occ.start).toMatch(/T10:00:00/) + expect(occ.start).toContain('[America/New_York]') + } + + // Explicitly check pre- and post-transition occurrences + const preTransition = occurrences.find(o => o.start.includes('2026-03-01')) + const postTransition = occurrences.find(o => o.start.includes('2026-03-15')) + + expect(preTransition!.start).toContain('-05:00[America/New_York]') // EST + expect(postTransition!.start).toContain('-04:00[America/New_York]') // EDT + }) +}) +``` + +**Zustand Store Testing:** +```typescript +describe('calendarStore', () => { + it('setEventForm(true, edit, some-uid) updates all three keys', async () => { + const { useCalendarStore } = await import('../store/calendarStore.js') + useCalendarStore.getState().setEventForm(true, 'edit', 'some-uid') + const state = useCalendarStore.getState() + + expect(state.eventFormOpen).toBe(true) + expect(state.eventFormMode).toBe('edit') + expect(state.eventFormUid).toBe('some-uid') + }) +}) +``` + +## Test Setup + +**Backend (Node environment):** +- `vitest.config.ts` specifies `environment: 'node'` with `globals: true` +- No test-setup file needed (Node has built-in globals) +- Modules imported via `await import(...)` to enable per-test mocking + +**Frontend (jsdom environment):** +- `vitest.config.ts` specifies `environment: 'jsdom'` with `globals: true` and `setupFiles: ['./src/test-setup.ts']` +- `test-setup.ts` polyfills `window.matchMedia` (jsdom doesn't implement CSSOM MediaQueryList) +- `test-setup.ts` extends `expect` with `jest-dom` matchers +- Timezone pinned to UTC via `env: { TZ: 'UTC' }` for deterministic date tests (WR-05) + +**Example (from `apps/pwa/vitest.config.ts`):** +```typescript +export default defineConfig({ + test: { + environment: 'jsdom', + globals: true, + setupFiles: ['./src/test-setup.ts'], + env: { TZ: 'UTC' }, + }, +}) +``` + +**Example (from `apps/pwa/src/test-setup.ts`):** +```typescript +import '@testing-library/jest-dom' + +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: (query: string) => ({ + matches: false, + media: query, + // ... other MediaQueryList methods + }), +}) +``` + +## Known Testing Gaps + +**Backend Route Handlers:** +- GET /api/events, POST /api/events/create, PATCH /api/events/:uid/edit, DELETE /api/events/:uid — no route tests yet (in scope for Phase 5 / Plan 05) +- GET /api/events/writable-calendars, GET /api/events/sync-status — no route tests +- SSE route (`/api/sse`) — not tested +- Auth flow tests (dev-bypass, OIDC session) partially covered; integration tests with Authelia not applicable + +**Frontend Components:** +- EventForm, DeleteConfirmationDialog, CalendarShell — no component tests yet +- SSE event listener integration (real-time list updates) — not tested + +**Integration:** +- Full end-to-end flow (login → fetch events → create event → poll sync-status) — not covered +- Database transaction rollback on error — not explicitly tested + +--- + +*Testing analysis: 2026-06-09*