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