Files
Lucas Berger 982438dc10 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.
2026-06-11 20:35:18 -04:00

37 KiB

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:

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):

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:

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:

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:

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:

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.

New file apps/api/src/lib/outboxTrigger.ts — follows the exact pattern of listEmitter.ts (proven, same single-process deployment):

// 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:

signalOutboxDrain();

Import from '../lib/outboxTrigger.js'. Fire-and-forget; no await.

apps/api/src/broker/outboxWorker.ts — MODIFY

In startOutboxWorker():

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:

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:

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/statusunauthenticated (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]

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

  1. apps/api/src/broker/outboxWorker.ts — subscribe to drain signal in startOutboxWorker
  2. 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

  1. 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
  2. apps/api/src/routes/me.ts — extend response with isAdmin: boolean
  3. apps/api/src/index.ts — mount adminRouter at /api/admin

Phase D — Setup wizard

Prerequisite: Phase C admin routes

  1. apps/api/src/routes/setup.ts — new file (wizard backend endpoints)
  2. apps/api/src/index.ts — mount setupRouter at /api/setup; mount GET /api/setup/status BEFORE the OIDC guard
  3. apps/pwa/src/components/SetupWizard.tsx — multi-step first-run UI
  4. 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

  1. apps/pwa/src/components/AdminSettings.tsx — member list, "Set app password" flow, shared-calendar picker
  2. apps/pwa/src/components/SettingsSheet.tsx — add "Admin" section (shown only when currentUser.isAdmin)
  3. apps/pwa/src/api/client.ts — add admin API fetch wrappers

Phase F — VALARM authoring (event form + scheduler)

Prerequisite: Phase A (schema has reminderLeadMinutes)

  1. apps/api/src/broker/vevent.ts — add VALARM to buildVeventString; extend NewEventParams
  2. apps/api/src/broker/outboxWorker.ts — pass reminderMinutes in create + update branches; add to outboxPayloadSchema
  3. apps/api/src/routes/events.ts — add reminderMinutes to eventFieldsSchema
  4. apps/api/src/broker/sync.ts — extract VALARM trigger; store reminderLeadMinutes on upsert
  5. apps/pwa/src/components/EventForm.tsx — add reminder selector
  6. apps/pwa/src/api/client.ts — extend CreateEventPayload type

Then, once sync.ts change is deployed and populating reminder_lead_minutes:

  1. 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)

  1. .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)

  1. 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:

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.tsoutboxWorker.ts signalOutboxDrain() via EventEmitter NEW: fire-and-forget, one-way
routes/admin.tsbroker/crypto.ts Direct import of encryptPassword REUSE existing module — no changes to crypto.ts
routes/setup.tsdb/schema.ts appConfig Drizzle queries NEW table, same db client and connection pool
broker/vevent.tsical.js ICAL.Component('valarm'), ICAL.Duration.fromSeconds EXTEND existing ical.js usage
broker/sync.tsdb/schema.ts Store reminderLeadMinutes in events upsert MODIFY existing upsert path in syncCalendar
reminderScheduler.tsdb/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.tsbuildVeventString, NewEventParams, ical.js RRULE pattern (VALARM follows same API)
  • apps/api/src/broker/crypto.tsencryptPassword/decryptPassword; confirmed reusable by admin routes
  • apps/api/src/db/schema.ts — table definitions; migration impact analysis
  • apps/api/src/routes/events.tseventFieldsSchema, 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