style(13-03): apply Prettier formatting across repo

Mechanical reformat — no logic changes. 398 files changed, 19125
insertions(+), 16457 deletions(-). Prettier 3.8.4 with .prettierrc
(singleQuote:true, semi:true, tabWidth:2, trailingComma:all,
printWidth:100). Isolated per D-13-08 for reviewability.
This commit is contained in:
Lucas Berger
2026-06-11 20:35:18 -04:00
parent 4bc0445173
commit 982438dc10
398 changed files with 19050 additions and 16382 deletions
+84 -69
View File
@@ -55,27 +55,27 @@
### 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 |
| 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 |
---
@@ -96,6 +96,7 @@ The reminder selector should be disabled or hidden when `allDay = true` (RFC 554
**`apps/pwa/src/api/client.ts`** — MODIFY
Extend `CreateEventPayload` type:
```typescript
reminderMinutes?: number // 0 = no alarm; positive = minutes before
```
@@ -103,8 +104,9 @@ 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
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.
@@ -115,14 +117,14 @@ Add `reminderMinutes?: number` to `NewEventParams` interface (line 21). In `buil
```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)
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);
}
```
@@ -137,6 +139,7 @@ In both the `create` branch and `update` branch, pass `reminderMinutes: fields.r
**`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
@@ -147,13 +150,14 @@ Add `drizzle-kit generate` migration. This is a nullable column addition — saf
**`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
const valarm = vevent.getFirstSubcomponent('valarm');
let reminderLeadMinutes: number | null = null;
if (valarm) {
const trigger = valarm.getFirstPropertyValue('trigger')
const trigger = valarm.getFirstPropertyValue('trigger');
if (trigger instanceof ICAL.Duration) {
reminderLeadMinutes = Math.abs(trigger.toSeconds()) / 60
reminderLeadMinutes = Math.abs(trigger.toSeconds()) / 60;
}
}
```
@@ -163,21 +167,24 @@ Store `reminderLeadMinutes` in the DB upsert. This is the ground truth — the s
**`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)}`
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.
@@ -200,16 +207,16 @@ New file `apps/api/src/lib/outboxTrigger.ts` — follows the exact pattern of `l
```typescript
// apps/api/src/lib/outboxTrigger.ts
import { EventEmitter } from 'node:events'
const emitter = new EventEmitter()
import { EventEmitter } from 'node:events';
const emitter = new EventEmitter();
export function signalOutboxDrain(): void {
emitter.emit('drain')
emitter.emit('drain');
}
export function onOutboxDrainSignal(handler: () => void): () => void {
emitter.on('drain', handler)
return () => emitter.off('drain', handler)
emitter.on('drain', handler);
return () => emitter.off('drain', handler);
}
```
@@ -218,8 +225,9 @@ export function onOutboxDrainSignal(handler: () => void): () => void {
**`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()
signalOutboxDrain();
```
Import from `'../lib/outboxTrigger.js'`. Fire-and-forget; no await.
@@ -227,24 +235,25 @@ 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)
})
})
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)
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.
**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.
@@ -255,6 +264,7 @@ export function startOutboxWorker(): void {
#### Admin role flag
**Where:** `users` table in `apps/api/src/db/schema.ts`. Add:
```typescript
isAdmin: boolean('is_admin').default(false).notNull(),
```
@@ -271,20 +281,21 @@ isAdmin: boolean('is_admin').default(false).notNull(),
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 |
| 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).
@@ -294,6 +305,7 @@ Initial keys: `setup_complete` (`'0'` or `'1'`), `setup_step` (resumable flow st
**`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
@@ -449,9 +461,11 @@ This means `reminder_lead_minutes` in `calendar_events` is the ground truth. Eve
### 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
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.
---
@@ -543,21 +557,21 @@ The `isDraining` module-level boolean guard is the correct concurrency mechanism
### External Services
| Service | Integration | v1.1 Notes |
|---------|-------------|------------|
| 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 |
| 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 |
| 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 |
---
@@ -579,5 +593,6 @@ All findings are grounded in direct codebase inspection:
- `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*
_Architecture research for: FamilySync v1.1 Operability & Polish_
_Researched: 2026-06-10_