Files
familysync/.planning/research/ARCHITECTURE.md
T

584 lines
35 KiB
Markdown

# Architecture Research
**Domain:** FamilySync v1.1 — integration analysis for Operability & Polish milestone
**Researched:** 2026-06-10
**Confidence:** HIGH (grounded in actual codebase)
## Standard Architecture
### System Overview
```
┌──────────────────────────────────────────────────────────────────┐
│ React PWA (apps/pwa/src/) │
│ ┌──────────────┐ ┌──────────────┐ ┌───────────────────────────┐ │
│ │ EventForm.tsx│ │SettingsSheet │ │ [NEW] SetupWizard / │ │
│ │ + reminder │ │ + Admin tab │ │ AdminSettings │ │
│ │ selector │ │ │ │ │ │
│ └──────┬───────┘ └──────┬───────┘ └────────────┬──────────────┘ │
│ │ api/client.ts (typed fetch wrappers) │ │
└─────────┼──────────────────────────────────────┬─┴───────────────┘
│ │
▼ HTTP / SSE ▼ HTTP
┌──────────────────────────────────────────────────────────────────┐
│ Hono API (apps/api/src/index.ts) │
│ ┌──────────────┐ ┌────────────┐ ┌──────────────────────────────┐│
│ │ routes/ │ │ routes/ │ │ [NEW] routes/admin.ts + ││
│ │ events.ts │ │ push.ts │ │ routes/setup.ts ││
│ │ (enqueue to │ │ │ │ (role-gated credential mgmt, ││
│ │ outbox) │ │ │ │ first-run wizard endpoints) ││
│ └──────┬───────┘ └────────────┘ └──────────────────────────────┘│
│ │ │
│ ┌──────▼──────────────────────────────────────────────────────┐ │
│ │ broker/ │ │
│ │ outboxWorker.ts (15s setInterval + NEW event-driven drain) │ │
│ │ reminderScheduler.ts (1-min setInterval, MODIFIED: per- │ │
│ │ event VALARM lead, variable window) │ │
│ │ poller.ts (5-min setInterval, UNCHANGED) │ │
│ │ vevent.ts [MODIFIED: buildVeventString adds VALARM] │ │
│ │ sync.ts [MODIFIED: extract VALARM -> reminder_lead_minutes] │ │
│ │ crypto.ts (AES-256-GCM, REUSED by admin credential writes) │ │
│ └──────┬──────────────────────────────────────────────────────┘ │
└─────────┼────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────┐
│ Data layer (apps/api/src/db/) │
│ schema.ts: users (+is_admin), calendars, calendarEvents │
│ (+reminder_lead_minutes), calendarOutbox, │
│ memberCredentials, pushSubscriptions, lists, ... │
│ [NEW] app_config table (setup_complete flag, etc.) │
│ │
│ MariaDB (mariadb:11) + Redis (7-alpine, ioredis for pub/sub) │
└──────────────────────────────────────────────────────────────────┘
```
### Component Responsibilities
| Component | File | Responsibility | v1.1 Status |
|-----------|------|----------------|-------------|
| Event form | `apps/pwa/src/components/EventForm.tsx` | Create/edit event UI | MODIFY: add reminder selector |
| Settings sheet | `apps/pwa/src/components/SettingsSheet.tsx` | Notifications toggle | MODIFY: add Admin section |
| API client | `apps/pwa/src/api/client.ts` | Typed fetch wrappers | MODIFY: admin + setup endpoints |
| Events route | `apps/api/src/routes/events.ts` | Calendar CRUD, outbox enqueue | MODIFY: pass reminder in payload, signal drain |
| Push route | `apps/api/src/routes/push.ts` | VAPID subscription management | UNCHANGED |
| VEVENT builder | `apps/api/src/broker/vevent.ts` | iCalendar string construction | MODIFY: add VALARM |
| CalDAV sync | `apps/api/src/broker/sync.ts` | Fastmail REPORT -> DB upsert | MODIFY: extract VALARM trigger |
| Outbox worker | `apps/api/src/broker/outboxWorker.ts` | CalDAV write-back drain | MODIFY: event-driven trigger subscription |
| Reminder scheduler | `apps/api/src/broker/reminderScheduler.ts` | Push reminders for events | MODIFY: variable VALARM-based lead |
| Poller | `apps/api/src/broker/poller.ts` | 5-min CalDAV sync | UNCHANGED |
| Crypto | `apps/api/src/broker/crypto.ts` | AES-256-GCM encrypt/decrypt | UNCHANGED (reused by admin) |
| DB schema | `apps/api/src/db/schema.ts` | Drizzle table definitions | MODIFY: is_admin, reminder_lead_minutes, app_config |
| Index / wiring | `apps/api/src/index.ts` | App bootstrap + worker startup | MODIFY: mount admin + setup routes |
| [NEW] Admin route | `apps/api/src/routes/admin.ts` | Role-gated credential + calendar mgmt | NEW |
| [NEW] Setup route | `apps/api/src/routes/setup.ts` | First-run wizard endpoints + validation | NEW |
| [NEW] Admin UI | `apps/pwa/src/components/AdminSettings.tsx` | Member credential UI, shared-cal picker | NEW |
| [NEW] Setup wizard | `apps/pwa/src/components/SetupWizard.tsx` | First-run guided bootstrap | NEW |
| [NEW] Outbox trigger | `apps/api/src/lib/outboxTrigger.ts` | In-process EventEmitter for drain signal | NEW |
| [NEW] CI workflow | `.gitea/workflows/ci.yml` | Lint/typecheck/test on PR | NEW |
---
## Feature Integration Analysis
### (a) Per-Event Reminders: VALARM Authoring + Variable-Lead Scheduling
#### Write path — what changes
**`apps/pwa/src/components/EventForm.tsx`** — MODIFY
Add a "Reminder" `<select>` field. Values: `none | 5 | 10 | 15 | 30 | 60` (minutes before). Default `none`. Show in both create and edit modes. On submit, `executeSubmit()` includes `reminderMinutes?: number` in `CreateEventPayload`.
The existing WR-01 "omit recurrence on edit" pattern does NOT apply here — reminder lead is user-configured per save and should always be sent explicitly. Include `reminderMinutes: 0` to mean "remove VALARM".
The reminder selector should be disabled or hidden when `allDay = true` (RFC 5545: duration-form TRIGGER is semantically invalid for all-day events; see Anti-Pattern 4 below).
**`apps/pwa/src/api/client.ts`** — MODIFY
Extend `CreateEventPayload` type:
```typescript
reminderMinutes?: number // 0 = no alarm; positive = minutes before
```
**`apps/api/src/routes/events.ts`** — MODIFY
Extend `eventFieldsSchema` (line 100) and `outboxPayloadSchema` (line 71 in `outboxWorker.ts`):
```typescript
reminderMinutes: z.number().int().min(0).max(10080).optional() // max = 1 week
```
The payload is JSON-stringified into `calendar_outbox.payload` (TEXT column). No outbox schema change needed.
**`apps/api/src/broker/vevent.ts`** — MODIFY
Add `reminderMinutes?: number` to `NewEventParams` interface (line 21). In `buildVeventString`, after the RRULE block, add:
```typescript
if (!params.allDay && params.reminderMinutes !== undefined && params.reminderMinutes > 0) {
const valarm = new ICAL.Component('valarm')
valarm.addPropertyWithValue('action', 'DISPLAY')
valarm.addPropertyWithValue('description', params.summary)
const trigger = ICAL.Duration.fromSeconds(-(params.reminderMinutes * 60))
const triggerProp = new ICAL.Property('trigger')
triggerProp.setValue(trigger)
valarm.addProperty(triggerProp)
vevent.addSubcomponent(valarm)
}
```
`ICAL.Duration.fromSeconds` is the correct ical.js API — same pattern as how `ICAL.Recur.fromString` is used for RRULE on line 144. Negative duration = before the event.
**`apps/api/src/broker/outboxWorker.ts`** — MODIFY
In both the `create` branch and `update` branch, pass `reminderMinutes: fields.reminderMinutes` to `buildVeventString`. One-line addition per branch. Also add `reminderMinutes` to `outboxPayloadSchema` for IN-03 re-validation.
#### Scheduler path — what changes
**`apps/api/src/db/schema.ts`** — MODIFY
Add to `calendarEvents`:
```typescript
reminderLeadMinutes: int('reminder_lead_minutes'),
// nullable: NULL = no VALARM; positive int = minutes before dtstart
```
Add `drizzle-kit generate` migration. This is a nullable column addition — safe online DDL on MariaDB 11/InnoDB.
**`apps/api/src/broker/sync.ts`** — MODIFY
When upserting a `calendarEvent`, extract VALARM from the parsed VEVENT:
```typescript
const valarm = vevent.getFirstSubcomponent('valarm')
let reminderLeadMinutes: number | null = null
if (valarm) {
const trigger = valarm.getFirstPropertyValue('trigger')
if (trigger instanceof ICAL.Duration) {
reminderLeadMinutes = Math.abs(trigger.toSeconds()) / 60
}
}
```
Store `reminderLeadMinutes` in the DB upsert. This is the ground truth — the scheduler reads from DB, not from the outbox payload.
**`apps/api/src/broker/reminderScheduler.ts`** — MODIFY (significant rewrite of `runReminderCheck`)
Current behavior:
- Fixed 16-minute catch-up window: `dtstartUtc IN (now, now+16min]`
- Fires only for `calendars.isShared = true` (D-05 constraint)
- Dedup key: bare `uid` in `sentReminders: Map<string, number>`
New behavior:
- Variable lead: query events where the alarm-fire-time falls in the current tick's window
- Alarm-fire-time = `dtstartUtc - INTERVAL reminder_lead_minutes MINUTE`
- Window: `(now - 1min, now + 1min]` gives a 2-minute catch-up on missed ticks (scheduler runs every 60s)
- Events with `reminder_lead_minutes IS NULL` are excluded — no VALARM means no reminder
Drizzle raw SQL expression for the window predicate:
```typescript
sql`${calendarEvents.reminderLeadMinutes} IS NOT NULL
AND DATE_SUB(${calendarEvents.dtstartUtc}, INTERVAL ${calendarEvents.reminderLeadMinutes} MINUTE) > ${new Date(now.getTime() - 60_000)}
AND DATE_SUB(${calendarEvents.dtstartUtc}, INTERVAL ${calendarEvents.reminderLeadMinutes} MINUTE) <= ${new Date(now.getTime() + 60_000)}`
```
`isShared` restriction (D-05): this is a scope decision. D-05 was originally "shared calendar only" for the fixed-15-min reminder. With per-event VALARM, the user explicitly configured a reminder — it should fire regardless of which calendar the event is on. Recommend removing the `isShared` restriction in v1.1 for reminder-triggered notifications. Event-change notifications (via `eventChangeDispatcher.ts`) are a separate flow and unaffected.
**Catch-up guarantee is preserved.** The existing 16-min window provided catch-up by keeping missed events visible for 16 minutes. With a 2-minute window (±1min around now) and 1-min ticks, a single missed tick means the next tick has the event in window. A 2-minute outage means at most one missed reminder — acceptable for household use.
**Dedup stays the same.** `sentReminders: Map<uid, dtstartMs>` keyed by bare uid. This correctly prevents double-fire when the event lingers in the window across ticks. No change needed.
---
### (b) Event-Driven Outbox Drain
#### Current state
`startOutboxWorker()` in `outboxWorker.ts` (line 781) runs `runOutboxDrain()` every 15 seconds. All write routes in `routes/events.ts` insert to `calendar_outbox` and return 202 with no drain trigger.
#### Recommended approach: in-process EventEmitter
New file `apps/api/src/lib/outboxTrigger.ts` — follows the exact pattern of `listEmitter.ts` (proven, same single-process deployment):
```typescript
// apps/api/src/lib/outboxTrigger.ts
import { EventEmitter } from 'node:events'
const emitter = new EventEmitter()
export function signalOutboxDrain(): void {
emitter.emit('drain')
}
export function onOutboxDrainSignal(handler: () => void): () => void {
emitter.on('drain', handler)
return () => emitter.off('drain', handler)
}
```
**Why not Redis pub/sub:** The outbox drain is single-process by design — the `isDraining` guard in `outboxWorker.ts` (line 156) explicitly documents "SINGLE-PROCESS LIMITATION". Redis pub/sub adds a network hop and operational overhead for zero benefit. Redis is already used for SSE list fan-out — a different problem (multi-browser-tab fan-out).
**`apps/api/src/routes/events.ts`** — MODIFY
After each `db.insert(calendarOutbox)` in the three write handlers (create, edit/same-calendar, edit/move transaction), add:
```typescript
signalOutboxDrain()
```
Import from `'../lib/outboxTrigger.js'`. Fire-and-forget; no await.
**`apps/api/src/broker/outboxWorker.ts`** — MODIFY
In `startOutboxWorker()`:
```typescript
export function startOutboxWorker(): void {
// Event-driven: drain immediately after any enqueue signal
onOutboxDrainSignal(() => {
runOutboxDrain().catch((err: unknown) => {
console.error('[outboxWorker] Signal-triggered drain error:', err)
})
})
// Fallback: 15s periodic drain for recovery + startup catch-up
setInterval(() => {
runOutboxDrain().catch((err: unknown) => {
console.error('[outboxWorker] Interval drain error:', err)
})
}, 15 * 1000)
}
```
**All durability guarantees are preserved.** The `isDraining` concurrency guard handles overlapping invocations from both the signal and the interval. When a signal-triggered drain is running (`isDraining = true`), any concurrent call (signal or interval) is a no-op. Create-before-delete ordering (D-04), etag re-read (WR-02), 412 conflict flow (D-08), transient backoff (D-07) — all unchanged. The trigger path only affects *when* `runOutboxDrain()` is called.
**Rapid-successive-edits behavior:** Each `signalOutboxDrain()` call may trigger a drain that processes row N while row N+1 hasn't been inserted yet. Row N+1's `nextAttemptAt = defaultNow()`, so it appears in the next drain cycle's `pending + next_attempt_at <= NOW()` query. The 15s fallback interval ensures it is drained within 15 seconds at most.
---
### (c) Admin Role + Setup Wizard Config Storage
#### Admin role flag
**Where:** `users` table in `apps/api/src/db/schema.ts`. Add:
```typescript
isAdmin: boolean('is_admin').default(false).notNull(),
```
**Why DB not env:** Admin designation is per-user, mutable, and tied to identity. Env vars cannot express "user X is admin." The first user to complete the setup wizard is auto-promoted. Subsequent changes go through an admin route action.
**Migration:** `ALTER TABLE users ADD COLUMN is_admin BOOLEAN NOT NULL DEFAULT 0` — safe online DDL on MariaDB 11/InnoDB.
**Route guard:** `requireAdmin` middleware in `apps/api/src/routes/admin.ts` calls `resolveUserId` then checks `users.isAdmin`. Applied to all `/api/admin/*` routes.
**`GET /api/me` extension:** Add `isAdmin: boolean` to the response. The PWA uses this to conditionally show the Admin section in `SettingsSheet`. The `['me']` TanStack Query is already called on app load — no new query needed.
#### Setup wizard config
The wizard bootstraps four categories of state:
| Config | Current Home | v1.1 Approach |
|--------|-------------|----------------|
| VAPID keypair | `.env` (VAPID_PUBLIC_KEY, _PRIVATE_KEY, _SUBJECT) | Stays in env; wizard validates presence + offers generate-and-display |
| AES-256 key | `.env` (APP_PASSWORD_ENCRYPTION_KEY) | Stays in env; wizard validates format (64-char hex) |
| OIDC credentials | `.env` (OIDC_ISSUER, _CLIENT_ID, _SECRET, etc.) | Stays in env; wizard validates OIDC discovery reachability |
| App passwords + shared-cal | DB (member_credentials, calendars.is_shared) | Same DB; wizard is the UI for what was previously a manual DB write |
**New `app_config` table** for app-level flags:
```typescript
export const appConfig = mysqlTable('app_config', {
key: varchar('key', { length: 128 }).primaryKey(),
value: text('value').notNull(),
updatedAt: timestamp('updated_at').defaultNow().onUpdateNow(),
})
```
Initial keys: `setup_complete` (`'0'` or `'1'`), `setup_step` (resumable flow state).
**Why the wizard cannot write `.env`:** Env vars are set at Docker container start via Docker Compose. A running container's process cannot mutate its own env and have those mutations persist across restarts. The correct flow: wizard detects missing env vars, provides a "Generate" button that returns a keypair for the operator to copy, then re-validates after the operator restarts the container with updated env. Alternatively, the wizard writes to a `/app/.env.local` file that the Node.js process reads via dotenv — but this requires the container to have write access to its own image layer or a mounted volume. The simplest Unraid approach: wizard shows values to copy, operator adds to Docker Compose env, restarts.
**`apps/api/src/routes/setup.ts`** — NEW
Endpoints (all except `/status` are admin-only after first-run completes):
- `GET /api/setup/status`**unauthenticated** (mounted before OIDC guard in `index.ts`, like `/health`); returns `{ complete: boolean }`
- `GET /api/setup/env-status` — returns which env vars are present/missing (no values)
- `POST /api/setup/generate-vapid` — calls `webpush.generateVAPIDKeys()`, returns `{ publicKey, privateKey, subject }` for operator to copy; does NOT store in DB
- `GET /api/setup/validate-oidc` — pings `${OIDC_ISSUER}/.well-known/openid-configuration`, returns `{ ok: boolean, error?: string }`
- `POST /api/setup/complete` — sets `app_config.setup_complete = '1'`; sets `users.is_admin = true` for the calling user (promotes wizard-completing user to admin)
**First-run detection in PWA:** `App.tsx` adds a setup-status check before rendering the calendar shell. If `complete = false` and the user is authenticated (or the wizard must be completeable pre-auth), render `<SetupWizard>`. Simplest flow: require authentication first (Authelia protects everything), then the wizard runs as an authenticated user. The `GET /api/setup/status` route being pre-OIDC lets the PWA detect setup state before the OIDC redirect fires — render a "Setup required" splash instead of bouncing the user around.
#### Shared surface: wizard vs admin Settings
`POST /api/admin/credentials` is called by both the setup wizard (step: first app password) and the admin Settings page (ongoing credential management). Build this route once. The wizard in Phase D is a pure frontend consumer of the same admin API surface.
`PATCH /api/admin/calendars/:id/shared` is called by both the setup wizard (step: designate shared calendar) and admin Settings. Same pattern.
**Do not create `/api/setup/credentials` or `/api/setup/calendar-shared`** — these would duplicate `/api/admin/credentials` and `/api/admin/calendars/:id/shared`.
**Deferred: self-service provider onboarding (999.5).** This is a member-initiated flow where each member enters their own app password. The admin credential route is admin-only and takes a `userId` parameter. Self-service onboarding is a separate concern — do not conflate with admin settings in v1.1.
---
### (d) New vs Modified Components — Dependency-Ordered Build Sequence
#### Dependency graph
```
DB schema migration (users.is_admin, calendarEvents.reminder_lead_minutes, app_config)
├─── Admin role + routes (admin.ts) ─────────────────────────┐
│ │ │
│ routes/me.ts (extend response with isAdmin) │
│ │ │
│ AdminSettings.tsx + SettingsSheet.tsx mod │
│ │ │
│ SetupWizard.tsx (reuses admin routes) ◄───┘
│ routes/setup.ts (wizard backend)
│ App.tsx (setup-status gate)
├─── Faster write-back ─────────────────────────────────────
│ lib/outboxTrigger.ts (new)
│ outboxWorker.ts (subscribe to signal)
│ routes/events.ts (call signalOutboxDrain)
│ [no PWA changes]
├─── VALARM write path ──────────────────────────────────────┐
│ broker/vevent.ts (add VALARM to buildVeventString) │
│ outboxWorker.ts (pass reminderMinutes) │
│ routes/events.ts (extend schemas) │
│ EventForm.tsx (reminder selector) │
│ api/client.ts (extend CreateEventPayload) │
│ broker/sync.ts (extract VALARM -> DB) ◄───┘
│ reminderScheduler.ts (variable-lead query)
├─── Gitea CI (.gitea/workflows/ci.yml)
│ [no code dependencies; parallel]
└─── Mobile test harness
[no new backend code; playwright-cli + DEV_AUTH_BYPASS]
```
#### Recommended build order
**Phase A — DB foundation (unblocks everything)**
1. `apps/api/src/db/schema.ts` — add `users.isAdmin`, `calendarEvents.reminderLeadMinutes`, `appConfig` table
2. `drizzle-kit generate` + apply migration to dev DB
3. `apps/api/src/lib/outboxTrigger.ts` — new file, zero dependencies
**Phase B — Faster write-back (low-risk, isolated)**
Prerequisite: `outboxTrigger.ts` from Phase A
4. `apps/api/src/broker/outboxWorker.ts` — subscribe to drain signal in `startOutboxWorker`
5. `apps/api/src/routes/events.ts` — add `signalOutboxDrain()` after each outbox insert (3 call sites)
No PWA changes. Tests: extend `tests/broker/outboxWorker.test.ts`.
**Phase C — Admin role + routes**
6. `apps/api/src/routes/admin.ts` — new file:
- `GET /api/admin/members` — list users with credential status (isAdmin flag determines who sees it)
- `POST /api/admin/credentials` — upsert `memberCredentials` for a given `userId`; reuses `encryptPassword` from `broker/crypto.ts`
- `DELETE /api/admin/credentials/:userId` — remove credential
- `PATCH /api/admin/calendars/:id/shared` — toggle `calendars.isShared`
- All routes: `requireAdmin` middleware
7. `apps/api/src/routes/me.ts` — extend response with `isAdmin: boolean`
8. `apps/api/src/index.ts` — mount `adminRouter` at `/api/admin`
**Phase D — Setup wizard**
Prerequisite: Phase C admin routes
9. `apps/api/src/routes/setup.ts` — new file (wizard backend endpoints)
10. `apps/api/src/index.ts` — mount `setupRouter` at `/api/setup`; mount `GET /api/setup/status` BEFORE the OIDC guard
11. `apps/pwa/src/components/SetupWizard.tsx` — multi-step first-run UI
12. `apps/pwa/src/App.tsx` — setup-status check gate
**Phase E — Admin Settings UI**
Prerequisite: Phase C admin routes + Phase C `isAdmin` in `/api/me`
13. `apps/pwa/src/components/AdminSettings.tsx` — member list, "Set app password" flow, shared-calendar picker
14. `apps/pwa/src/components/SettingsSheet.tsx` — add "Admin" section (shown only when `currentUser.isAdmin`)
15. `apps/pwa/src/api/client.ts` — add admin API fetch wrappers
**Phase F — VALARM authoring (event form + scheduler)**
Prerequisite: Phase A (schema has `reminderLeadMinutes`)
16. `apps/api/src/broker/vevent.ts` — add VALARM to `buildVeventString`; extend `NewEventParams`
17. `apps/api/src/broker/outboxWorker.ts` — pass `reminderMinutes` in create + update branches; add to `outboxPayloadSchema`
18. `apps/api/src/routes/events.ts` — add `reminderMinutes` to `eventFieldsSchema`
19. `apps/api/src/broker/sync.ts` — extract VALARM trigger; store `reminderLeadMinutes` on upsert
20. `apps/pwa/src/components/EventForm.tsx` — add reminder selector
21. `apps/pwa/src/api/client.ts` — extend `CreateEventPayload` type
Then, once sync.ts change is deployed and populating `reminder_lead_minutes`:
22. `apps/api/src/broker/reminderScheduler.ts` — rewrite `runReminderCheck` to use variable lead from `reminder_lead_minutes`; update `isShared` scope per feature decision
**Phase G — Gitea CI (independent, parallel)**
23. `.gitea/workflows/ci.yml` — lint/typecheck/unit/API-integration on PR; MariaDB service container; secrets for `DB_HOST=127.0.0.1`, DB creds, test env vars. Tests live in `apps/api/tests/` — confirm test runner path in CI config.
**Phase H — Mobile test harness (independent)**
24. Playwright-cli mobile viewport configuration + authenticated entry via `DEV_AUTH_BYPASS=true`; no backend changes required.
---
## Component Boundaries and Key Integration Notes
### Shared surface: wizard credential entry vs admin Settings
`POST /api/admin/credentials` is called by both the setup wizard (first app password) and the admin Settings page (ongoing management). Build once in Phase C. Do not duplicate into a `/api/setup/credentials` endpoint.
### VALARM round-trip: write path vs read path
These are independent flows that rendezvous through Fastmail:
- **Write:** `reminderMinutes` travels as a number in the outbox JSON payload → `buildVeventString` emits VALARM TRIGGER:-PT{n}M → tsdav PUT to Fastmail
- **Read:** After the event syncs back from Fastmail (via targeted re-sync in `outboxWorker.ts` or 5-min poll), `sync.ts` parses the VALARM TRIGGER and stores `reminderLeadMinutes` in `calendar_events`. The scheduler reads this column — never the outbox payload.
This means `reminder_lead_minutes` in `calendar_events` is the ground truth. Events created before v1.1 (or by third-party clients) that have no VALARM will have `reminderLeadMinutes = NULL` and the scheduler ignores them.
### `outboxPayloadSchema` vs `eventFieldsSchema` — must stay in sync
`eventFieldsSchema` in `routes/events.ts` (line 100) and `outboxPayloadSchema` in `outboxWorker.ts` (line 71) are intentionally duplicated for defense-in-depth (IN-03). Adding `reminderMinutes` requires updating both. This is documented in the existing IN-03 comment — flag it explicitly when writing the phase plan.
### `isAdmin` in API responses
`GET /api/me` currently returns `{ id, displayName, color }`. Extend to include `isAdmin: boolean`. The PWA uses this to conditionally show the Admin section in SettingsSheet. The `['me']` TanStack Query is already called on app load — no new query, just extend the existing response and the `MeUser` type in `api/client.ts`.
### First-run detection: mount `/api/setup/status` before OIDC guard
In `apps/api/src/index.ts`, `/health` is already mounted before the OIDC guard (line 40). Mount `GET /api/setup/status` with the same pattern:
```typescript
app.get('/api/setup/status', setupStatusHandler) // before oidcAuthMiddleware
```
This lets the PWA detect first-run state before the OIDC redirect fires and render a setup splash instead of an auth bounce loop.
---
## Data Flow Diagrams
### Per-Event Reminder: Create to Notification
```
EventForm (reminder: 15min)
↓ POST /api/events/create { reminderMinutes: 15, ... }
routes/events.ts (schema validates, inserts outbox row, signalOutboxDrain())
↓ [event-driven, ~0s]
outboxWorker.ts runOutboxDrain()
→ buildVeventString({ reminderMinutes: 15 })
→ vevent.ts emits VCALENDAR with VALARM TRIGGER:-PT15M
→ tsdav PUT to Fastmail (.ics)
→ triggerTargetedResync() → sync.ts parses VALARM → stores reminder_lead_minutes=15
↓ (next 1-min scheduler tick)
reminderScheduler.ts
→ SQL WHERE DATE_SUB(dtstart_utc, INTERVAL reminder_lead_minutes MINUTE) IN tick-window
→ dispatchPush() to all subscribers
Browser notification: "Starts in 15 min"
```
### Admin: Set Member App Password
```
AdminSettings.tsx (admin enters app password for member X)
↓ POST /api/admin/credentials { userId: X, email: '...', password: '...' }
routes/admin.ts (requireAdmin guard → resolveUserId → check users.isAdmin)
→ zod validate input
→ encryptPassword() from broker/crypto.ts (AES-256-GCM)
→ db.insert/onDuplicateKeyUpdate on memberCredentials
↓ 200 OK
AdminSettings.tsx invalidates ['admin/members'] query
poller.ts (next 5-min tick): picks up new credential, discovers calendars for that user
```
### Setup Wizard: First-Run Bootstrap
```
App.tsx (on load)
↓ GET /api/setup/status [UNAUTHENTICATED, pre-OIDC]
← { complete: false }
→ render <SetupWizard> (after OIDC login)
Step 1: env-var check → GET /api/setup/env-status
Step 2: DB/OIDC validation → GET /api/setup/validate-oidc
Step 3: VAPID keypair → POST /api/setup/generate-vapid (operator copies to .env, restarts)
Step 4: first app password → POST /api/admin/credentials
Step 5: shared calendar → PATCH /api/admin/calendars/:id/shared
Step 6: POST /api/setup/complete → app_config.setup_complete='1', user.is_admin=true
→ redirect to /
App.tsx (on reload): GET /api/setup/status → { complete: true } → render CalendarShell
```
---
## Anti-Patterns to Avoid
### Anti-Pattern 1: Separate wizard endpoints that duplicate admin routes
The wizard is a frontend flow over the same admin API. Do not create `/api/setup/credentials` alongside `/api/admin/credentials`. One route, two callers (wizard + Settings page).
### Anti-Pattern 2: Storing env-var secrets in the DB
VAPID private keys and `APP_PASSWORD_ENCRYPTION_KEY` must stay in env vars — they are the bootstrap secrets that protect everything else. The wizard generates and displays them for the operator to copy; it never writes them to the `app_config` table or any other DB table.
### Anti-Pattern 3: Reintroducing node-cron
The codebase uses `setInterval` throughout because node-cron 4.2.1 silently skips ticks in the long-running process (documented in every worker file's header comment). Do not use `node-cron` for any new scheduled work in v1.1. The event-driven drain replaces the need for a tighter interval; the 15s `setInterval` fallback is already correct.
### Anti-Pattern 4: Adding VALARM to all-day events
RFC 5545 duration-form TRIGGER (`-PT15M`) is semantically valid only for timed events. For all-day events it requires `RELATED=END` or a DATE-form trigger. Fastmail may accept it regardless, but the semantics are wrong and would yield confusing reminder times. Guard in `buildVeventString`: `if (!params.allDay && params.reminderMinutes > 0)`. Disable the reminder selector in `EventForm.tsx` when `allDay = true`.
### Anti-Pattern 5: Dedup key includes lead time in reminderScheduler
The existing `sentReminders: Map<uid, dtstartMs>` keyed by bare uid is correct and must not be changed to `uid:leadMinutes`. A keyed `uid:lead` would cause double-fire if the event is rescheduled (new dtstart, same uid) or if the lead changes. The bare uid key + dtstart-based pruning is the correct exactly-once guarantee — leave it intact.
### Anti-Pattern 6: Concurrency guard changes in outboxWorker
The `isDraining` module-level boolean guard is the correct concurrency mechanism for the single-process deployment. Do not add a second guard, change it to an async mutex, or introduce Redis-based locking for the drain. The event-driven signal adds a new call site but does not require any guard changes — the existing guard already handles simultaneous invocations.
---
## Integration Points
### External Services
| Service | Integration | v1.1 Notes |
|---------|-------------|------------|
| Fastmail CalDAV | tsdav PUT with VCALENDAR containing VALARM | VALARM is inert to Fastmail's CalDAV server (stored as-is); sync reads it back |
| Authelia OIDC | No change | Setup wizard is post-OIDC; admin routes use existing OIDC session |
### Internal Module Boundaries
| Boundary | Communication | Notes |
|----------|---------------|-------|
| `routes/events.ts``outboxWorker.ts` | `signalOutboxDrain()` via EventEmitter | NEW: fire-and-forget, one-way |
| `routes/admin.ts``broker/crypto.ts` | Direct import of `encryptPassword` | REUSE existing module — no changes to crypto.ts |
| `routes/setup.ts``db/schema.ts` appConfig | Drizzle queries | NEW table, same db client and connection pool |
| `broker/vevent.ts``ical.js` | `ICAL.Component('valarm')`, `ICAL.Duration.fromSeconds` | EXTEND existing ical.js usage |
| `broker/sync.ts``db/schema.ts` | Store `reminderLeadMinutes` in events upsert | MODIFY existing upsert path in syncCalendar |
| `reminderScheduler.ts``db/schema.ts` | Read `reminder_lead_minutes` in Drizzle query | MODIFY existing query; raw SQL expression |
---
## Sources
All findings are grounded in direct codebase inspection:
- `apps/api/src/broker/outboxWorker.ts` — drain loop, `isDraining` guard, durability decisions (D-04/D-07/D-08/CR-05)
- `apps/api/src/broker/reminderScheduler.ts` — scheduler logic, dedup map, fixed-window rationale (D-05/D-06/D-12)
- `apps/api/src/broker/vevent.ts``buildVeventString`, `NewEventParams`, ical.js RRULE pattern (VALARM follows same API)
- `apps/api/src/broker/crypto.ts``encryptPassword`/`decryptPassword`; confirmed reusable by admin routes
- `apps/api/src/db/schema.ts` — table definitions; migration impact analysis
- `apps/api/src/routes/events.ts``eventFieldsSchema`, `outboxPayloadSchema` contract, IN-03 duplication requirement
- `apps/api/src/lib/listEmitter.ts` — in-process EventEmitter precedent for `outboxTrigger.ts`
- `apps/pwa/src/components/EventForm.tsx` — full form state machine; integration points for reminder selector
- `apps/pwa/src/components/SettingsSheet.tsx` — existing Settings surface; Admin section attachment point
- `apps/api/src/index.ts` — route mounting order, OIDC guard placement, `isMainModule` guard pattern
- `apps/api/src/routes/me.ts``/api/me` response shape; where `isAdmin` is added
- `apps/api/tests/` — test directory layout confirms tests in `tests/`, not `src/`; `helpers/db.ts` pattern
---
*Architecture research for: FamilySync v1.1 Operability & Polish*
*Researched: 2026-06-10*