style(13-03): apply Prettier formatting across repo
Mechanical reformat — no logic changes. 398 files changed, 19125 insertions(+), 16457 deletions(-). Prettier 3.8.4 with .prettierrc (singleQuote:true, semi:true, tabWidth:2, trailingComma:all, printWidth:100). Isolated per D-13-08 for reviewability.
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
<!-- refreshed: 2026-06-09 -->
|
||||
|
||||
# Architecture
|
||||
|
||||
**Analysis Date:** 2026-06-09
|
||||
@@ -50,53 +51,56 @@
|
||||
└─ Fastmail CalDAV + app passwords ────────────────┘
|
||||
(tsdav client, encrypted credentials)
|
||||
(PROPFIND, REPORT, PUT, DELETE)
|
||||
|
||||
|
||||
MariaDB (persistent cache)
|
||||
(read on every request, written by broker)
|
||||
```
|
||||
|
||||
## Component Responsibilities
|
||||
|
||||
| Component | Responsibility | File |
|
||||
|-----------|----------------|------|
|
||||
| **CalendarShell** | React entry point; wires TanStack Query + Schedule-X + Zustand + event modals | `apps/pwa/src/components/CalendarShell.tsx` |
|
||||
| **AppNav** | Navigation header/sidebar; responsive (phone/tablet layout) | `apps/pwa/src/components/AppNav.tsx` |
|
||||
| **EventDetailPopover** | Displays event details; driven by Zustand.openEventId | `apps/pwa/src/components/EventDetailPopover.tsx` |
|
||||
| **EventForm** | Create/edit event modal; builds payloads for POST/PATCH endpoints | `apps/pwa/src/components/EventForm.tsx` |
|
||||
| **SyncStateToast** | Toast polling `/api/events/sync-status?uid=` for optimistic-accept feedback | `apps/pwa/src/components/SyncStateToast.tsx` |
|
||||
| **Zustand calendarStore** | UI-only state: selectedView, openEventId, eventFormOpen, calendarRange, deleteDialog state | `apps/pwa/src/store/calendarStore.ts` |
|
||||
| **TanStack Query hooks** | Server state: fetchMe (user profile), fetchEvents (windowed occurrences), fetchSyncStatus | `apps/pwa/src/api/client.ts` + CalendarShell.tsx |
|
||||
| **hydrateEvents** | Transforms raw CalendarOccurrence[] to Schedule-X CalendarType[] with Temporal dates | `apps/pwa/src/lib/hydrateEvents.ts` |
|
||||
| **buildCalendarConfig** | Builds Schedule-X calendar configuration (views, plugins, columns) | `apps/pwa/src/lib/calendarConfig.ts` |
|
||||
| **OIDC middleware** | Protects /api/* routes; 302-redirects unauthenticated requests to Authelia | `apps/api/src/auth/middleware.ts` |
|
||||
| **devAuthBypass** | Dev-only passthrough auth for local development without Authelia | `apps/api/src/auth/devBypass.ts` |
|
||||
| **upsertUser** | Identity upsert by (oidc_iss, oidc_sub); auto-assigns color from palette | `apps/api/src/auth/user.ts` |
|
||||
| **eventsRouter** | GET /api/events (windowed reads), POST/PATCH/DELETE (enqueue outbox), GET /writable-calendars | `apps/api/src/routes/events.ts` |
|
||||
| **meRouter** | GET /api/me — returns authenticated user profile (id, displayName, color) | `apps/api/src/routes/me.ts` |
|
||||
| **sseRouter** | GET /api/sse/heartbeat — SSE smoke test for Pangolin proxy validation | `apps/api/src/routes/sse.ts` |
|
||||
| **CalDAV Broker** | Decrypts credentials, manages tsdav clients, syncs calendars, drains outbox | `apps/api/src/broker/*.ts` |
|
||||
| **Poller** | 5-min background job: PROPFIND → ctag comparison → skip or syncCalendar | `apps/api/src/broker/poller.ts` |
|
||||
| **syncCalendar** | REPORT → ical.js parse → VEVENT upsert; prunes deleted events | `apps/api/src/broker/sync.ts` |
|
||||
| **OutboxWorker** | 15-sec background job: drain pending outbox rows, build VEVENTs, PUT/DELETE to Fastmail | `apps/api/src/broker/outboxWorker.ts` |
|
||||
| **expandOccurrences** | Server-side RRULE expansion via ical.js; produces concrete occurrences for UI | `apps/api/src/broker/expand.ts` |
|
||||
| Component | Responsibility | File |
|
||||
| ------------------------- | --------------------------------------------------------------------------------------------- | ------------------------------------------------ |
|
||||
| **CalendarShell** | React entry point; wires TanStack Query + Schedule-X + Zustand + event modals | `apps/pwa/src/components/CalendarShell.tsx` |
|
||||
| **AppNav** | Navigation header/sidebar; responsive (phone/tablet layout) | `apps/pwa/src/components/AppNav.tsx` |
|
||||
| **EventDetailPopover** | Displays event details; driven by Zustand.openEventId | `apps/pwa/src/components/EventDetailPopover.tsx` |
|
||||
| **EventForm** | Create/edit event modal; builds payloads for POST/PATCH endpoints | `apps/pwa/src/components/EventForm.tsx` |
|
||||
| **SyncStateToast** | Toast polling `/api/events/sync-status?uid=` for optimistic-accept feedback | `apps/pwa/src/components/SyncStateToast.tsx` |
|
||||
| **Zustand calendarStore** | UI-only state: selectedView, openEventId, eventFormOpen, calendarRange, deleteDialog state | `apps/pwa/src/store/calendarStore.ts` |
|
||||
| **TanStack Query hooks** | Server state: fetchMe (user profile), fetchEvents (windowed occurrences), fetchSyncStatus | `apps/pwa/src/api/client.ts` + CalendarShell.tsx |
|
||||
| **hydrateEvents** | Transforms raw CalendarOccurrence[] to Schedule-X CalendarType[] with Temporal dates | `apps/pwa/src/lib/hydrateEvents.ts` |
|
||||
| **buildCalendarConfig** | Builds Schedule-X calendar configuration (views, plugins, columns) | `apps/pwa/src/lib/calendarConfig.ts` |
|
||||
| **OIDC middleware** | Protects /api/\* routes; 302-redirects unauthenticated requests to Authelia | `apps/api/src/auth/middleware.ts` |
|
||||
| **devAuthBypass** | Dev-only passthrough auth for local development without Authelia | `apps/api/src/auth/devBypass.ts` |
|
||||
| **upsertUser** | Identity upsert by (oidc_iss, oidc_sub); auto-assigns color from palette | `apps/api/src/auth/user.ts` |
|
||||
| **eventsRouter** | GET /api/events (windowed reads), POST/PATCH/DELETE (enqueue outbox), GET /writable-calendars | `apps/api/src/routes/events.ts` |
|
||||
| **meRouter** | GET /api/me — returns authenticated user profile (id, displayName, color) | `apps/api/src/routes/me.ts` |
|
||||
| **sseRouter** | GET /api/sse/heartbeat — SSE smoke test for Pangolin proxy validation | `apps/api/src/routes/sse.ts` |
|
||||
| **CalDAV Broker** | Decrypts credentials, manages tsdav clients, syncs calendars, drains outbox | `apps/api/src/broker/*.ts` |
|
||||
| **Poller** | 5-min background job: PROPFIND → ctag comparison → skip or syncCalendar | `apps/api/src/broker/poller.ts` |
|
||||
| **syncCalendar** | REPORT → ical.js parse → VEVENT upsert; prunes deleted events | `apps/api/src/broker/sync.ts` |
|
||||
| **OutboxWorker** | 15-sec background job: drain pending outbox rows, build VEVENTs, PUT/DELETE to Fastmail | `apps/api/src/broker/outboxWorker.ts` |
|
||||
| **expandOccurrences** | Server-side RRULE expansion via ical.js; produces concrete occurrences for UI | `apps/api/src/broker/expand.ts` |
|
||||
|
||||
## Pattern Overview
|
||||
|
||||
**Overall:** Three-tier monolith (PWA frontend, Node.js backend, MariaDB), split between `apps/pwa` (React) and `apps/api` (Hono + broker).
|
||||
**Overall:** Three-tier monolith (PWA frontend, Node.js backend, MariaDB), split between `apps/pwa` (React) and `apps/api` (Hono + broker).
|
||||
|
||||
**Request-response pattern:**
|
||||
|
||||
- Frontend reads from MariaDB cache via REST endpoints (GET only)
|
||||
- Frontend enqueues writes to transactional outbox (POST/PATCH/DELETE return 202 immediately)
|
||||
- Background broker drains outbox, calls Fastmail CalDAV, updates cache
|
||||
- Real-time updates via SSE (Phase 4) and/or polling (SyncStateToast for write feedback)
|
||||
|
||||
**Data ownership pattern:**
|
||||
|
||||
- Poller owns calendar collection discovery + change detection (D-13 ctag polling)
|
||||
- syncCalendar owns per-calendar event cache (REPORT → parse → upsert)
|
||||
- OutboxWorker owns write-back to Fastmail (D-05 transactional outbox)
|
||||
- Routes own read authorization and ownership checks (T-03-06..T-03-11)
|
||||
|
||||
**Key Characteristics:**
|
||||
|
||||
- Events endpoint shares MariaDB cache — no direct Fastmail I/O from routes (T-03-02 broker boundary)
|
||||
- Write operations use optimistic-accept pattern: 202 + immediate UI response, success confirmed via polling
|
||||
- All server state in TanStack Query; UI state only in Zustand (clear separation)
|
||||
@@ -107,6 +111,7 @@
|
||||
## Layers
|
||||
|
||||
**Presentation (React PWA):**
|
||||
|
||||
- Purpose: Display calendar, handle user interactions, manage UI state (view selection, modals, popovers)
|
||||
- Location: `apps/pwa/src/`
|
||||
- Contains: Components (CalendarShell, EventForm, EventDetailPopover, AppNav, SyncStateToast), UI hooks (CalendarShell's useQuery for data, Zustand for view state)
|
||||
@@ -114,6 +119,7 @@
|
||||
- Used by: Browser tab (Vite dev proxy or production Pangolin tunnel)
|
||||
|
||||
**API / Route Layer:**
|
||||
|
||||
- Purpose: Validate requests, enforce authorization (T-03-06..T-03-11), read from cache, enqueue writes
|
||||
- Location: `apps/api/src/routes/`
|
||||
- Contains: Route handlers (events.ts, me.ts, health.ts, sse.ts); Zod schemas for input validation
|
||||
@@ -122,6 +128,7 @@
|
||||
- Architecture invariant: Routes **never** import tsdav or call Fastmail directly (T-03-02)
|
||||
|
||||
**Database / ORM Layer:**
|
||||
|
||||
- Purpose: Type-safe query building, schema definition, migrations
|
||||
- Location: `apps/api/src/db/`
|
||||
- Contains: Drizzle schema (users, member_credentials, calendars, calendar_events, calendar_outbox), mysql2 client
|
||||
@@ -129,6 +136,7 @@
|
||||
- Used by: All route handlers, broker modules
|
||||
|
||||
**Broker / Background Worker Layer:**
|
||||
|
||||
- Purpose: Keep MariaDB calendar cache in sync with Fastmail; drain transactional outbox
|
||||
- Location: `apps/api/src/broker/`
|
||||
- Contains: Poller (5-min cron), syncCalendar (REPORT parse), OutboxWorker (15-sec drain), supporting utilities
|
||||
@@ -137,6 +145,7 @@
|
||||
- Data sources: member_credentials (encrypted), calendars, calendar_events (cache), calendar_outbox (pending writes)
|
||||
|
||||
**Auth / Session Layer:**
|
||||
|
||||
- Purpose: OIDC authentication via Authelia, user identity upsert, session cookies
|
||||
- Location: `apps/api/src/auth/`
|
||||
- Contains: Middleware (oidcAuthMiddleware, processOAuthCallback from @hono/oidc-auth), upsertUser color assignment, dev bypass
|
||||
@@ -159,6 +168,7 @@
|
||||
8. **Schedule-X render** — eventsService.set() updates calendar model; re-render with color routing (isShared ? 'shared' : String(ownerUserId))
|
||||
|
||||
**State Management:**
|
||||
|
||||
- TanStack Query caches result with key ['events', start, end]; staleTime 5 min
|
||||
- Zustand calendarRange (start/end) drives query key → navigation re-fetches
|
||||
- SyncStateToast polls `/api/events/sync-status?uid=` to show write-back progress
|
||||
@@ -200,6 +210,7 @@
|
||||
- Prune deletes: DELETE events whose uid is no longer on server (BUG B: scope by (userId, url) for shared account)
|
||||
|
||||
**Ownership Model (D-03, D-16):**
|
||||
|
||||
- Shared Fastmail account: both members' credentials fetch the same calendar collections
|
||||
- Stored as (userId, url) composite unique key so each member caches the same calendar separately
|
||||
- eventsRouter ownership check: calendar.userId = currentUserId OR isShared=true (writable set)
|
||||
@@ -208,26 +219,31 @@
|
||||
## Key Abstractions
|
||||
|
||||
**CalendarOccurrence:**
|
||||
|
||||
- Purpose: Single concrete event occurrence ready for UI (expanded from RRULE if needed)
|
||||
- Examples: `apps/api/src/broker/expand.ts:CalendarOccurrence`, `apps/pwa/src/api/client.ts:CalendarOccurrence`
|
||||
- Pattern: Backend expands RRULE into N occurrences; each has stable id = `${uid}::${dtstart_iso}`, allowing Schedule-X dedup and Zustand.openEventId routing
|
||||
|
||||
**Transactional Outbox (D-05):**
|
||||
|
||||
- Purpose: Decouple client request (202 response) from Fastmail write (async worker)
|
||||
- Examples: `apps/api/src/db/schema.ts:calendarOutbox`
|
||||
- Pattern: Write endpoint INSERTs pending row; worker POLLs and drains; status machine (pending → done/failed/dead) controls retry + backoff
|
||||
|
||||
**Wrapped Schema Contract (D-13):**
|
||||
|
||||
- Purpose: Guarantee correct DATE vs TIMESTAMP storage for all-day vs timed events
|
||||
- Examples: `apps/api/src/db/schema.ts` (dtstartUtc, dtstartDate, allDay); `apps/api/src/broker/sync.ts` (storage logic); `apps/api/src/routes/events.ts` (window predicate)
|
||||
- Pattern: All-day events NEVER coerce to midnight-UTC (Pitfall 2); timed events always UTC; query pre-filters both branches
|
||||
|
||||
**RRULE Expansion (D-09):**
|
||||
|
||||
- Purpose: Expand recurring masters server-side so client receives concrete occurrences only
|
||||
- Examples: `apps/api/src/broker/expand.ts:expandOccurrences`, `apps/pwa/src/lib/hydrateEvents.ts` (no expansion on client)
|
||||
- Pattern: Route calls expandOccurrences for each cached VEVENT; ical.js handles RRULE parsing, EXDATE exclusion, VTIMEZONE DST adjustment
|
||||
|
||||
**Encrypted Credentials:**
|
||||
|
||||
- Purpose: Store Fastmail app passwords at rest without exposing plaintext
|
||||
- Examples: `apps/api/src/db/schema.ts:memberCredentials.encryptedPassword`, `apps/api/src/broker/crypto.ts:decryptPassword`
|
||||
- Pattern: AES-256-GCM with per-message nonce; stored as JSON { iv, authTag, ciphertext }; decrypted only immediately before tsdav client creation (T-03-04)
|
||||
@@ -235,36 +251,43 @@
|
||||
## Entry Points
|
||||
|
||||
**Browser → PWA:**
|
||||
|
||||
- Location: `apps/pwa/src/main.tsx` (Vite SPA entry), `apps/pwa/src/App.tsx` (root component = CalendarShell)
|
||||
- Triggers: User navigates to / (domain root) or clicks Home
|
||||
- Responsibilities: Hydrate React app, mount CalendarShell, wire TanStack Query + Zustand
|
||||
|
||||
**PWA → API:**
|
||||
|
||||
- Location: `apps/pwa/src/api/client.ts` (fetch functions)
|
||||
- Triggers: CalendarShell useQuery hooks on mount and navigation
|
||||
- Responsibilities: Fetch events, me profile, sync status; handle OIDC redirects via maybeRedirectToLogin
|
||||
|
||||
**Unauthenticated User → OIDC:**
|
||||
|
||||
- Location: `apps/api/src/auth/middleware.ts` (oidcAuthMiddleware)
|
||||
- Triggers: Unauthenticated fetch to /api/* endpoint
|
||||
- Triggers: Unauthenticated fetch to /api/\* endpoint
|
||||
- Responsibilities: 302-redirect to Authelia /authorize; await callback at /callback; set session JWT cookie
|
||||
|
||||
**OIDC Callback → API Login:**
|
||||
|
||||
- Location: `apps/api/src/index.ts:app.get('/callback')` and `apps/api/src/auth/middleware.ts:processOAuthCallback`
|
||||
- Triggers: Authelia POST to /callback after authorization-code exchange
|
||||
- Responsibilities: Exchange code for token, validate nonce, set JWT cookie with refresh token, redirect to /api/login
|
||||
|
||||
**API Login → SPA Boot:**
|
||||
|
||||
- Location: `apps/api/src/index.ts:app.get('/api/login')`
|
||||
- Triggers: Top-level navigation after callback redirects here (or direct /api/login hit by PWA)
|
||||
- Responsibilities: Verify session cookie valid, 302-redirect to / so SPA boots authenticated
|
||||
|
||||
**Background Poller:**
|
||||
|
||||
- Location: `apps/api/src/broker/poller.ts:startBrokerPoller`, called from `apps/api/src/index.ts` in isMainModule() guard
|
||||
- Triggers: 5-min node-cron schedule starting at API boot
|
||||
- Responsibilities: Load all credentials, PROPFIND calendars, compare ctag, call syncCalendar if changed
|
||||
|
||||
**Outbox Worker:**
|
||||
|
||||
- Location: `apps/api/src/broker/outboxWorker.ts:startOutboxWorker`, called from `apps/api/src/index.ts` in isMainModule() guard
|
||||
- Triggers: 15-sec node-cron schedule starting at API boot
|
||||
- Responsibilities: Poll outbox WHERE status='pending', drain to Fastmail via write.ts, update status, trigger refetch
|
||||
@@ -319,4 +342,4 @@
|
||||
|
||||
---
|
||||
|
||||
*Architecture analysis: 2026-06-09*
|
||||
_Architecture analysis: 2026-06-09_
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
## 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.
|
||||
@@ -12,6 +13,7 @@
|
||||
- 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.
|
||||
@@ -19,12 +21,14 @@
|
||||
- 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.
|
||||
@@ -35,12 +39,14 @@
|
||||
## 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.
|
||||
@@ -51,18 +57,21 @@
|
||||
## 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.
|
||||
@@ -73,18 +82,21 @@
|
||||
## 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.
|
||||
@@ -95,16 +107,19 @@
|
||||
## 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.
|
||||
@@ -114,16 +129,19 @@
|
||||
## 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).
|
||||
@@ -133,16 +151,19 @@
|
||||
## 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.
|
||||
@@ -152,12 +173,14 @@
|
||||
## 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.
|
||||
@@ -168,30 +191,35 @@
|
||||
## 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).
|
||||
@@ -202,16 +230,19 @@
|
||||
## 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.
|
||||
@@ -221,12 +252,14 @@
|
||||
## 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).
|
||||
@@ -238,16 +271,19 @@
|
||||
## 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.
|
||||
@@ -255,4 +291,4 @@
|
||||
|
||||
---
|
||||
|
||||
*Concerns audit: 2026-06-09*
|
||||
_Concerns audit: 2026-06-09_
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
## 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/`)
|
||||
@@ -12,12 +13,14 @@
|
||||
- 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()`
|
||||
@@ -26,6 +29,7 @@
|
||||
- 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'`
|
||||
@@ -33,6 +37,7 @@
|
||||
- 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
|
||||
@@ -44,6 +49,7 @@
|
||||
## 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"`)
|
||||
@@ -51,21 +57,23 @@
|
||||
- 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<number | null> {
|
||||
const devUser = c.get('user') as { id: number } | undefined
|
||||
if (devUser) return devUser.id
|
||||
const devUser = c.get('user') as { id: number } | undefined;
|
||||
if (devUser) return devUser.id;
|
||||
|
||||
const auth = await getAuth(c)
|
||||
if (!auth) return null
|
||||
const auth = await getAuth(c);
|
||||
if (!auth) return null;
|
||||
|
||||
const iss = (auth.iss as string | undefined) ?? ''
|
||||
const sub = auth.sub ?? ''
|
||||
const iss = (auth.iss as string | undefined) ?? '';
|
||||
const sub = auth.sub ?? '';
|
||||
// ...
|
||||
}
|
||||
```
|
||||
@@ -73,6 +81,7 @@ async function resolveUserId(c: Context): Promise<number | null> {
|
||||
## 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)
|
||||
@@ -80,10 +89,12 @@ async function resolveUserId(c: Context): Promise<number | null> {
|
||||
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
|
||||
@@ -105,6 +116,7 @@ import '../auth/devBypass.js' // Side-effect import (last)
|
||||
**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
|
||||
@@ -112,6 +124,7 @@ import '../auth/devBypass.js' // Side-effect import (last)
|
||||
- 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)
|
||||
@@ -136,28 +149,30 @@ eventsRouter.get('/', zValidator('query', eventsQuerySchema), async (c) => {
|
||||
```
|
||||
|
||||
**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<MeResponse> {
|
||||
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')
|
||||
throw new Error('GET /api/me: authentication required');
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`GET /api/me failed: ${res.status}`)
|
||||
throw new Error(`GET /api/me failed: ${res.status}`);
|
||||
}
|
||||
|
||||
return res.json() as Promise<MeResponse>
|
||||
return res.json() as Promise<MeResponse>;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -166,6 +181,7 @@ export async function fetchMe(): Promise<MeResponse> {
|
||||
**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 ...')`
|
||||
@@ -173,19 +189,21 @@ export async function fetchMe(): Promise<MeResponse> {
|
||||
- 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.')
|
||||
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}`)
|
||||
})
|
||||
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)
|
||||
@@ -193,11 +211,13 @@ serve({ fetch: app.fetch, port: 3000 }, (info) => {
|
||||
- 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.
|
||||
@@ -217,16 +237,19 @@ export const COLOR_PALETTE: string[] = [...]
|
||||
**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<T>` or `Promise<void>`
|
||||
- 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,
|
||||
@@ -253,22 +276,26 @@ export async function upsertUser(
|
||||
## 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'
|
||||
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
|
||||
@@ -276,12 +303,14 @@ export { oidcAuthMiddleware, processOAuthCallback, getAuth } from '@hono/oidc-au
|
||||
- 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',
|
||||
@@ -298,24 +327,27 @@ export const calendarEvents = mysqlTable(
|
||||
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.
|
||||
@@ -327,4 +359,4 @@ export const calendarEvents = mysqlTable(
|
||||
|
||||
---
|
||||
|
||||
*Convention analysis: 2026-06-09*
|
||||
_Convention analysis: 2026-06-09_
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
## 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)
|
||||
@@ -14,6 +15,7 @@
|
||||
- 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)
|
||||
@@ -27,6 +29,7 @@
|
||||
## 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)
|
||||
@@ -37,12 +40,14 @@
|
||||
- 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
|
||||
@@ -51,6 +56,7 @@
|
||||
## 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)
|
||||
@@ -59,6 +65,7 @@
|
||||
- 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
|
||||
@@ -67,14 +74,17 @@
|
||||
## 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
|
||||
@@ -83,15 +93,18 @@
|
||||
## 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
|
||||
@@ -100,6 +113,7 @@
|
||||
## 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)
|
||||
@@ -109,18 +123,21 @@
|
||||
- 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
|
||||
@@ -128,6 +145,7 @@
|
||||
- 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
|
||||
@@ -135,12 +153,14 @@
|
||||
## 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
|
||||
@@ -148,9 +168,10 @@
|
||||
- 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*
|
||||
_Integration audit: 2026-06-09_
|
||||
|
||||
@@ -5,20 +5,24 @@
|
||||
## 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
|
||||
@@ -26,16 +30,19 @@
|
||||
## 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
|
||||
@@ -44,45 +51,55 @@
|
||||
- @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
|
||||
@@ -91,15 +108,18 @@
|
||||
## 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)
|
||||
|
||||
- `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
|
||||
@@ -110,6 +130,7 @@
|
||||
## Platform Requirements
|
||||
|
||||
**Development:**
|
||||
|
||||
- Node.js 22 LTS
|
||||
- pnpm 11.5.1
|
||||
- Docker + Docker Compose (for local MariaDB + Redis)
|
||||
@@ -118,6 +139,7 @@
|
||||
- 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
|
||||
@@ -127,4 +149,4 @@
|
||||
|
||||
---
|
||||
|
||||
*Stack analysis: 2026-06-09*
|
||||
_Stack analysis: 2026-06-09_
|
||||
|
||||
@@ -93,12 +93,14 @@ familysync/
|
||||
## 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
|
||||
@@ -107,6 +109,7 @@ familysync/
|
||||
- **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
|
||||
@@ -114,103 +117,104 @@ familysync/
|
||||
- **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 |
|
||||
|------|---------|
|
||||
| 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 |
|
||||
| `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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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()` |
|
||||
| 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` |
|
||||
| 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` |
|
||||
| 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 |
|
||||
| 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:**
|
||||
|
||||
@@ -241,6 +245,7 @@ familysync/
|
||||
## 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)
|
||||
@@ -248,26 +253,30 @@ familysync/
|
||||
- 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*
|
||||
_Structure analysis: 2026-06-09_
|
||||
|
||||
+113
-67
@@ -5,16 +5,19 @@
|
||||
## 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
|
||||
@@ -30,14 +33,17 @@ 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
|
||||
@@ -76,39 +82,41 @@ apps/pwa/src/
|
||||
## Test Structure
|
||||
|
||||
**Suite Organization:**
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
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')
|
||||
|
||||
const { app } = await import('../src/index.js');
|
||||
|
||||
// Act
|
||||
const res = await app.request('/health')
|
||||
|
||||
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)
|
||||
})
|
||||
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'))
|
||||
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')
|
||||
const { app } = await import('../src/index.js');
|
||||
const res = await app.request('/health');
|
||||
|
||||
// Assert
|
||||
expect(res.status).toBe(503)
|
||||
})
|
||||
})
|
||||
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()`
|
||||
@@ -120,25 +128,27 @@ describe('GET /health', () => {
|
||||
**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)
|
||||
const mockFetch = vi.mocked(fetch);
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ uid: 'test-uid' }),
|
||||
} as Response)
|
||||
} as Response);
|
||||
|
||||
// Call the function under test
|
||||
await createEvent(payload)
|
||||
await createEvent(payload);
|
||||
|
||||
// Assert the mock was called correctly
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
@@ -147,27 +157,30 @@ expect(mockFetch).toHaveBeenCalledWith(
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
}),
|
||||
)
|
||||
);
|
||||
```
|
||||
|
||||
**Global Stubs (Frontend):**
|
||||
|
||||
```typescript
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('fetch', vi.fn())
|
||||
})
|
||||
vi.stubGlobal('fetch', vi.fn());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
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
|
||||
@@ -181,7 +194,7 @@ 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')
|
||||
const rawVevent = readFileSync(join(FIXTURES, 'weekly-dst.ics'), 'utf8');
|
||||
|
||||
// Use in test
|
||||
const occurrences = expandOccurrences(
|
||||
@@ -194,7 +207,7 @@ const occurrences = expandOccurrences(
|
||||
'Alice',
|
||||
'#4A90D9',
|
||||
false,
|
||||
)
|
||||
);
|
||||
```
|
||||
|
||||
**Test Data (Frontend):**
|
||||
@@ -205,14 +218,25 @@ 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 },
|
||||
{
|
||||
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)
|
||||
} 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.)
|
||||
|
||||
@@ -221,10 +245,12 @@ vi.mocked(fetch).mockResolvedValueOnce({
|
||||
**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
|
||||
@@ -233,38 +259,43 @@ 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)
|
||||
} as Response);
|
||||
|
||||
const { createEvent } = await import('./client.js')
|
||||
const result = await createEvent(payload)
|
||||
const { createEvent } = await import('./client.js');
|
||||
const result = await createEvent(payload);
|
||||
|
||||
expect(result).toEqual({ uid: 'returned-uid' })
|
||||
})
|
||||
expect(result).toEqual({ uid: 'returned-uid' });
|
||||
});
|
||||
```
|
||||
|
||||
**Error Testing:**
|
||||
|
||||
```typescript
|
||||
it('throws on non-ok response', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
@@ -274,7 +305,7 @@ it('throws on non-ok response', async () => {
|
||||
} as Response)
|
||||
|
||||
const { createEvent } = await import('./client.js')
|
||||
|
||||
|
||||
await expect(
|
||||
createEvent({ title: '', ... })
|
||||
).rejects.toThrow()
|
||||
@@ -282,78 +313,89 @@ it('throws on non-ok response', async () => {
|
||||
```
|
||||
|
||||
**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 { 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')
|
||||
const { app } = await import('../src/index.js');
|
||||
const res = await app.request('/health');
|
||||
|
||||
expect(res.status).toBe(503)
|
||||
})
|
||||
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 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,
|
||||
)
|
||||
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]')
|
||||
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'))
|
||||
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
|
||||
})
|
||||
})
|
||||
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()
|
||||
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')
|
||||
})
|
||||
})
|
||||
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: {
|
||||
@@ -362,12 +404,13 @@ export default defineConfig({
|
||||
setupFiles: ['./src/test-setup.ts'],
|
||||
env: { TZ: 'UTC' },
|
||||
},
|
||||
})
|
||||
});
|
||||
```
|
||||
|
||||
**Example (from `apps/pwa/src/test-setup.ts`):**
|
||||
|
||||
```typescript
|
||||
import '@testing-library/jest-dom'
|
||||
import '@testing-library/jest-dom';
|
||||
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
@@ -376,25 +419,28 @@ Object.defineProperty(window, 'matchMedia', {
|
||||
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*
|
||||
_Testing analysis: 2026-06-09_
|
||||
|
||||
Reference in New Issue
Block a user