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.
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
uidinsentReminders: 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 NULLare 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.
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):
// 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/status— unauthenticated (mounted before OIDC guard inindex.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— callswebpush.generateVAPIDKeys(), returns{ publicKey, privateKey, subject }for operator to copy; does NOT store in DBGET /api/setup/validate-oidc— pings${OIDC_ISSUER}/.well-known/openid-configuration, returns{ ok: boolean, error?: string }POST /api/setup/complete— setsapp_config.setup_complete = '1'; setsusers.is_admin = truefor 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)
apps/api/src/db/schema.ts— addusers.isAdmin,calendarEvents.reminderLeadMinutes,appConfigtabledrizzle-kit generate+ apply migration to dev DBapps/api/src/lib/outboxTrigger.ts— new file, zero dependencies
Phase B — Faster write-back (low-risk, isolated)
Prerequisite: outboxTrigger.ts from Phase A
apps/api/src/broker/outboxWorker.ts— subscribe to drain signal instartOutboxWorkerapps/api/src/routes/events.ts— addsignalOutboxDrain()after each outbox insert (3 call sites)
No PWA changes. Tests: extend tests/broker/outboxWorker.test.ts.
Phase C — Admin role + routes
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— upsertmemberCredentialsfor a givenuserId; reusesencryptPasswordfrombroker/crypto.tsDELETE /api/admin/credentials/:userId— remove credentialPATCH /api/admin/calendars/:id/shared— togglecalendars.isShared- All routes:
requireAdminmiddleware
apps/api/src/routes/me.ts— extend response withisAdmin: booleanapps/api/src/index.ts— mountadminRouterat/api/admin
Phase D — Setup wizard
Prerequisite: Phase C admin routes
apps/api/src/routes/setup.ts— new file (wizard backend endpoints)apps/api/src/index.ts— mountsetupRouterat/api/setup; mountGET /api/setup/statusBEFORE the OIDC guardapps/pwa/src/components/SetupWizard.tsx— multi-step first-run UIapps/pwa/src/App.tsx— setup-status check gate
Phase E — Admin Settings UI
Prerequisite: Phase C admin routes + Phase C isAdmin in /api/me
apps/pwa/src/components/AdminSettings.tsx— member list, "Set app password" flow, shared-calendar pickerapps/pwa/src/components/SettingsSheet.tsx— add "Admin" section (shown only whencurrentUser.isAdmin)apps/pwa/src/api/client.ts— add admin API fetch wrappers
Phase F — VALARM authoring (event form + scheduler)
Prerequisite: Phase A (schema has reminderLeadMinutes)
apps/api/src/broker/vevent.ts— add VALARM tobuildVeventString; extendNewEventParamsapps/api/src/broker/outboxWorker.ts— passreminderMinutesin create + update branches; add tooutboxPayloadSchemaapps/api/src/routes/events.ts— addreminderMinutestoeventFieldsSchemaapps/api/src/broker/sync.ts— extract VALARM trigger; storereminderLeadMinuteson upsertapps/pwa/src/components/EventForm.tsx— add reminder selectorapps/pwa/src/api/client.ts— extendCreateEventPayloadtype
Then, once sync.ts change is deployed and populating reminder_lead_minutes:
apps/api/src/broker/reminderScheduler.ts— rewriterunReminderCheckto use variable lead fromreminder_lead_minutes; updateisSharedscope per feature decision
Phase G — Gitea CI (independent, parallel)
.gitea/workflows/ci.yml— lint/typecheck/unit/API-integration on PR; MariaDB service container; secrets forDB_HOST=127.0.0.1, DB creds, test env vars. Tests live inapps/api/tests/— confirm test runner path in CI config.
Phase H — Mobile test harness (independent)
- 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:
reminderMinutestravels as a number in the outbox JSON payload →buildVeventStringemits VALARM TRIGGER:-PT{n}M → tsdav PUT to Fastmail - Read: After the event syncs back from Fastmail (via targeted re-sync in
outboxWorker.tsor 5-min poll),sync.tsparses the VALARM TRIGGER and storesreminderLeadMinutesincalendar_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.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,isDrainingguard, 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 routesapps/api/src/db/schema.ts— table definitions; migration impact analysisapps/api/src/routes/events.ts—eventFieldsSchema,outboxPayloadSchemacontract, IN-03 duplication requirementapps/api/src/lib/listEmitter.ts— in-process EventEmitter precedent foroutboxTrigger.tsapps/pwa/src/components/EventForm.tsx— full form state machine; integration points for reminder selectorapps/pwa/src/components/SettingsSheet.tsx— existing Settings surface; Admin section attachment pointapps/api/src/index.ts— route mounting order, OIDC guard placement,isMainModuleguard patternapps/api/src/routes/me.ts—/api/meresponse shape; whereisAdminis addedapps/api/tests/— test directory layout confirms tests intests/, notsrc/;helpers/db.tspattern
Architecture research for: FamilySync v1.1 Operability & Polish Researched: 2026-06-10