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:
@@ -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_
|
||||
|
||||
Reference in New Issue
Block a user