SPB Git

Repository Public

ARCHITECTURE.md 9.0 KB

# SP Calendar — architecture

Self-hosted appointment booking for one owner: a public booking site and a private admin area, on the MacLustr cluster behind the MacLustr Tunnel (https://calendar.spboucher.ai).

# Audit of the environment (2026-11-11)

What was actually inspected before writing code:

Item Finding
DNS calendar.spboucher.ai A 51.161.112.61 (BHS64, tunnel gateway) already in place; no route yet (TLS error until mld deploy).
Deployment mld on M1M32: manifests in ~/dispatch/apps/<app>.json, PM2 processes, Caddy route on BHS64, health check. Port 8660 free in every manifest.
Sibling apps read clip-spboucher-ai (Next 16, postgres, SQL migrations, tsx worker, PM2 manifest), sp-wallet (WebAuthn with @simplewebauthn/server 14.1.0, design system docs/v9/DESIGN.md), sp-drive (same WebAuthn library), chat-spboucher-ai (memory notes only).
Runtimes Nodes: Node 26, pnpm 12, PostgreSQL 17, PM2. Temporal is native in Node 26 (used server-side only).
E-mail Other apps use Resend; a verified sending domain mail.spboucher.ai appears in a retired app's manifest. No SMTP server on the cluster.
Not available Google / Microsoft OAuth client credentials; no shared auth service between spboucher.ai apps (each app has its own).

# Components

text
Browser ──HTTPS──▶ Caddy (BHS64) ──WireGuard──▶ node: next start :8660  (sp-calendar-web, PM2)
                                                        │
                                                        ├── PostgreSQL 17 (db sp_calendar)
                                                        │
                                               tsx src/worker (sp-calendar-worker, PM2)
                                                        ├── e-mail outbox → Resend / SMTP
                                                        ├── expiry of unanswered requests and proposals
                                                        ├── ICS subscriptions (external free/busy)
                                                        └── retention purge
                                               scripts/backup.sh (sp-calendar-backup, PM2 cron)
  • Web: Next.js 16 (App Router), React 19, TypeScript strict, Tailwind 4. Pages are server-rendered shells with client components that talk to the API; no server actions.
  • API /api/v1: three catch-all surfaces, each with one guard that runs before any handler is looked up (src/lib/api/router.ts): public/* (rate-limited, no session), auth/* (WebAuthn), admin/* (session + CSRF). Every body and query is validated with Zod (src/lib/api/schemas.ts).
  • Worker: one loop, every task idempotent; worker_heartbeat is read by /api/health and the dashboard.
  • No Redis: the job queue is the notification_jobs table (FOR UPDATE SKIP LOCKED), written in the same transaction as the change it announces (transactional outbox). See DECISIONS D-03.

# Availability engine (src/lib/engine/slots.ts)

Pure functions, no I/O, tested alone (tests/engine.test.ts). Inputs are modelled separately:

Input Source Nature
weekly opening hours availability_rules (default schedule, or one per type) wall-clock, owner's zone
exceptions: closures and extra openings availability_exceptions (range, all-day, weekly recurring) wall-clock
private events busy_blocks (source = manual) instants
external calendars busy_blocks (source = external) of calendars set to block instants
requests holding a slot booking_requests pending with unexpired hold, or open proposal instants + buffers
confirmed appointments bookings (status = confirmed) instants + buffers
type rules duration, step, buffers, notice, horizon, daily / weekly limits —

Algorithm: weekly rules and openings → instants per local date → union → minus closures → slots cut by elapsed time (a slot is always exactly duration real minutes; the hour skipped in spring offers nothing, the hour repeated in autumn is real time) → filtered by notice, horizon, limits (owner-local day and ISO week) and collisions. Buffers do not stack: the gap between two appointments is the larger of the two buffers that face each other.

Instants are stored as timestamptz (UTC); zones as IANA identifiers; wall-clock values are never stored as instants. The browser only formats (Intl) — it never computes availability.

# Double-booking protection (three layers)

  1. Owner advisory lock: every path that creates, confirms or moves an appointment runs in withOwnerLock() (pg_advisory_xact_lock), so check-then-write is serialised per owner.
  2. Re-check inside the lock with checkSlot() against the state read in that same transaction (approval, automatic confirmation, acceptance of a proposal, rescheduling).
  3. Exclusion constraint bookings_no_overlap (EXCLUDE USING gist (user_id WITH =, tstzrange(starts_at, ends_at) WITH &&) WHERE status = 'confirmed'): the database refuses an overlap whatever the code does; error 23P01 is mapped to HTTP 409 slot_taken.

Submissions carry an idempotency key (unique in the database): a replay returns the first outcome.

# Request and booking states

text
pending ──approve──▶ approved ──cancel──▶ cancelled
   │  ├─decline──▶ declined                 ▲
   │  ├─(time passes)──▶ expired            │
   │  ├─visitor withdraws──▶ cancelled      │
   │  └─owner proposes──▶ reschedule_requested ──visitor accepts (re-checked)──▶ approved
   │                              ├─visitor picks another time──▶ pending
   │                              ├─visitor declines──▶ cancelled
   │                              └─72 h or proposed time passes──▶ expired
booking: confirmed ──▶ cancelled        (a confirmed booking moves in place: same ICS UID, SEQUENCE + 1)

Pending policy per appointment type: hold (the request holds its slot for hold_ttl_hours, then stops blocking while staying pending) or none (never blocks; several requests may wait for the same time, one can be approved). On a conflict at approval nothing is confirmed: the owner proposes another time or declines.

A visitor's request to move a confirmed booking is a linked request (reschedule_of_booking_id): the booking stays where it is until the owner approves, then moves.

# Notifications

enqueue() writes a job with a dedup_key; the worker renders the message from the current database state (a superseded confirmation is dropped, a reminder for a moved booking is obsolete), sends it and records the provider's message id. Failure → retry after 1, 5, 30, 120, 360 min → dead. No transport configured → unconfigured (parked, visible, re-queueable); nothing is ever marked sent without the transport's acceptance. Invitations: RFC 5545, stable UID, growing SEQUENCE, METHOD:REQUEST / CANCEL, UTC times, 75-octet folding.

# External calendars

Implemented connector: ICS subscription, read-only free/busy, refreshed every ~10 minutes with ETag, recurrences expanded by node-ical, replaced atomically per calendar, SSRF-guarded (https only, public addresses only, 8 MB cap), address stored AES-256-GCM encrypted. Freshness is tracked in sync_state; a stale or failing calendar keeps its last known busy periods and, with the default conservative policy, turns automatic confirmations into requests to approve (conflict_flag = external_stale). Outgoing: a private ICS feed of confirmed bookings (token shown once, stored hashed). Google Calendar / Microsoft 365 OAuth and CalDAV are not implemented (no credentials available); the tables (external_calendar_accounts.provider, secret_enc, write_events) already allow them.

# Data model

db/migrations/001_init.sql (commented): users, authenticators, setup_tokens, recovery_codes, webauthn_challenges, sessions, settings, event_types, availability_rules, availability_exceptions, external_calendar_accounts, external_calendars, sync_state, busy_blocks, contacts, bookings, booking_requests, booking_answers, booking_tokens, notification_jobs, audit_events, rate_limits, worker_heartbeat.

# Source map

Path Role
src/lib/engine/slots.ts availability engine
src/lib/services/state.ts loads what the engine needs from the database
src/lib/services/booking.ts submit, approve, decline, propose, cancel, reschedule, expire
src/lib/services/outbox.ts, notify.ts, src/lib/mail/*, src/lib/ics.ts notifications
src/lib/services/external.ts ICS subscriptions
src/lib/services/maintenance.ts retention, erasure of a contact
src/lib/auth.ts passkeys, sessions, CSRF, enrolment tokens
src/lib/api/* routers, schemas, handlers
src/components/public/*, src/components/admin/* interface
src/worker/index.ts background loop
tests/ (Vitest, real PostgreSQL), e2e/ (Playwright) tests
Files main
Menu

Theme