# 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_
