docs: simplify guides and publish deployment examples #99
No files matched your search
@@ -1,51 +1,42 @@
|
||||
# When
|
||||
# 📅 When
|
||||
|
||||
Single-user appointment scheduling. A public appointment page, an admin UI, calendar
|
||||
integration (Google + CalDAV), and email notifications — all configured through one
|
||||
YAML file.
|
||||
Appointment scheduling for one person. You, probably.
|
||||
|
||||
"When" runs as two services that share a single `when.yaml` and data directory:
|
||||
the **web** app (appointment page + admin) and a background **worker** that handles
|
||||
calendar sync and sends appointment emails off the request path. The bundled
|
||||
`apps/web/docker-compose.yml` runs both.
|
||||
A public booking page, an admin UI, Google and CalDAV calendars, and email
|
||||
notifications. Configure it in YAML. Keep your appointments in SQLite.
|
||||
|
||||
## Quick start (Docker)
|
||||
No teams. No round-robin routing. No subscription to your own calendar.
|
||||
|
||||
1. Copy `apps/web/config/when.example.yml` to `apps/web/config/when.yaml` and fill it in. Point
|
||||
your editor at the bundled schema for autocomplete and validation — the example's
|
||||
first line already does this:
|
||||
## Get started
|
||||
|
||||
```yaml
|
||||
# yaml-language-server: $schema=./config.schema.json
|
||||
```
|
||||
You'll need Docker Compose. In a fresh directory:
|
||||
|
||||
See [`docs/config.md`](docs/config.md) for the full configuration reference.
|
||||
```sh
|
||||
mkdir when
|
||||
cd when
|
||||
mkdir config data public
|
||||
curl -fsSLo compose.yaml https://git.eleith.com/eleith/when/raw/branch/main/docs/compose.yaml
|
||||
curl -fsSLo config/when.yaml https://git.eleith.com/eleith/when/raw/branch/main/docs/when.example.yaml
|
||||
curl -fsSLo .env https://git.eleith.com/eleith/when/raw/branch/main/docs/.env.example
|
||||
openssl rand -base64 32
|
||||
```
|
||||
|
||||
2. Copy `apps/web/.env.example` to `apps/web/.env` and fill it in — the compose file
|
||||
loads it into all three services. Generate the secret it asks for:
|
||||
Edit `.env` and `config/when.yaml` to make it all cozy and comfortable.
|
||||
Make `data/` writable by container UID 1000; see [permissions](docs/deployment.md#start-up).
|
||||
|
||||
```sh
|
||||
openssl rand -base64 32 # -> AUTH_SECRET
|
||||
```
|
||||
```sh
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Set `ORIGIN` to the public URL you serve from; behind a reverse proxy, form
|
||||
submissions fail with a 403 without it. The full list of environment variables is in
|
||||
[`docs/deployment.md`](docs/deployment.md).
|
||||
Open http://localhost:3000 and `/admin` to get a lay of the land.
|
||||
|
||||
3. Bring up both services:
|
||||
|
||||
```sh
|
||||
docker compose -f apps/web/docker-compose.yml up -d
|
||||
```
|
||||
|
||||
The appointment page is at `http://localhost:3000/` and the admin UI at `/admin`. The
|
||||
container runs database migrations on boot. SQLite lives under `/app/data`; the
|
||||
bundled compose file mounts `./data` so appointments survive restarts.
|
||||
There is a lot more going on under the hood, read more documentation if you
|
||||
want to learn more.
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Philosophy](docs/philosophy.md) — what "When" is, and the principles behind it.
|
||||
- [Architecture](docs/architecture.md) — how the system is built.
|
||||
- [Configuration](docs/config.md) — the full `when.yaml` reference.
|
||||
- [Deployment](docs/deployment.md) — environment variables, Docker, running behind a reverse proxy, persistence, and operating endpoints.
|
||||
- [Development](docs/development.md) — running it locally, the scripts, testing, and coding conventions.
|
||||
- [Configuration](docs/config.md) — fields, defaults and examples.
|
||||
- [Deployment](docs/deployment.md) — run it, update it, recover it.
|
||||
- [Development](docs/development.md) — change it without making a hobby of it.
|
||||
- [Philosophy](docs/philosophy.md) — what belongs here. And what doesn't.
|
||||
- [Architecture](docs/architecture.md) — where the pieces live.
|
||||
@@ -4,9 +4,10 @@ import { define } from 'gunshi';
|
||||
import { getValidatedConfigPath } from '../../utils/config-path.ts';
|
||||
import { pass, fail } from '../../utils/report.ts';
|
||||
|
||||
const SKELETON = `# yaml-language-server: $schema=../../../packages/config/src/config.schema.json
|
||||
# Minimal starter config. Edit the placeholders, set the \${...} env vars, then
|
||||
# run: when-cli config validate. See config/when.example.yml for every option.
|
||||
const SKELETON = `# yaml-language-server: $schema=http://localhost:3000/schema.json
|
||||
# Adjust the schema URL to your running app's address.
|
||||
# Edit the placeholders and set the \${...} env vars, then run: when-cli config validate.
|
||||
# Full example: https://git.eleith.com/eleith/when/raw/branch/main/docs/when.example.yaml
|
||||
auth:
|
||||
credentials:
|
||||
username: admin
|
||||
@@ -70,6 +71,8 @@ export const initCommand = define({
|
||||
|
||||
pass(`wrote ${path}`);
|
||||
console.log(' edit the placeholders, then run: when-cli config validate');
|
||||
console.log(' see config/when.example.yml for every option');
|
||||
console.log(
|
||||
' see https://git.eleith.com/eleith/when/src/branch/main/docs/when.example.yaml for every option'
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -1,28 +0,0 @@
|
||||
# Copy to `.env` alongside this file and fill in. Both compose files load it with
|
||||
# `env_file:`, so nothing is baked into the image — ship the image, bring your own
|
||||
# `.env` and `config/when.yaml`.
|
||||
|
||||
# --- Read directly by the app ---
|
||||
|
||||
# Auth.js JWT signing secret. Required, 32+ characters.
|
||||
# openssl rand -base64 32
|
||||
AUTH_SECRET= development-only-secret-please-change-it-32chars
|
||||
|
||||
# Public base URL, no trailing slash — e.g. https://when.example.com
|
||||
# Required behind a reverse proxy: without it every form POST fails SvelteKit's
|
||||
# CSRF origin check with a 403. Also the default for `url.app` in when.yaml, so
|
||||
# set it here and leave that unset rather than writing the URL twice.
|
||||
ORIGIN=
|
||||
|
||||
# --- Referenced from when.yaml via ${...} ---
|
||||
# These names are whatever your config uses. Below are the ones `when-cli config
|
||||
# init` and `config/when.example.yml` write; `<NAME>` is the provider's key
|
||||
# upper-cased, with `-` as `_`.
|
||||
|
||||
WHEN_ADMIN_PASSWORD=
|
||||
WHEN_SMTP_PASSWORD=
|
||||
# WHEN_OIDC_CLIENT_SECRET=
|
||||
# WHEN_PROVIDER_<NAME>_PASSWORD=
|
||||
# WHEN_PROVIDER_<NAME>_CLIENT_SECRET=
|
||||
# WHEN_PROVIDER_<NAME>_REFRESH_TOKEN=
|
||||
# WHEN_METRICS_TOKEN=
|
||||
@@ -18,7 +18,7 @@ export async function bootConfig(path: string = resolveConfigPath()): Promise<Wh
|
||||
if (!exists) {
|
||||
logger.fatal(
|
||||
{ path },
|
||||
'config not found; copy config/when.example.yml to config/when.yaml as a starting point'
|
||||
'config not found; start with the example at https://git.eleith.com/eleith/when/src/branch/main/docs/when.example.yaml'
|
||||
);
|
||||
throw new Error(`config not found at ${path}`);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { join } from 'node:path';
|
||||
import { parse as parseYaml } from 'yaml';
|
||||
import * as config from '@when/config';
|
||||
|
||||
const examplePath = join(process.cwd(), 'config', 'when.example.yml');
|
||||
const examplePath = join(process.cwd(), '..', '..', 'docs', 'when.example.yaml');
|
||||
const exampleText = readFileSync(examplePath, 'utf8');
|
||||
|
||||
// Field names the example intentionally leaves undocumented (none today). Add a
|
||||
@@ -36,7 +36,7 @@ function allSchemaFieldNames(): Set<string> {
|
||||
return names;
|
||||
}
|
||||
|
||||
describe('config/when.example.yml', () => {
|
||||
describe('docs/when.example.yaml', () => {
|
||||
test('is a structurally valid config', () => {
|
||||
expect(() => config.validateStructure(parseYaml(exampleText))).not.toThrow();
|
||||
});
|
||||
|
||||
+3
-174
@@ -1,176 +1,5 @@
|
||||
# @when/worker
|
||||
|
||||
The `when` worker is a long-running Node service for appointment side effects,
|
||||
calendar maintenance, and on-demand service probes. Built on
|
||||
[OpenWorkflow](https://openworkflow.dev) `0.11.0` over `node:sqlite`, it shares the
|
||||
`when.sqlite` app DB and the `openworkflow.sqlite` queue with producers.
|
||||
|
||||
Web enqueues `reconcile-appointment` when an appointment changes. The worker
|
||||
coordinates calendar/video updates, renders emails, sends them over SMTP, and
|
||||
records outcomes in the appointment action log and service status. It also handles
|
||||
purge, calendar-repair requests, video-link generation, and service probes. On boot
|
||||
it loads config, migrates the app DB, connects the queue, registers workflows,
|
||||
starts polling with one execution slot and a 30-second lease, and serves `/healthz`.
|
||||
Native `worker.start()` persists declared schedules before startup enqueues all three
|
||||
maintenance passes with `{}` and no run options.
|
||||
|
||||
## Run it
|
||||
|
||||
```sh
|
||||
# from the repo root; build is a tsc compile to dist/ (run with node)
|
||||
pnpm --filter @when/worker build && pnpm --filter @when/worker start
|
||||
|
||||
# or watch mode in dev, pointed at the shared config. The app + queue DB paths
|
||||
# come from that config's `database` section, resolved relative to it — so this
|
||||
# opens apps/web/data/{when.sqlite,openworkflow.sqlite}, same as web.
|
||||
CONFIG_PATH=apps/web/config/when.yaml pnpm --filter @when/worker dev
|
||||
```
|
||||
|
||||
`GET http://localhost:9000/healthz` → `{"status":"ok"}`.
|
||||
|
||||
## Configuration
|
||||
|
||||
Database locations live in `when.yaml` (the source of truth), not env vars:
|
||||
|
||||
```yaml
|
||||
database:
|
||||
app: ./data/when.sqlite # default
|
||||
queue: ./data/openworkflow.sqlite # default
|
||||
```
|
||||
|
||||
Relative paths resolve against the config directory's parent (the deployment root), so web and worker
|
||||
(sharing one `when.yaml`) open the same files.
|
||||
|
||||
## Environment
|
||||
|
||||
- `CONFIG_PATH` — path to `when.yaml` (default `/app/config/when.yaml` in prod,
|
||||
`<cwd>/config/when.yaml` otherwise).
|
||||
- `WHEN_DATABASE_PATH` / `WHEN_QUEUE_DB_PATH` — optional escape hatches that override the
|
||||
config's `database.app` / `database.queue`.
|
||||
- `PORT` — health server port (default `9000`).
|
||||
|
||||
## Scripts
|
||||
|
||||
- `pnpm dev` — build once, then `tsc --watch` + `node --watch dist/src/index.js`
|
||||
(source changes rebuild and restart).
|
||||
- `pnpm build` — `tsc -p tsconfig.build.json` → `dist/`.
|
||||
- `pnpm start` — `node dist/src/index.js`.
|
||||
- `pnpm check` / `pnpm lint` / `pnpm test` / `pnpm test:coverage`.
|
||||
|
||||
## Appointment jobs
|
||||
|
||||
The worker-local `notify-appointment` workflow saves prepared email content and
|
||||
recipient roles, then starts one `deliver-appointment-email` child per recipient. Each child has five native SMTP
|
||||
attempts (30-second initial delay, doubling up to ten minutes). A successful send
|
||||
is checkpointed separately from its status write. Host and guest keep independent
|
||||
retry budgets, even when they share an email address.
|
||||
|
||||
Normal bookings, nonmatching attachment rules and already-selected links use the
|
||||
notification workflow directly. A pending/confirmed booking that needs an automatic
|
||||
link delegates its original notification to Generate instead of sending it separately.
|
||||
Generate makes one provider attempt, publishes an immediately ready link, then sends
|
||||
the original notice with or without that link. First-attempt success therefore needs
|
||||
only the original notification; pending or failed generation can produce a later
|
||||
"Video link added" update. Manual Generate remains update-only; removal uses the
|
||||
existing edit notification, while canceling an unfulfilled request is silent.
|
||||
|
||||
Expected provider failures are returned as data for the first attempt, not retried
|
||||
before the original notice. That initial video attempt has one five-second deadline,
|
||||
shared by its HTTP calls, including Google token refresh. Calendar sync, later video
|
||||
retries and SMTP do not inherit this deadline. The notification child
|
||||
returns the selected link in its saved envelope snapshot. Generate continues only
|
||||
if that original notice lacked a link. The original notice has its own native child
|
||||
checkpoint, so video retries do not resend it. Its SMTP retries finish before video
|
||||
continuation; exhausted notification delivery stops that continuation for operator
|
||||
review. Physical inbox arrival order is still not guaranteed.
|
||||
|
||||
Generate checkpoints provider requests separately from local publication and
|
||||
notification delivery. Accepted pending Google requests use read-only native retries
|
||||
(ten attempts, 30-second initial delay, doubling to a ten-minute cap); they are not
|
||||
resubmitted. Current choice, revision, selection and relevant event binding guard
|
||||
publication. Every newly attached background link advances ICS and calendar revision;
|
||||
manual intent logs the host edit, while automatic attachment logs a system edit.
|
||||
The existing builders and role-specific SMTP children deliver both notices through
|
||||
`notify-appointment`—Generate never sends SMTP directly. Saved intent is not job progress and workers never rewrite it.
|
||||
|
||||
Maintenance still repairs dirty calendar content and cleans unwanted resources; it
|
||||
does not create rooms or restart exhausted generation. Broader late-ready observation
|
||||
is not included here. Generation failure leaves the booking intact and requires
|
||||
operator review; application-write/enqueue and effect-before-checkpoint gaps remain.
|
||||
One worker slot means active provider calls can still delay other work, but retry
|
||||
waits park; the original notice follows one bounded attempt rather than the full
|
||||
generation retry budget.
|
||||
|
||||
Purge deletes each remote event with up to thirty native attempts (one-minute
|
||||
initial delay, doubling up to one hour). Exhaustion fails the job and retains the
|
||||
current and later hidden `purged` rows, including local personal data. Clicking
|
||||
purge again is not promised recovery; cleanup requires explicit operator review.
|
||||
|
||||
Email action logs describe attempts, not terminal job outcomes. See
|
||||
[interpreting outcomes](../../docs/deployment.md#interpreting-outcomes) for email
|
||||
log/service-status meanings and purge recovery guidance.
|
||||
|
||||
Prepared envelopes include personal data and guest capability links: treat queue
|
||||
backups like application backups. Effects are at-least-once: a crash after remote
|
||||
acceptance but before saving success can still duplicate effects or action-log entries.
|
||||
Metrics and logs are not exactly-once ledgers.
|
||||
|
||||
Deploy web, worker and CLI together only after draining old work. Follow the
|
||||
[grouped rollout runbook](../../docs/deployment.md#grouped-workflow-rollout) for read-only
|
||||
inspection, paired SQLite backups, restore verification and rollback restrictions.
|
||||
Do not replay unfinished old workflow histories through changed step graphs.
|
||||
|
||||
## Engine integration
|
||||
|
||||
Engine polling, schedule, heartbeat, and execution diagnostics go through the
|
||||
worker's Pino logger. The adapter emits operational categories without forwarding
|
||||
arbitrary engine error text or payloads that may contain credentials or guest links.
|
||||
|
||||
The existing job metrics describe **handler executions**, including replays. An
|
||||
escaped handler execution may mean durable suspension rather than a terminal job
|
||||
failure. Email metrics count physical SMTP attempt outcomes, not an exactly-once
|
||||
delivery ledger.
|
||||
|
||||
## Maintenance and shutdown
|
||||
|
||||
| Workflow | UTC cron | Stable schedule ID | Result |
|
||||
| ----------------------------- | -------------- | ---------------------------------- | ----------- |
|
||||
| `refresh-calendars` | `*/5 * * * *` | `when:refresh-calendars` | `refreshed` |
|
||||
| `expire-pending-appointments` | `0 * * * *` | `when:expire-pending-appointments` | `expired` |
|
||||
| `sync-calendars` | `*/10 * * * *` | `when:sync-calendars` | `requested` |
|
||||
|
||||
All inputs are `{}`. Each handler awaits the actual pass in durable steps; `requested`
|
||||
means the scan finished, not that an in-memory request was accepted. Refresh checks
|
||||
per-calendar due intervals, keeps stale mirrors on provider fetch failure, and runs
|
||||
before conflict checks. Expiry SQL is idempotent and dirties newly expired pending rows
|
||||
for repair; already-expired equal-revision rows are not backfilled. Subsequent callbacks
|
||||
read live config.
|
||||
Expected provider failures record status for later passes; local errors escape to native
|
||||
retries. Completion is not a promise that every provider succeeded.
|
||||
|
||||
Mirror freshness is proven only by the latest successful calendar refresh or calendar
|
||||
test, including an empty result. Pushes, failures and unknown/inconsistent observations
|
||||
make a calendar due on the next pass, even when an older success timestamp remains.
|
||||
Provider authentication/discovery does not refresh a mirror. The worker and web health
|
||||
share this evidence check: untrusted refresh time is unknown, while explicit errors
|
||||
remain failures. This conservative policy can fetch more often during push traffic or
|
||||
sustained failure; it adds no timestamp column or freshness history.
|
||||
|
||||
Cron follows UTC wall-clock boundaries, not time since the last pass finished. After
|
||||
downtime it dispatches one overdue run per schedule. Native startup upserts preserve
|
||||
administrative pause state, but independent startup runs still execute while paused;
|
||||
startup and overdue runs can coexist. See [schedule management](../../packages/jobs/README.md#schedule-management)
|
||||
for the public list/pause/resume/delete API; removing declarations does not retire stored schedules.
|
||||
|
||||
No application maintenance timers or coalescers remain. Every cron/manual request gets
|
||||
its own pass. One slot serializes active work: slow calls block appointment jobs and
|
||||
probes, and repeated scans can build a backlog. Native retry delays park and release
|
||||
the slot. Ordinary function steps use the engine default of ten attempts, one-second
|
||||
initial delay, doubling to a 100-second cap. Workflow backstops are not extra provider
|
||||
budgets; claim counts include resumes, and native exhaustion is terminal. Bookkeeping
|
||||
failures can consume step attempts too: these limits are not exact physical-call counts.
|
||||
|
||||
Signals and config restarts share shutdown: stop config watching and HTTP admission,
|
||||
await `worker.stop()`, finish HTTP closure, close the queue, then close the app DB.
|
||||
Stop waits for active passes to finish or park, not for pending work/future retries;
|
||||
it is not queue draining. Force-kill recovery relies on saved history and lease expiry.
|
||||
Calendar sync, video links and email delivery, off the booking request path.
|
||||
Run it alongside web with the same config and databases; see
|
||||
[development](../../docs/development.md) and [deployment](../../docs/deployment.md).
|
||||
@@ -0,0 +1,16 @@
|
||||
# Copy to .env beside compose.yaml. Keep it private and out of Git.
|
||||
# Generate AUTH_SECRET with: openssl rand -base64 32
|
||||
AUTH_SECRET=
|
||||
ORIGIN=http://localhost:3000
|
||||
|
||||
# Match the ${...} references in config/when.yaml.
|
||||
WHEN_ADMIN_PASSWORD=
|
||||
WHEN_SMTP_PASSWORD=
|
||||
WHEN_PROVIDER_CALDAV_SERVICE_PASSWORD=
|
||||
|
||||
# Optional services. Replace <NAME> with the provider key, uppercase, hyphens as _.
|
||||
# WHEN_OIDC_CLIENT_SECRET=
|
||||
# WHEN_PROVIDER_<NAME>_PASSWORD=
|
||||
# WHEN_PROVIDER_<NAME>_CLIENT_SECRET=
|
||||
# WHEN_PROVIDER_<NAME>_REFRESH_TOKEN=
|
||||
# WHEN_METRICS_TOKEN=
|
||||
+53
-248
@@ -1,270 +1,75 @@
|
||||
# Architecture
|
||||
|
||||
How "When" is built. For _why_ it's built this way, see
|
||||
[`philosophy.md`](philosophy.md).
|
||||
One booking app. Two processes. Shared files.
|
||||
|
||||
## Stack
|
||||
This is the map. The code has the street names.
|
||||
|
||||
Node 26, TypeScript (`strict`), pnpm workspaces. SvelteKit (`@sveltejs/adapter-node`)
|
||||
for the web app. SQLite through Node's built-in `node:sqlite` behind a small Kysely
|
||||
dialect. [openworkflow](https://openworkflow.dev) for durable background jobs. Vitest
|
||||
for tests, Prettier + ESLint for formatting/linting. Email templates render with Eta;
|
||||
zoned time math uses Node's native `Temporal` on the server and `temporal-polyfill`'s
|
||||
function-style entrypoints (`temporal-polyfill/fns/*`) in the browser; calendar iCal in
|
||||
and out goes through `ts-ics`.
|
||||
## The pieces
|
||||
|
||||
## Monorepo layout
|
||||
Node 26, strict TypeScript and pnpm workspaces. SvelteKit serves web;
|
||||
`node:sqlite` and Kysely handle data. OpenWorkflow runs background jobs.
|
||||
|
||||
A pnpm workspace (`apps/*`, `packages/*`). Tests are co-located as `*.test.ts` next to
|
||||
the code they cover — there is no top-level `tests/` directory.
|
||||
| Path | Job |
|
||||
| --------------------- | --------------------------------------------- |
|
||||
| `apps/web` | Booking, admin and HTTP routes. |
|
||||
| `apps/worker` | Calendar I/O, video links and email. |
|
||||
| `apps/cli` | Config validation and operator commands. |
|
||||
| `packages/config` | TypeBox schema, YAML loading and validation. |
|
||||
| `packages/db` | App database, queries and migrations. |
|
||||
| `packages/jobs` | Workflow contracts and queue client. |
|
||||
| `packages/calendar` | Google/CalDAV adapters and local ICS helpers. |
|
||||
| `packages/video-chat` | Google Meet and Nextcloud Talk adapters. |
|
||||
|
||||
| Path | Role |
|
||||
| --------------------- | -------------------------------------------------------------------------------------------- |
|
||||
| `apps/web` | SvelteKit app: appointment page, admin UI, API routes. Also holds `e2e/` (Playwright). |
|
||||
| `apps/worker` | Long-running background service: calendar sync + email delivery. See its README. |
|
||||
| `apps/cli` | Operator CLI (`when-cli`): config validation, service/calendar testing, and email test. |
|
||||
| `packages/config` | Canonical `when.yaml` schema, generated types, loader/validator. See its README. |
|
||||
| `packages/db` | SQLite data layer: `node:sqlite` + Kysely dialect, schema types, migrations. See its README. |
|
||||
| `packages/jobs` | The job/workflow contract shared by web (producer) and worker (consumer). See its README. |
|
||||
| `packages/calendar` | External-calendar I/O: provider adapters, busy-time fetch, push/delete, ICS. See its README. |
|
||||
| `packages/video-chat` | Video conferencing I/O: provider adapters (Google Meet, Nextcloud Talk) and dynamic links. |
|
||||
## A booking
|
||||
|
||||
Each package's `README.md` is the detailed reference for that package; this document is
|
||||
the system-level overview that ties them together.
|
||||
|
||||
## Two-process model
|
||||
|
||||
The web app and the worker are separate processes that share **one `when.yaml`** and
|
||||
**one data directory**. The web app stays on the request path only long enough to do the
|
||||
durable, fast work — validate, write the appointment row, enqueue a job — and returns. The
|
||||
worker does everything slow or failure-prone: sending emails, pushing to external
|
||||
calendars, refreshing busy times. They communicate through the database, not over HTTP.
|
||||
|
||||
```
|
||||
appointment request ─► web: write appointment row ─► enqueue job ─► respond
|
||||
│
|
||||
(shared SQLite + config) ▼
|
||||
worker: drain job ─► send email / sync calendar
|
||||
```text
|
||||
request → web: validate → save appointment → enqueue job → respond
|
||||
↓
|
||||
worker: calendar / video / email → record outcome
|
||||
```
|
||||
|
||||
## Background jobs
|
||||
Web and worker share `when.yaml`, `when.sqlite` and `openworkflow.sqlite`.
|
||||
Web imports job contracts, not worker implementations. Producers do not start a
|
||||
worker or register schedules.
|
||||
|
||||
Jobs run on openworkflow over `node:sqlite`. `packages/jobs` is the single source of
|
||||
truth for **what** jobs exist and their input/output shapes
|
||||
(`packages/jobs/src/specs.ts`): the web app enqueues `reconcile-appointment` from its
|
||||
spec, and the worker provides the implementation. Both sides share types and resolve a
|
||||
workflow by name, so web never imports worker code. Web and CLI initialize producer-only
|
||||
clients: they neither start a worker nor persist schedules.
|
||||
Booking reads a local busy-time mirror. Calendar providers expand recurrence;
|
||||
CalDAV must support `CALDAV:expand`. Failed imports keep the old mirror, which is
|
||||
not proof it is current. Calendar publishing writes content; video adapters handle
|
||||
conferencing. Those network calls stay in the worker.
|
||||
|
||||
- **Durable steps.** Saved successes are reused on replay. Reconciliation checkpoints
|
||||
prepared role-tagged email envelopes, then starts independent guest/host delivery
|
||||
children with native retries. Native retry waits park and release the execution slot.
|
||||
A crash after a remote effect but before its checkpoint can still duplicate effects
|
||||
or logs: execution is at-least-once, not exactly-once.
|
||||
- **Idempotency keys** dedupe enqueues for 24 hours, including matching terminal history;
|
||||
they do not automatically rerun old jobs. Appointment emails key on
|
||||
`appointmentId:kind:ics_sequence`; email-free reconciliation uses
|
||||
`appointmentId:sync:calendar_revision`. Generation reminders use
|
||||
`generate-video-chat:appointmentId:calendar_revision:notificationKind` (or `sync`
|
||||
for cleanup-only work). Different notification purposes cannot consume each
|
||||
other's reminder. Purge keys use sorted chain IDs. Calendar sync
|
||||
uses a random key per call, and the producer awaits enqueueing only.
|
||||
- **Native maintenance.** Worker `start()` persists declared UTC schedules, preserving
|
||||
pause state: refresh every five minutes, pending expiry hourly, repair every ten
|
||||
minutes. Startup then independently enqueues all three `{}` passes without options.
|
||||
Cron is wall-clock aligned and catches up once per overdue schedule, not once per
|
||||
missed tick. Pausing cron does not suppress startup passes or already queued work.
|
||||
Worker scheduling and web health share conservative mirror-freshness evidence;
|
||||
a push is not a refresh. See the [worker maintenance reference](../apps/worker/README.md#maintenance-and-shutdown).
|
||||
- **One execution slot.** Maintenance handlers await their actual pass; sync's
|
||||
`requested` result means its scan finished. There are no application maintenance
|
||||
timers or coalescers. Repeated requests each get a pass and can build a backlog;
|
||||
slow active maintenance blocks appointment jobs and probes. Expected provider
|
||||
failures are recorded for later passes; unexpected local errors escape to bounded
|
||||
native retries. A completed pass does not mean every provider succeeded.
|
||||
## Background work
|
||||
|
||||
Normal bookings use the worker-local `notify-appointment` workflow. A booking that
|
||||
needs an automatic link instead delegates its original pending/confirmed notice
|
||||
to Generate. That job makes one provider attempt, publishes a ready link, and sends
|
||||
the original notification with or without it. Only a notice prepared without a link
|
||||
needs native video continuation and a later `video-chat-added` update. Manual Generate
|
||||
is update-only. Expected initial provider failures are data, not a reason to withhold
|
||||
the original notice. The initial video attempt shares one five-second deadline across
|
||||
its HTTP calls; calendar sync and SMTP are outside that deadline. Notification
|
||||
SMTP retries complete before video continuation. Notification preparation/role
|
||||
children and their snapshot receipt have native checkpoints; no progress columns or
|
||||
separate video-continuation job are needed. Physical inbox order is not guaranteed. Calendar publishing does not
|
||||
create, copy, clear or adopt native conferencing. Provider video adapters own those
|
||||
operations, with current-choice checks in the worker. See the
|
||||
[worker appointment jobs](../apps/worker/README.md#appointment-jobs) for retry and
|
||||
failure limits.
|
||||
The worker has one execution slot. Active slow calls block other jobs; native
|
||||
retry delays park and release the slot. Saved steps avoid repeating checkpointed
|
||||
successes, but a crash before checkpointing can duplicate a remote effect.
|
||||
**At-least-once, not exactly-once.**
|
||||
|
||||
Email action logs describe attempts; engine history determines terminal outcomes.
|
||||
Failed purges retain hidden local personal data and require explicit operator review.
|
||||
See [interpreting outcomes](deployment.md#interpreting-outcomes) for detailed semantics
|
||||
and the [rollout runbook](deployment.md#grouped-workflow-rollout) for backup requirements.
|
||||
Enqueue keys deduplicate for 24 hours, including terminal runs. They are not retry
|
||||
commands. Worker stop is not queue draining. See
|
||||
[updates](deployment.md#updates) before deploying
|
||||
changed workflow graphs.
|
||||
|
||||
## Calendar I/O (off the request path)
|
||||
## Time
|
||||
|
||||
`packages/calendar` owns all provider-reaching logic — the Google and CalDAV adapters
|
||||
and every network call to an external calendar. **This code runs only in the worker.**
|
||||
The worker fetches busy times into a local mirror in the database and pushes
|
||||
appointments to the organizer's destination calendar. The web app reads the
|
||||
already-expanded busy mirror from `@when/db` and makes no provider call. Web does import
|
||||
`@when/calendar`, but only the network-free pieces (`buildIcs` for the `.ics` download
|
||||
endpoint, `setLogger`); tree-shaking keeps the adapter code out of the web bundle.
|
||||
Use native `Temporal` on the server and `temporal-polyfill/fns/*` in the browser.
|
||||
Inject a clock into domain logic.
|
||||
|
||||
## Email pipeline
|
||||
Displayed times follow the context's stored zone where applicable, then the `tz`
|
||||
cookie, then the browser on first visit. Until the zone is known, show blank space
|
||||
or a skeleton, not guessed times. `preferredTimezone.svelte.ts` owns the preference.
|
||||
|
||||
Lives in `apps/worker/src/email`. The flow separates assembly from rendering from
|
||||
delivery:
|
||||
Slot links carry an absolute instant; day links carry a date in the viewer's zone.
|
||||
There is no `tz` query parameter. Duration links must name an allowed choice;
|
||||
otherwise use the configured default or the current booking's reschedule length.
|
||||
|
||||
- **Builders** (\`builders/\*.ts\`) are pure functions: from an appointment they produce
|
||||
`EmailMessage` values (an addressed `EmailContent` model + optional ICS attachment).
|
||||
They do no I/O and no rendering.
|
||||
- **`renderMessage`** (`render.ts`) turns one `EmailMessage` into a send-ready envelope
|
||||
using Eta templates (`templates/email.html.eta`, `email.txt.eta`) and the shared
|
||||
`emailTheme` tokens (`theme.ts`). It is the single place the brand logo is attached.
|
||||
- **`Mailer`** (`smtp.ts`) sends an envelope over SMTP. It's built once at boot from
|
||||
config and carried on the worker context (`services/context.ts`), so handlers depend on
|
||||
it explicitly rather than reaching for a global — the same injection style as the
|
||||
workflow `step`.
|
||||
## Styling and theming (web)
|
||||
|
||||
## Data layer
|
||||
Scoped CSS and Bits UI. Use `theme.css` tokens for colors, dimensions,
|
||||
type, radii, shadows and transitions; no hardcoded colors or pixel dimensions.
|
||||
Components use the shared light/dark palette, not local dark-mode patches.
|
||||
|
||||
`packages/db` wraps `node:sqlite` behind a small Kysely dialect (no native addons) and
|
||||
owns the schema types, migrations, and runner. Migrations run on boot and are idempotent.
|
||||
Both web and worker open the same `when.sqlite`; web/CLI producers and the worker
|
||||
also open the shared openworkflow queue database. Worker shutdown stops config watching and
|
||||
HTTP admission, awaits worker stop and HTTP closure, then closes queue and app DBs.
|
||||
Worker stop waits for active work to finish or park, not for pending jobs or future retries.
|
||||
Keep component copy in markup. Script is for behavior, not identifier-to-sentence
|
||||
mappers. A data-derived value for genuinely repeated copy is fine.
|
||||
|
||||
## Configuration system
|
||||
|
||||
`packages/config` keeps the canonical config schema as JSON Schema
|
||||
(`src/config.schema.json`) and generates everything else from it:
|
||||
|
||||
- **Strict TypeScript types** via `json2ts`, so defaulted fields are non-optional in code
|
||||
(ajv fills the default at load).
|
||||
- **A relaxed editor schema** (`config.external.schema.json`) that drops defaulted fields
|
||||
from `required`, so an editor pointed at it via `# yaml-language-server: $schema=…`
|
||||
doesn't flag omitted-but-defaulted fields. Point a `when.yaml` at this relaxed copy,
|
||||
never the canonical one.
|
||||
|
||||
`${ENV_VAR}` references in any string are interpolated **before** validation, so secrets
|
||||
stay in the environment, not on disk. Validation is **ajv** against the canonical schema
|
||||
plus a cross-reference pass (`cross-refs.ts`) for things JSON Schema can't express (e.g.
|
||||
an event type's `destination_calendar` must name a declared calendar). This project does
|
||||
not use Zod — the JSON Schema is the one validator. The full `when.yaml` reference is
|
||||
[`config.md`](config.md).
|
||||
|
||||
## Cross-cutting concerns
|
||||
|
||||
### Time
|
||||
|
||||
Never call `new Date()` or `Date.now()` inline in domain logic. Inject a `Clock` service
|
||||
(`now()`) so tests can pin time. All zoned datetime math uses `@js-temporal/polyfill`
|
||||
(a future Node upgrade will swap this for the native `Temporal` global).
|
||||
|
||||
**Viewer timezone.** Times shown to a person render in _their_ zone, resolved in this
|
||||
precedence: (1) a context-specific stored zone — e.g. an appointment's \`attendee_timezone\`;
|
||||
(2) the `tz` cookie — the viewer's preference, read server-side so SSR is correct and
|
||||
flash-free; (3) first visit with no cookie — the client seeds it from the browser zone.
|
||||
The cookie defaults to the browser zone and is replaced where a surface lets the user
|
||||
choose (the scheduler). While the zone is still unknown (only the first visit, before the
|
||||
cookie is set), render **blank space** where the time goes — never a guessed placeholder
|
||||
time; a surface may substitute its own placeholder (e.g. a slot picker showing something).
|
||||
The shared accessor is `$lib/preferredTimezone.svelte.ts` (`createPreferredTimezone` /
|
||||
`getPreferredTimezone`).
|
||||
|
||||
**Deep links are zone-agnostic.** A slot is an absolute ISO instant (`?slot=…Z`) matched
|
||||
directly against availability; a `date` (`YYYY-MM-DD`) day link opens in the viewer's zone.
|
||||
There is no `tz` parameter — display always follows the viewer, so a link carries the
|
||||
moment, not a zone.
|
||||
|
||||
### Security
|
||||
|
||||
- Credentials auth compares the configured username and password with `timingSafeEqual`, both
|
||||
always evaluated so an early return cannot time the username. Supported in production; see
|
||||
the `auth` section of `docs/config.md` for when it fits.
|
||||
- Secrets are injected via `${ENV_VAR}` interpolation in `when.yaml`, never committed.
|
||||
- Nothing in SQLite is encrypted at the column level. The database holds guest names,
|
||||
emails and booking history in plain text. The queue also holds prepared email
|
||||
envelopes, personal data, and guest capability links. Treat both databases and their
|
||||
backups like `.env`; encrypt backups that leave the host.
|
||||
- Never log raw request bodies, session tokens, `cancel_token` values, or secrets.
|
||||
|
||||
#### The guest capability model
|
||||
|
||||
Guests have no account, so their link _is_ their credential — a `cancel_token` in the query
|
||||
string. A phone-only booking may not even have an email address to send a second factor to, so
|
||||
the goal is not to make a leak impossible. It is to keep the blast radius small, bounded in
|
||||
time, and reversible by the host.
|
||||
|
||||
- **One token per row.** A reschedule creates a new row with a new token; the old row is marked
|
||||
`rescheduled` and keeps its own.
|
||||
- **The live token is a master key over its chain.** A row opens to its own token or to the
|
||||
chain tip's, so whoever holds the current link can read the history it grew out of.
|
||||
`isViewAllowed`, `access.ts`.
|
||||
- **Old links do not reach forward.** A superseded row names its successor but hands over no
|
||||
token for it. A stale link therefore shows that the meeting moved and stops there; the guest
|
||||
was sent the new link when it moved.
|
||||
- **Everything expires.** `APPOINTMENT_VIEW_GRACE_DAYS` (7) past the appointment's end, and the
|
||||
same window gates the `.ics` download. A superseded row counts those 7 days from the _move_,
|
||||
not from its old slot, which would otherwise still be months away.
|
||||
- **The host can revoke.** Rotating from the guest-link dialog mints a fresh token, so every
|
||||
link issued so far stops resolving. Offered only on the chain tip, enforced in SQL. No email
|
||||
is sent — re-delivery is the host's call, since the address may itself be the leak path.
|
||||
|
||||
What a leaked _live_ link still permits: cancelling, or moving the appointment — which on a
|
||||
`require_approval: true` meeting drops it back to `pending` and re-arms host approval. Both
|
||||
are written to the action log, both are visible to the host, and both are recoverable. That is
|
||||
accepted as proportionate. View-only tokens and emailed confirmation before destructive actions
|
||||
were both considered and rejected: they tax every honest guest, and neither is available at all
|
||||
when no email was captured.
|
||||
|
||||
#### Known limits, accepted
|
||||
|
||||
These are understood and deliberately not fixed. Recorded so a future reader does not mistake
|
||||
them for oversights.
|
||||
|
||||
- **`cancel_token` and bearer tokens compare with `!==`, not in constant time.** They are
|
||||
full-entropy random values, which makes remote timing extraction impractical. The _password_
|
||||
comparison is constant-time, because a human-chosen secret is a different problem.
|
||||
- **`/public/[...file]` follows symlinks.** `resolve()` blocks `../`, but a symlink planted
|
||||
inside the public directory serves its target. That directory is operator-mounted, and an
|
||||
operator who can plant a symlink there can already edit `when.yaml`.
|
||||
- **Bulk admin actions are not transactional.** They mutate row by row and report a combined
|
||||
error afterwards, so a partial failure leaves earlier rows changed. Admin-only, low frequency,
|
||||
and the outcome is visible in the list.
|
||||
- **A config reload is not atomic with respect to in-flight requests.** `setState` swaps the
|
||||
object, so a single request can observe two configs across two `getConfig()` calls. Reloads
|
||||
are rare and the window is microseconds.
|
||||
- **The app does no rate limiting.** Delegated to the reverse proxy; see `docs/deployment.md`.
|
||||
|
||||
### Styling and theming (web)
|
||||
|
||||
Vanilla CSS with Svelte's scoped `<style>` blocks. **No Tailwind.** Use [Bits UI](https://bits-ui.com)
|
||||
for headless, accessible component primitives.
|
||||
|
||||
- **Theme variables only.** Every visual value — colors, spacing, font sizes, radii,
|
||||
shadows, transitions — must use the custom properties in
|
||||
`apps/web/src/lib/styles/theme.css`. Hardcoded hex/pixel values in component styles are
|
||||
forbidden. Colors are semantic (`var(--primary)`, `var(--text)`, `var(--surface)`,
|
||||
`var(--border)`); there is one brand hue (`--primary`) with a derived tonal scale —
|
||||
don't introduce a second.
|
||||
- **Branding + dark mode.** `--primary` is injected at the root layout from the user's
|
||||
`when.yaml` appearance; the tonal scale derives from it. Dark mode is handled by media
|
||||
queries in `theme.css`, so components need no explicit dark-mode overrides.
|
||||
- **Copy lives in the markup.** Per-component user-facing strings (titles, labels, button
|
||||
text) belong in the template via `{#if}` chains, not in a `<script>`-side mapper
|
||||
function. `<script>` is for behavior (formatting transforms, handlers, data-derived
|
||||
state), not identifier-to-string mapping. If a string repeats 3+ times in one component,
|
||||
a data-derived `$derived` is fine — a mapper function is not.
|
||||
|
||||
## Testing
|
||||
|
||||
Unit tests are co-located `*.test.ts` files run by Vitest. Domain logic stays testable by
|
||||
injecting dependencies — the `Clock` for time, a fake `Mailer` for email, a fake workflow
|
||||
`step` for jobs — rather than reaching for globals. End-to-end tests live in
|
||||
`apps/web/e2e` and run against a real browser with Playwright.
|
||||
For development, see [development](development.md). For running it and backing it
|
||||
up, see [deployment](deployment.md).
|
||||
@@ -0,0 +1,58 @@
|
||||
services:
|
||||
when-app:
|
||||
# Pin web, worker and CLI to matching published versions for controlled updates.
|
||||
image: ghcr.io/eleith/when:latest
|
||||
platform: linux/amd64
|
||||
env_file: .env
|
||||
ports:
|
||||
# Localhost only. Use '3000:3000' for LAN access; restrict it with your firewall.
|
||||
- '127.0.0.1:3000:3000'
|
||||
volumes:
|
||||
- ./config:/app/config:ro
|
||||
- ./data:/app/data
|
||||
- ./public:/app/public:ro
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
when-worker:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
- node
|
||||
- --eval
|
||||
- fetch('http://localhost:3000/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))
|
||||
interval: 30s
|
||||
timeout: 3s
|
||||
start_period: 5s
|
||||
retries: 3
|
||||
|
||||
when-worker:
|
||||
image: ghcr.io/eleith/when-worker:latest
|
||||
platform: linux/amd64
|
||||
env_file: .env
|
||||
# Health and metrics stay on the Compose network. No published worker port.
|
||||
volumes:
|
||||
- ./config:/app/config:ro
|
||||
- ./data:/app/data
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
- node
|
||||
- --eval
|
||||
- fetch('http://localhost:9000/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))
|
||||
interval: 30s
|
||||
timeout: 3s
|
||||
start_period: 5s
|
||||
retries: 3
|
||||
|
||||
when-cli:
|
||||
image: ghcr.io/eleith/when-cli:latest
|
||||
platform: linux/amd64
|
||||
profiles:
|
||||
- tools
|
||||
env_file: .env
|
||||
volumes:
|
||||
# Writable so config init can create a starter file. Other services only read it.
|
||||
- ./config:/app/config
|
||||
- ./data:/app/data
|
||||
+338
-268
@@ -1,319 +1,389 @@
|
||||
# Configuration
|
||||
|
||||
`when.yaml` is the heart of "When" — it drives authentication, branding,
|
||||
availability, calendars, email, and the meetings people can book, without an admin
|
||||
database for settings. This is the complete reference; the canonical source of truth is
|
||||
defined in [schema.ts](file:///home/eleith/dev/when/packages/config/src/schema.ts) (TypeBox), which generates `config.schema.json` for editors to point at.
|
||||
One `when.yaml` for the web app and worker. Auth, availability, calendars, email,
|
||||
and what guests can book. No settings scavenger hunt.
|
||||
|
||||
Point your editor at that schema for inline autocomplete and validation:
|
||||
Copy the [example](when.example.yaml) to `config/when.yaml` and edit it. It
|
||||
documents every field; this page explains the rules. The running app serves its
|
||||
editor schema at `/schema.json`:
|
||||
|
||||
```yaml
|
||||
# yaml-language-server: $schema=./config.schema.json
|
||||
# yaml-language-server: $schema=http://localhost:3000/schema.json
|
||||
```
|
||||
|
||||
The required top-level keys are: `auth`, `user`, `smtp`, `providers`, `schedules`, `meetings`. Optional top-level keys include `version`, `database`, `url`, and `prometheus`.
|
||||
Use your app's address if different. In a checkout, the generated schema also
|
||||
lives at `packages/config/src/config.schema.json`; the source of truth is the
|
||||
[TypeBox schema](../packages/config/src/schema.ts).
|
||||
|
||||
`providers`, `schedules`, and `meetings` are **maps keyed by name, not lists**. The key _is_
|
||||
the name — a provider has no `name:` field, a schedule has no `name:`, and a meeting has
|
||||
neither `name:` nor `slug:` (its key is the slug, and `title:` holds the text guests see).
|
||||
There is likewise **no top-level `calendars:`**: a calendar is declared inside the
|
||||
`calendars:` map of the provider that serves it, which is what gives it its type and
|
||||
credentials.
|
||||
## File shape
|
||||
|
||||
```yaml
|
||||
# not this # this
|
||||
providers: providers:
|
||||
- name: cloud cloud:
|
||||
type: caldav type: caldav
|
||||
calendars: calendars:
|
||||
- name: work work:
|
||||
provider: cloud href: 'calendars/jane/work/'
|
||||
```
|
||||
| Key | Required? | Purpose / default |
|
||||
| ------------ | --------- | ------------------------------------------------------------------------------------------------------- |
|
||||
| `auth` | Yes | One admin sign-in strategy. |
|
||||
| `user` | Yes | Owner details and appearance. |
|
||||
| `smtp` | Yes | Outgoing appointment email. |
|
||||
| `providers` | No | Named services and their calendars; defaults to `{}`. Meetings must still reference declared calendars. |
|
||||
| `schedules` | Yes | At least one named availability schedule. |
|
||||
| `meetings` | Yes | At least one bookable meeting. |
|
||||
| `version` | No | Config format version, not the app release. Defaults to `1`; only `1` is accepted. |
|
||||
| `database` | No | SQLite paths. |
|
||||
| `url` | No | Public and internal service URLs. |
|
||||
| `prometheus` | No | Metrics; off by default. |
|
||||
|
||||
## `version`
|
||||
`providers`, `schedules`, and `meetings` are **maps, not lists**. Their keys are
|
||||
their names; a meeting's key is also its URL slug. Use lowercase letters, digits,
|
||||
and hyphens, starting with a letter or digit. The same rule applies to calendar
|
||||
keys.
|
||||
|
||||
The shape of this file, not the app's release. It defaults to `1` and you never need to
|
||||
write it; it exists so a future change to the config layout can be detected rather than
|
||||
guessed at. Only `1` is accepted today.
|
||||
|
||||
```yaml
|
||||
version: 1
|
||||
```
|
||||
|
||||
## Environment variable interpolation
|
||||
|
||||
Any string value may reference `${ENV_VAR}`. Substitution happens **before** validation,
|
||||
so secrets stay in the environment rather than on disk. The `${VAR:-default}` form
|
||||
supplies a fallback.
|
||||
|
||||
```yaml
|
||||
auth:
|
||||
oidc:
|
||||
client_secret: '${WHEN_OIDC_CLIENT_SECRET}'
|
||||
```
|
||||
|
||||
## `auth`
|
||||
|
||||
Declare **exactly one** strategy for the `/admin` interface.
|
||||
|
||||
```yaml
|
||||
# Local username/password:
|
||||
auth:
|
||||
credentials:
|
||||
username: 'admin'
|
||||
password: '${WHEN_ADMIN_PASSWORD}' # defaults to WHEN_ADMIN_PASSWORD env var if omitted
|
||||
|
||||
# — or — single sign-on via OIDC:
|
||||
auth:
|
||||
oidc:
|
||||
issuer: 'https://auth.example.com'
|
||||
client_id: 'when'
|
||||
client_secret: '${WHEN_OIDC_CLIENT_SECRET}'
|
||||
name: 'Single Sign-On' # optional display name, defaults to 'Single Sign-On' (e.g. 'Authelia', 'Google')
|
||||
```
|
||||
|
||||
Both are supported in production. Which one fits depends on who can reach the sign-in page:
|
||||
|
||||
- **`credentials`** is a good fit on a trusted network — a LAN, a VPN, or behind a proxy that
|
||||
authenticates before the request arrives. The app applies **no rate limiting and no lockout**
|
||||
to password attempts, so nothing slows an attacker who can reach `/signin`. Running it in
|
||||
production logs one warning at startup saying so.
|
||||
- **`oidc`** is the recommendation for an internet-facing deployment. Attempt throttling,
|
||||
lockout, password policy, and MFA then belong to your identity provider, which is built for
|
||||
them.
|
||||
|
||||
If you want `credentials` on a public address anyway, put the rate limiting at the reverse
|
||||
proxy — see the sign-in endpoint in `docs/deployment.md`.
|
||||
|
||||
## `user`
|
||||
|
||||
The schedule owner and the appointment page's appearance.
|
||||
|
||||
```yaml
|
||||
user:
|
||||
name: 'Your Name'
|
||||
email: 'you@example.com' # required, must be a valid email
|
||||
timezone: 'America/New_York' # IANA timezone; defaults to the TZ env var, or UTC
|
||||
appearance:
|
||||
title: 'Schedule a time with me'
|
||||
description: 'A little bit about me.'
|
||||
app_icon_path: '/public/logo.png'
|
||||
avatar_path: '/public/my-avatar.jpg'
|
||||
favicon_path: '/public/favicon.svg'
|
||||
opengraph_path: '/public/share-card.png'
|
||||
font_name: 'Outfit' # bundled: Noto Sans, Lato, Outfit, Inter
|
||||
font_path: '/public/my-font.woff2' # a woff2 registered as font_name, for a non-bundled family
|
||||
primary_light_color: '#166534' # brand hue, light mode
|
||||
primary_dark_color: '#34d399' # brand hue, dark mode
|
||||
background_light_color: '#f5f5f5'
|
||||
background_dark_color: '#0a0a0a'
|
||||
text_light_color: '#171717'
|
||||
text_dark_color: '#ededed'
|
||||
```
|
||||
|
||||
`appearance` and all of its fields are optional; defaults are applied when omitted. `app_icon_path` and `favicon_path` default to a bundled spiral-calendar mark (`/assets/images/app-icon.svg`, `/assets/images/favicon.svg`), `avatar_path` defaults to `/assets/images/avatar.svg` — a placeholder avatar generated from `user.name` — and `opengraph_path` defaults to `/assets/images/opengraph.png`, a share card generated from the rest of the appearance. Every `*_path` here is a root-relative path to an asset this app serves; remote URLs are rejected. `primary_light_color` and `primary_dark_color` set the brand hue for light and dark modes, and a muted tonal scale is derived from each; the `background_*_color` and `text_*_color` fields likewise override the light/dark surface and text colors. To override any of them, place custom assets in `./public/` (a sibling of `config/` and `data/`, overridable with `WHEN_PUBLIC_DIR`) and reference them as `/public/...`.
|
||||
|
||||
## `smtp`
|
||||
|
||||
**Required.** The worker sends all appointment emails over this server; the appointment system
|
||||
relies on it.
|
||||
|
||||
```yaml
|
||||
smtp:
|
||||
host: 'smtp.example.com'
|
||||
port: 587 # defaults to 587
|
||||
username: 'you@example.com'
|
||||
password: '${WHEN_SMTP_PASSWORD}'
|
||||
from: 'bookings@example.com' # optional; defaults to noreply@<your url.app domain>
|
||||
```
|
||||
|
||||
`from` is the address on every email and the organizer on guest-facing calendar invites, so
|
||||
the host's own address is never exposed. It must be one your SMTP server may send from. The
|
||||
display name always comes from `user.name`.
|
||||
|
||||
## `providers`
|
||||
|
||||
External services and the calendars they serve, each keyed by the name you refer to it
|
||||
by. Three `type`s are supported: `google`, `caldav`, and `nextcloud`.
|
||||
|
||||
A calendar is declared inside the provider that serves it, so it needs no `provider`
|
||||
reference and no `type` of its own — both are implied by where it sits. Calendars are
|
||||
conflict sources (busy times) and/or appointment destinations.
|
||||
There is no top-level `calendars`, no provider or schedule `name`, and no meeting
|
||||
`name` or `slug` field. Calendars belong inside their provider:
|
||||
|
||||
```yaml
|
||||
providers:
|
||||
my-google-service:
|
||||
type: 'google'
|
||||
client_id: '${WHEN_PROVIDER_GOOGLE_SERVICE_CLIENT_ID}'
|
||||
client_secret: '${WHEN_PROVIDER_GOOGLE_SERVICE_CLIENT_SECRET}'
|
||||
refresh_token: '${WHEN_PROVIDER_GOOGLE_SERVICE_REFRESH_TOKEN:-}' # from /admin; empty = not connected
|
||||
calendars:
|
||||
personal: # referenced by meetings
|
||||
id: 'primary' # the Google calendar ID
|
||||
sync:
|
||||
refresh_every_minutes: 10 # minutes between busy-time refreshes (default 10)
|
||||
my-caldav-service:
|
||||
type: 'caldav'
|
||||
cloud:
|
||||
type: caldav
|
||||
url: 'https://cloud.example.com/remote.php/dav/'
|
||||
username: 'jane'
|
||||
password: '${WHEN_PROVIDER_CALDAV_SERVICE_PASSWORD}'
|
||||
username: jane
|
||||
password: '${WHEN_CALDAV_PASSWORD}'
|
||||
calendars:
|
||||
work:
|
||||
href: 'calendars/jane/work/' # joined to the provider url
|
||||
shared:
|
||||
href: 'https://other.example.com/dav/shared/' # ...or a full URL of its own
|
||||
my-nextcloud-service:
|
||||
type: 'nextcloud'
|
||||
url: 'https://nextcloud.example.com/'
|
||||
username: 'jane'
|
||||
password: '${WHEN_PROVIDER_NEXTCLOUD_SERVICE_PASSWORD}'
|
||||
calendars: {}
|
||||
href: 'calendars/jane/work/'
|
||||
```
|
||||
|
||||
**Calendar keys must be unique across every provider**, since meetings reference them by
|
||||
name alone. A CalDAV or Nextcloud calendar names its location with `href`, which is either joined to
|
||||
the provider's `url` or a full URL of its own. `sync.refresh_every_minutes` is optional
|
||||
everywhere.
|
||||
Unknown options are rejected. References to schedules, calendars, providers, and
|
||||
form fields are checked at load time.
|
||||
|
||||
A Google provider's `refresh_token` is the one credential you cannot write from scratch:
|
||||
Google only issues it through a browser consent round-trip. Connect the provider from
|
||||
`/admin`, which shows the token once, then store it in an env var and reference it here
|
||||
like any other secret. It is optional and defaults to empty — an empty token simply reads
|
||||
as not connected, so a provider works up to the point of actually reaching Google. See
|
||||
[deployment](deployment.md) for the full flow, including the container recreate that a new
|
||||
env var needs.
|
||||
## Environment variables
|
||||
|
||||
Any string value can contain `${ENV_VAR}`. Values are substituted before runtime
|
||||
validation, including in defaults. Keep secrets in the environment, not in
|
||||
committed YAML.
|
||||
|
||||
- `${VAR}` fails loading if the variable is unset.
|
||||
- `${VAR:-fallback}` uses the fallback if the variable is unset, not if it is empty.
|
||||
- `$$` produces a literal `$`.
|
||||
|
||||
For example: `password: '${WHEN_SMTP_PASSWORD}'`. Deployment variables such as
|
||||
`CONFIG_PATH`, `AUTH_SECRET`, and `ORIGIN` are covered in
|
||||
[deployment](deployment.md#environment-variables).
|
||||
|
||||
## `auth`
|
||||
|
||||
Choose **exactly one** strategy for `/admin`.
|
||||
|
||||
```yaml
|
||||
auth:
|
||||
credentials:
|
||||
username: admin
|
||||
# password defaults to ${WHEN_ADMIN_PASSWORD}
|
||||
```
|
||||
|
||||
Or replace it with OIDC:
|
||||
|
||||
```yaml
|
||||
auth:
|
||||
oidc:
|
||||
issuer: 'https://auth.example.com'
|
||||
client_id: when
|
||||
client_secret: '${WHEN_OIDC_CLIENT_SECRET}'
|
||||
# name: 'Single Sign-On' # default display name
|
||||
```
|
||||
|
||||
`credentials.username` is required; `password` must be non-empty and defaults to
|
||||
`${WHEN_ADMIN_PASSWORD}`. OIDC requires `issuer`, `client_id`, and `client_secret`;
|
||||
`name` defaults to `Single Sign-On`.
|
||||
|
||||
Both strategies work in production. Choose based on who can reach sign-in:
|
||||
|
||||
- **Credentials:** suitable on a LAN, VPN, or behind an authenticating proxy.
|
||||
The app has **no password-attempt rate limiting or lockout** and warns about
|
||||
this at startup in production.
|
||||
- **OIDC:** recommended for an internet-facing deployment. Throttling, lockout,
|
||||
password policy, and MFA belong to your identity provider.
|
||||
|
||||
If you expose credentials sign-in publicly, enforce rate limiting at the reverse
|
||||
proxy.
|
||||
|
||||
## `user`
|
||||
|
||||
| Field | Default | Meaning |
|
||||
| ------------ | ------------ | ---------------------------------------------------------------------- |
|
||||
| `name` | Required | Owner's display name. |
|
||||
| `email` | Required | Owner's valid email address. |
|
||||
| `timezone` | `${TZ:-UTC}` | IANA zone, such as `America/New_York`. Schedule windows use this zone. |
|
||||
| `appearance` | `{}` | Optional overrides below. |
|
||||
|
||||
### Appearance
|
||||
|
||||
Every field under `user.appearance` is optional.
|
||||
|
||||
| Field | Default |
|
||||
| ------------------------ | --------------------------------------------------- |
|
||||
| `title` | `if not now, when?` |
|
||||
| `description` | `find some time and we can meet` |
|
||||
| `app_icon_path` | `/assets/images/app-icon.svg` |
|
||||
| `avatar_path` | `/assets/images/avatar.svg` |
|
||||
| `favicon_path` | `/assets/images/favicon.svg` |
|
||||
| `opengraph_path` | `/assets/images/opengraph.png` |
|
||||
| `font_name` | `Noto Sans` |
|
||||
| `font_path` | Unset; custom woff2 file registered as `font_name`. |
|
||||
| `primary_light_color` | `#166534` |
|
||||
| `primary_dark_color` | `#34d399` |
|
||||
| `background_light_color` | `#f5f5f5` |
|
||||
| `background_dark_color` | `#0a0a0a` |
|
||||
| `text_light_color` | `#171717` |
|
||||
| `text_dark_color` | `#ededed` |
|
||||
|
||||
The default icon and favicon are bundled calendar marks. The default avatar is
|
||||
generated from `user.name`; the default share card uses the appearance settings.
|
||||
Bundled font families are Noto Sans, Lato, Outfit, and Inter.
|
||||
|
||||
All `*_path` values must be root-relative paths served by this app. Remote URLs
|
||||
are rejected. Put custom files in `./public/`, alongside `config/` and `data/`,
|
||||
and reference them as `/public/logo.png`. `WHEN_PUBLIC_DIR` overrides that directory.
|
||||
|
||||
Colors accept three- or six-digit hex values. Each primary color generates a
|
||||
muted tonal scale for its mode; background and text colors override the surfaces
|
||||
and text.
|
||||
|
||||
## `smtp`
|
||||
|
||||
Required. The worker uses this server for appointment emails.
|
||||
|
||||
| Field | Default | Meaning |
|
||||
| ---------- | -------------------------- | --------------------------------------------- |
|
||||
| `host` | Required | SMTP hostname. |
|
||||
| `port` | `587` | Integer from 1–65535. |
|
||||
| `username` | Required | SMTP username. |
|
||||
| `password` | Required | SMTP password; use an environment reference. |
|
||||
| `from` | `noreply@<url.app domain>` | Sender address your server is allowed to use. |
|
||||
|
||||
`from` is also the organizer on guest-facing calendar invites, keeping the host's
|
||||
own address out of those fields. The sender display name is always `user.name`.
|
||||
|
||||
## `providers`
|
||||
|
||||
Named services, each containing a `calendars` map. See the
|
||||
[example](when.example.yaml) for complete provider blocks.
|
||||
|
||||
| `type` | Required fields | Optional fields |
|
||||
| ----------- | ----------------------------- | ----------------------------------------------------------- |
|
||||
| `google` | `client_id`, `client_secret` | `refresh_token` (default `''`), `calendars` (default `{}`). |
|
||||
| `caldav` | `url`, `username`, `password` | `calendars` (default `{}`). |
|
||||
| `nextcloud` | `url`, `username`, `password` | `calendars` (default `{}`). Also supports Talk video links. |
|
||||
|
||||
For CalDAV, `url` is the DAV endpoint. For Nextcloud, it is the instance base URL.
|
||||
|
||||
### Calendars
|
||||
|
||||
Calendar keys must be **unique across all providers**: meetings reference them by
|
||||
name alone. They supply busy times, receive appointments, or both.
|
||||
|
||||
| Provider | Calendar field | Meaning |
|
||||
| ------------------ | ---------------------------- | ----------------------------------------------------------- |
|
||||
| Google | `id` (required) | Google calendar ID, such as `primary`. |
|
||||
| CalDAV / Nextcloud | `href` (required) | Path joined to the provider's `url`, or a full HTTP(S) URL. |
|
||||
| All | `sync.refresh_every_minutes` | Busy-time refresh interval; integer ≥ 1, default `10`. |
|
||||
|
||||
A calendar needs no `type` or `provider` field; its parent supplies both.
|
||||
|
||||
### Google connection
|
||||
|
||||
An empty or omitted `refresh_token` means not connected. The config can load,
|
||||
but the provider cannot reach Google until you connect it.
|
||||
|
||||
Connect from `/admin`. Google issues the token through browser consent; the admin
|
||||
UI shows it once for you to save in an environment variable and reference in YAML.
|
||||
After saving it, [recreate the containers](deployment.md#environment-variables).
|
||||
|
||||
## `schedules`
|
||||
|
||||
Weekly schedules defining availability slots, keyed by the name meetings refer to.
|
||||
`weekly` is a list of rules, each naming the `days` it applies to and a `from`/`to` window (24-hour
|
||||
`HH:MM`, in `user.timezone`). Availability is the union of all rules.
|
||||
Each named schedule has a `weekly` list. Omit `weekly` to use Monday–Friday,
|
||||
09:00–17:00. For example, `standard: {}` is a complete schedule.
|
||||
|
||||
Each rule needs `days`, `from`, and `to`:
|
||||
|
||||
```yaml
|
||||
schedules:
|
||||
standard: # the key is the name meetings reference
|
||||
weekly: # optional; defaults to Monday–Friday 09:00–17:00 when omitted
|
||||
- days: [mon, tue, wed, thu]
|
||||
standard:
|
||||
weekly:
|
||||
- days: [mon, tue, wed, thu, fri]
|
||||
from: '09:00'
|
||||
to: '12:00'
|
||||
- days: [mon, tue, wed, thu, fri]
|
||||
from: '13:00'
|
||||
to: '17:00'
|
||||
# For multiple windows in a day (e.g. a lunch break), repeat the day:
|
||||
- days: [fri]
|
||||
from: '09:00'
|
||||
to: '13:00'
|
||||
- days: [fri]
|
||||
from: '14:00'
|
||||
to: '17:00'
|
||||
# A day named by no rule has no availability (here: sat, sun).
|
||||
```
|
||||
|
||||
- Days are `mon`, `tue`, `wed`, `thu`, `fri`, `sat`, `sun`.
|
||||
- Times are 24-hour `HH:MM` in `user.timezone`; `from` must be earlier than `to`.
|
||||
- Availability is the union of all rules. Repeat days for split windows.
|
||||
- Days absent from every rule are unavailable. An explicit `weekly` list cannot be empty.
|
||||
|
||||
## `meetings`
|
||||
|
||||
The meetings people can book. **The key is the URL slug** — `30-min-chat` is booked at
|
||||
`/schedule/30-min-chat`. `title`, `schedule`, and `booking_calendar` are required — every
|
||||
meeting names what it is, where it books, and when. `duration_minutes` (30),
|
||||
`require_approval` (true), and `show_slots` (false) all default; everything else is optional.
|
||||
The key is the booking URL slug: `chat` gives `/schedule/chat`.
|
||||
|
||||
```yaml
|
||||
meetings:
|
||||
chat: # URL slug: /schedule/chat
|
||||
title: '30-minute chat'
|
||||
duration_minutes: 30 # the default length offered (default 30)
|
||||
additional_duration_minutes: [15, 60] # further lengths the guest may pick
|
||||
description: 'A quick intro call.'
|
||||
visibility: 'public' # 'public' (default) or 'unlisted' (kept off the homepage; the link still works)
|
||||
require_approval: true # true (default, host approves each booking) or false (instant)
|
||||
additional_busy_calendars: ['personal'] # further calendars to check for conflicts; booking_calendar is always checked (default [])
|
||||
booking_calendar: 'work' # required: where the appointment is written
|
||||
schedule: 'standard' # required: references a schedules name
|
||||
location: 'Office Room 101' # a static URL, address, or phone number (optional)
|
||||
video_chat: # provider and dynamic link attachment rules (optional)
|
||||
provider: 'my-nextcloud-service' # references a nextcloud or google provider name
|
||||
attach: # rules for attaching link (optional; defaults to auto: true)
|
||||
auto: true # auto: true (automatic) or auto: false (admin generates on demand)
|
||||
# when: # or attach conditionally based on guest form answers
|
||||
# - field: 'loc'
|
||||
# equals: 'Video Call'
|
||||
note: 'Please review materials prior to the call.' # a host note shown to guests (optional)
|
||||
form_fields: # custom booking questions (optional)
|
||||
- name: 'name'
|
||||
type: 'guest_name'
|
||||
label: 'Your Name'
|
||||
required: true
|
||||
- name: 'email'
|
||||
type: 'guest_email'
|
||||
label: 'Your Email'
|
||||
required: true
|
||||
- name: 'loc'
|
||||
type: 'event_location'
|
||||
label: 'Preferred Location'
|
||||
required: true
|
||||
- name: 'goals'
|
||||
type: 'paragraph'
|
||||
label: 'What would you like to discuss?'
|
||||
required: false
|
||||
- name: 'contact_method'
|
||||
type: 'choice'
|
||||
label: 'How should we reach you?'
|
||||
required: true
|
||||
choices: ['phone', 'email']
|
||||
- name: 'phone'
|
||||
type: 'phone'
|
||||
label: 'Your phone number'
|
||||
required: true # required only while shown
|
||||
show_when: # show only when every condition holds (AND)
|
||||
- field: 'contact_method' # references an earlier field by name
|
||||
equals: 'phone' # a single value, or a list for "one of"; omit to mean "filled at all"
|
||||
|
||||
# Scheduling rules:
|
||||
start_times_every_minutes: 30 # slots snap to this boundary (default 30; independent of the lengths offered)
|
||||
notice_minutes: 120 # minutes of lead time required (default 120)
|
||||
booking_window_days: 60 # days bookable into the future (default 60)
|
||||
padding_before_minutes: 0 # minutes padded before a meeting (default 0)
|
||||
padding_after_minutes: 0 # minutes padded after a meeting (default 0)
|
||||
daily_booking_limit: null # cap on meetings per day; null = unlimited (default null)
|
||||
chat:
|
||||
title: 'A quick chat'
|
||||
schedule: standard
|
||||
booking_calendar: work
|
||||
duration_minutes: 30
|
||||
additional_duration_minutes: [15, 60]
|
||||
```
|
||||
|
||||
### Custom Form Fields and Video Chats
|
||||
### Booking options
|
||||
|
||||
Rather than rigid location structures, meetings are customized using:
|
||||
| Field | Default | Meaning |
|
||||
| ----------------------------- | -------- | ---------------------------------------------------------------------------------------- |
|
||||
| `title` | Required | Text guests see. |
|
||||
| `schedule` | Required | Declared schedule name. |
|
||||
| `booking_calendar` | Required | Declared calendar for confirmed bookings; always checked for conflicts. |
|
||||
| `duration_minutes` | `30` | Default guest-selected length; positive integer. |
|
||||
| `additional_duration_minutes` | `[]` | Other positive integer lengths guests may choose. |
|
||||
| `description` | Unset | Brief description. |
|
||||
| `visibility` | `public` | `unlisted` hides it from the homepage, **not** from anyone with its link. |
|
||||
| `require_approval` | `true` | Host approves bookings; `false` confirms instantly. |
|
||||
| `additional_busy_calendars` | `[]` | Other calendar names to check for conflicts. |
|
||||
| `show_slots` | `false` | `true` draws slot buttons; `false` uses a bare drag timeline snapping to the same slots. |
|
||||
| `location` | Unset | Static address, phone number, or link. |
|
||||
| `note` | Unset | Host note shown to guests after booking. |
|
||||
| `video_chat` | Unset | Dynamic video link settings below. |
|
||||
| `form_fields` | Unset | Custom booking form below. |
|
||||
| `start_times_every_minutes` | `30` | Positive integer slot step, independent of offered durations. |
|
||||
| `notice_minutes` | `120` | Minimum lead time; integer ≥ 0. |
|
||||
| `booking_window_days` | `60` | Days into the future open for booking; integer ≥ 1. |
|
||||
| `padding_before_minutes` | `0` | Padding before appointments; integer ≥ 0. |
|
||||
| `padding_after_minutes` | `0` | Padding after appointments; integer ≥ 0. |
|
||||
| `daily_booking_limit` | `null` | Positive integer daily cap; `null` means unlimited. |
|
||||
|
||||
- **Fixed Location**: A static string configured via `location`.
|
||||
- **Dynamic Video Chat**: Configured under `video_chat` with `provider` (e.g. `my-nextcloud-service` or `my-google-service`) and optional `attach` rules (`auto: true|false` or `when: [...]` conditions based on form answers). Dynamic links (like Nextcloud Talk rooms or Google Meet URLs) are generated automatically upon booking or on-demand by the host.
|
||||
- **Custom Questions**: Configured via `form_fields`. Every form **must** include exactly one `guest_name` field (with `required: true`). Optional special field types include `guest_email` and `event_location`. General text fields, numbers, phone numbers, paragraphs, and choices are also supported. A field may be shown conditionally with `show_when`: a list of `{ field, equals }` conditions (all must hold) referencing earlier fields. Omit `equals` to require only that the referenced field is filled, or give it a list to accept any of several values. A field hidden by `show_when` is never required and its answer is not recorded.
|
||||
Duration choices are deduplicated and sorted numerically. New bookings start with
|
||||
`duration_minutes`, rescheduling starts with the booked length, and a valid
|
||||
`duration` link overrides either. The first choice is not necessarily the default.
|
||||
Hosts also get preset lengths; guests do not gain those choices except to retain
|
||||
a booked length. The slot step does not change with the selected duration.
|
||||
A step shorter than a duration produces overlapping candidate slots, visible
|
||||
when `show_slots` is enabled.
|
||||
|
||||
### Custom forms
|
||||
|
||||
Without `form_fields`, the form asks for a required guest name, required guest
|
||||
email, and optional notes. Setting `form_fields` **replaces** that default form.
|
||||
|
||||
Supply 1–10 fields, with unique `name` values. Each needs `name`, `type`, and
|
||||
`label`; `required` defaults to `false`, and `show_when` is optional.
|
||||
|
||||
| Type | Rules |
|
||||
| -------------------------------------- | --------------------------------------------------------------------------- |
|
||||
| `guest_name` | Exactly one, with `required: true`. |
|
||||
| `guest_email` | At most one; optional in a custom form. |
|
||||
| `event_location` | At most one. With `choices`, guests pick a location; without, they type it. |
|
||||
| `choice` | Requires a non-empty `choices` list of non-empty strings, in display order. |
|
||||
| `text`, `number`, `phone`, `paragraph` | General questions; `choices` is not accepted. |
|
||||
|
||||
This is a `form_fields` block inside a meeting:
|
||||
|
||||
```yaml
|
||||
form_fields:
|
||||
- name: name
|
||||
type: guest_name
|
||||
label: 'Your name'
|
||||
required: true
|
||||
- name: contact
|
||||
type: choice
|
||||
label: 'How should we reach you?'
|
||||
choices: [email, phone]
|
||||
required: true
|
||||
- name: email
|
||||
type: guest_email
|
||||
label: 'Your email'
|
||||
required: true
|
||||
show_when:
|
||||
- field: contact
|
||||
equals: email
|
||||
- name: phone
|
||||
type: phone
|
||||
label: 'Your phone number'
|
||||
required: true
|
||||
show_when:
|
||||
- field: contact
|
||||
equals: phone
|
||||
```
|
||||
|
||||
Every `show_when` condition must hold (AND). Conditions reference **earlier** fields:
|
||||
|
||||
- `equals: phone` matches one value.
|
||||
- `equals: [phone, email]` accepts any listed value.
|
||||
- Omitting `equals` tests whether the field is filled.
|
||||
|
||||
For a `choice` controller, matched values must exist in its `choices`. Hidden
|
||||
fields are never required, and their answers are not recorded.
|
||||
|
||||
### Video links
|
||||
|
||||
`video_chat.provider` names a declared `google` or `nextcloud` provider, not a
|
||||
calendar. Google Meet also requires a Google booking calendar. Generic CalDAV
|
||||
providers do not generate video links.
|
||||
|
||||
Choose one attachment rule under `video_chat.attach`:
|
||||
|
||||
| Rule | Behavior |
|
||||
| ------------------------ | -------------------------------------------------------------- |
|
||||
| Omitted, or `auto: true` | Generate a link automatically on booking. |
|
||||
| `auto: false` | Host generates the link on demand. |
|
||||
| `when: [...]` | Generate automatically when every form-answer condition holds. |
|
||||
|
||||
Use `auto` **or** `when`, not both. `when` is a non-empty list using the same
|
||||
`field` / `equals` conditions as forms, referencing fields in this meeting.
|
||||
For example, inside a meeting with a `contact` field:
|
||||
|
||||
```yaml
|
||||
video_chat:
|
||||
provider: nextcloud-service
|
||||
attach:
|
||||
when:
|
||||
- field: contact
|
||||
equals: video
|
||||
```
|
||||
|
||||
A `choice` field must include the matched value (`video` here). Links are generated
|
||||
per appointment as Google Meet URLs or Nextcloud Talk rooms.
|
||||
|
||||
## `database`
|
||||
|
||||
On-disk SQLite paths. Relative paths resolve against the config directory's parent — the
|
||||
deployment root that holds `config/` and `data/` as siblings — so the web and worker
|
||||
(loading the same `when.yaml`) open the same files.
|
||||
| Field | Default | Contents |
|
||||
| ------- | ---------------------------- | -------------------------------------------------- |
|
||||
| `app` | `./data/when.sqlite` | Appointments, mirrored busy times, service status. |
|
||||
| `queue` | `./data/openworkflow.sqlite` | Background job queue. |
|
||||
|
||||
```yaml
|
||||
database:
|
||||
app: './data/when.sqlite' # appointments, busy times, service status (default)
|
||||
queue: './data/openworkflow.sqlite' # background job queue (default)
|
||||
```
|
||||
Relative paths resolve against the config directory's **parent**, not the shell's
|
||||
working directory. With `config/when.yaml`, `config/` and `data/` are siblings.
|
||||
Web and worker must load the same config and share these files.
|
||||
|
||||
`WHEN_DATABASE_PATH` and `WHEN_QUEUE_DB_PATH` override the respective YAML paths.
|
||||
Treat both databases and their backups as sensitive; see
|
||||
[persistence](deployment.md#persistence).
|
||||
|
||||
## `url`
|
||||
|
||||
Public URLs and internal network endpoints for service-to-service communication.
|
||||
| Field | Default | Used for |
|
||||
| ---------- | ---------------------------------- | ------------------------------------------------------------------------------------- |
|
||||
| `app` | `${ORIGIN:-http://localhost:5173}` | Public base URL, including scheme; links in emails and calendar events. |
|
||||
| `internal` | `${WHEN_URL_INTERNAL:-}` | Worker → web, including branding images embedded in email. Empty falls back to `app`. |
|
||||
| `worker` | `http://when-worker:9000` | Web → worker telemetry and metrics. |
|
||||
|
||||
```yaml
|
||||
url:
|
||||
app: 'https://book.example.com' # public base URL (with scheme); used in emails + calendar events (defaults to ${ORIGIN})
|
||||
internal: 'http://when-app:3000' # base URL the worker uses to reach the web app internally (defaults to ${WHEN_URL_INTERNAL}; falls back to `app`)
|
||||
worker: 'http://when-worker:9000' # base URL the web app uses to reach the worker internally for telemetry and metrics (default 'http://when-worker:9000')
|
||||
```
|
||||
|
||||
Usually you can omit `app` entirely. It defaults to the `ORIGIN` env var, which
|
||||
`adapter-node` requires anyway to run behind a reverse proxy (see
|
||||
[`deployment.md`](deployment.md)) — so `ORIGIN` alone keeps both in sync.
|
||||
Usually, set `ORIGIN` and leave `url.app` alone. The web adapter needs `ORIGIN`
|
||||
behind a reverse proxy anyway. See [deployment](deployment.md#environment-variables).
|
||||
|
||||
## `prometheus`
|
||||
|
||||
Configuration for Prometheus metrics collection.
|
||||
| Field | Default | Meaning |
|
||||
| --------- | ------------------------- | ----------------------------------------- |
|
||||
| `enabled` | `false` | Enable metrics collection and `/metrics`. |
|
||||
| `token` | `${WHEN_METRICS_TOKEN:-}` | Bearer token required for scraping. |
|
||||
|
||||
```yaml
|
||||
prometheus:
|
||||
enabled: false # Whether metrics collection and endpoint are active (default: false)
|
||||
token: '${WHEN_METRICS_TOKEN:-}' # Bearer token for scraping metrics (default: ${WHEN_METRICS_TOKEN})
|
||||
```
|
||||
When disabled, `/metrics` returns 404. When enabled without a non-empty token,
|
||||
it returns 500, not public metrics. Scrape with `Authorization: Bearer <token>`.
|
||||
See [operating endpoints](deployment.md#inspect-logs).
|
||||
+74
-380
@@ -1,398 +1,92 @@
|
||||
# Deployment
|
||||
|
||||
Running "When" in production. The bundled `apps/web/docker-compose.yml` runs both
|
||||
services (web + worker) against one `when.yaml` and data directory. See
|
||||
[`README.md`](../README.md) for the quick start and [`config.md`](config.md) for the
|
||||
config reference.
|
||||
Use Docker Compose with the [sample](compose.yaml). There are other ways; you're
|
||||
welcome to them.
|
||||
|
||||
## Environment variables
|
||||
Start with the [quick start](../README.md#get-started) and
|
||||
[example config](when.example.yaml).
|
||||
|
||||
Both compose files deliver the environment from `apps/web/.env` via `env_file:` — start
|
||||
from the tracked `apps/web/.env.example`. Nothing is baked into the image, so the same
|
||||
image runs against whatever `.env` you bring. Compose's auto-loaded `.env` only
|
||||
substitutes `${VAR}` inside the compose YAML itself; `env_file:` is what populates the
|
||||
container's environment.
|
||||
## start up
|
||||
|
||||
A few variables are read **directly** by the app from the environment:
|
||||
|
||||
| Variable | Required | Notes |
|
||||
| ------------- | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `AUTH_SECRET` | always | Auth.js JWT signing secret. 32+ random bytes, base64-encoded (`openssl rand -base64 32`). |
|
||||
| `ORIGIN` | behind a proxy | Public base URL, no trailing slash. `adapter-node` otherwise derives the origin from the inbound `Host` header, which a TLS-terminating proxy makes `http://…` while the browser sends `https://…` — SvelteKit's CSRF check then rejects every form POST with a 403. Also the default for `url.app`, so setting it here is enough. |
|
||||
|
||||
`ORIGIN` and `url.app` in `when.yaml` are the same value — your public base URL. Set
|
||||
`ORIGIN` and leave `url.app` unset: `adapter-node` reads `ORIGIN` from the environment
|
||||
before any app code runs, so it can only travel in that direction. If you do set both,
|
||||
keep them identical; a mismatch means correct links in emails and 403s on every form.
|
||||
|
||||
Everything else is a **secret referenced from `when.yaml`** via `${ENV_VAR}`
|
||||
interpolation — the variable _names_ are whatever your config uses. The table below lists
|
||||
the names both `config init` and `config/when.example.yml` write; `<NAME>` is the
|
||||
provider's key upper-cased, with `-` as `_` (so the `caldav-service` provider reads
|
||||
`WHEN_PROVIDER_CALDAV_SERVICE_PASSWORD`). You set these values yourself.
|
||||
|
||||
| Variable | Needed when | Notes |
|
||||
| ------------------------------------ | --------------------------- | --------------------------------------------------------------------------------------------- |
|
||||
| `WHEN_ADMIN_PASSWORD` | credentials auth | Plain text password of the admin (defaults to this if omitted in config). |
|
||||
| `WHEN_OIDC_CLIENT_SECRET` | OIDC auth | OIDC provider client secret. |
|
||||
| `WHEN_SMTP_PASSWORD` | always | SMTP password — SMTP is required. |
|
||||
| `WHEN_PROVIDER_<NAME>_PASSWORD` | a CalDAV/Nextcloud provider | CalDAV / Nextcloud provider password. |
|
||||
| `WHEN_PROVIDER_<NAME>_CLIENT_SECRET` | a Google provider | Client secret from Google Cloud. |
|
||||
| `WHEN_PROVIDER_<NAME>_REFRESH_TOKEN` | a connected Google provider | Refresh token. You do not invent this one — `/admin` shows it after you authorize. See below. |
|
||||
|
||||
The worker also honors a few operational variables:
|
||||
|
||||
| Variable | Notes |
|
||||
| ------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
|
||||
| `CONFIG_PATH` | Path to `when.yaml` (default `/app/config/when.yaml` in prod, `<cwd>/config/when.yaml` otherwise). |
|
||||
| `WHEN_DATABASE_PATH` / `WHEN_QUEUE_DB_PATH` | Optional overrides for the config's `database.app` / `database.queue`. |
|
||||
| `WHEN_PUBLIC_DIR` | Override the branding-overrides dir (default `<root>/public`, i.e. `/app/public` in the container). |
|
||||
| `PORT` | Worker health-server port (default `9000`). |
|
||||
| `WHEN_URL_INTERNAL` | Default for `url.internal` (how the worker reaches the web app internally). Baked into the Docker images per target. |
|
||||
|
||||
## Docker
|
||||
Make `data/` writable by container UID 1000, or SQLite won't start.
|
||||
|
||||
```sh
|
||||
cp apps/web/.env.example apps/web/.env # then fill it in
|
||||
docker compose -f apps/web/docker-compose.yml up -d
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
`.env` must exist — compose refuses to start without the file it is told to load.
|
||||
Web listens on 3000.
|
||||
|
||||
This brings up the web app (appointment page at \`/\`, admin at \`/admin\`) and the worker
|
||||
(calendar sync + email delivery). Both run database migrations relevant to their role on
|
||||
boot.
|
||||
|
||||
## Behind a reverse proxy
|
||||
|
||||
The app speaks plain HTTP on port 3000 and terminates no TLS of its own, so any
|
||||
internet-facing deployment puts a proxy in front. Below is the split of who owns what.
|
||||
|
||||
### Required of the deployment
|
||||
|
||||
- **Set `ORIGIN`** to the public base URL. Without it the app derives its origin from the
|
||||
inbound `Host` header, which a TLS-terminating proxy reports as `http://…` while the
|
||||
browser sends `Origin: https://…`; SvelteKit compares the two and rejects **every** form
|
||||
POST with a 403 — booking, cancel, reschedule, sign-in, and the admin bulk actions.
|
||||
- **The proxy must set `Host` from its own configuration** and must not forward a
|
||||
client-supplied value. With `ORIGIN` set the app ignores `Host` when building the request
|
||||
URL, which is also what makes Auth.js's `trustHost: true` safe — OIDC callback URLs are
|
||||
then built from a pinned origin rather than an attacker-influenceable header.
|
||||
- **Point the proxy's upstream health check at `GET /healthz`** on the web app.
|
||||
|
||||
You do **not** need `PROTOCOL_HEADER` or `HOST_HEADER`: those exist to reconstruct the
|
||||
origin from forwarded headers, and a set `ORIGIN` takes precedence over both. Likewise
|
||||
`ADDRESS_HEADER` and `XFF_DEPTH` — the app never reads the client address.
|
||||
|
||||
### Owned by the proxy
|
||||
|
||||
- **TLS termination** and the HTTP → HTTPS redirect.
|
||||
- **`Strict-Transport-Security`.** Deliberately not sent by the app; it is meaningless on
|
||||
the plaintext hop and belongs to whatever terminates TLS.
|
||||
- **Rate limiting.** The app does none. The endpoints worth a rule are the unauthenticated
|
||||
ones: `POST /schedule/<slug>` (each success writes a row _and_ sends mail over your SMTP
|
||||
credentials, so abuse costs sender reputation), `POST /appointment/<id>` and
|
||||
`POST /appointment/<id>/reschedule`, and the sign-in POST when `auth.credentials` is in
|
||||
use — the app performs no lockout or throttling on password attempts. It warns once at
|
||||
startup when credentials auth runs in production; a rule here, or OIDC instead, is the
|
||||
answer. See the `auth` section of `docs/config.md`.
|
||||
- **Response caching** for `/assets/images/opengraph.png` and `/assets/images/avatar.svg`,
|
||||
which are rendered per request, and for `/public/*`.
|
||||
- **Request body limits**, if you set any: keep them at or above the app's own
|
||||
`BODY_SIZE_LIMIT` (512K by default) so oversized requests produce the app's error rather
|
||||
than the proxy's.
|
||||
|
||||
### Security headers
|
||||
|
||||
The app sends these itself, so it is safe without a proxy in front of it:
|
||||
|
||||
- `Content-Security-Policy`, including `frame-ancestors 'none'` — which supersedes
|
||||
`X-Frame-Options`, so you do not need that header at all.
|
||||
- `Referrer-Policy: strict-origin-when-cross-origin`.
|
||||
- `X-Content-Type-Options: nosniff`.
|
||||
|
||||
**Never set `Content-Security-Policy` at the proxy.** Two CSP headers are not
|
||||
last-one-wins — the browser enforces both and the page gets their intersection. SvelteKit
|
||||
mints a per-request nonce for its hydration script and the booking page carries an injected
|
||||
inline `<style>` for the configured theme, neither of which a proxy can account for, so a
|
||||
second policy will break rendering.
|
||||
|
||||
The other two are yours to duplicate if your standard vhost sets them. A proxy that replaces
|
||||
headers wins outright; one that appends (bare nginx `add_header`) leaves two values and the
|
||||
browser takes the last valid one. Either way the app's value is the fallback, not the winner,
|
||||
so make sure yours is at least as strict. For `Referrer-Policy` that matters: guest
|
||||
appointment links carry their access token in the query string, and anything weaker than
|
||||
`strict-origin-when-cross-origin` leaks a working credential in the `Referer` of an outbound
|
||||
click.
|
||||
|
||||
The app's policy allows no remote origin at all. Every `appearance.*_path` is a root-relative
|
||||
path this app serves, so custom branding goes in `./public/` and is referenced at `/public/…`.
|
||||
|
||||
### Ports
|
||||
|
||||
Publish only the web app's 3000. The worker's port (`9000` by default) is internal-only and
|
||||
the bundled `docker-compose.yml` correctly does not publish it: its `GET /healthz` is
|
||||
unauthenticated by design, for the container liveness check. Its `/metrics` is bearer-gated
|
||||
and served only when `prometheus.enabled` is true, but the port should still never be
|
||||
reachable from outside the compose network.
|
||||
|
||||
The web app's `/metrics` is bearer-gated the same way. If you scrape it through the proxy,
|
||||
keep it off the public vhost or restrict it by source address — the bearer token is the only
|
||||
thing in front of it.
|
||||
|
||||
## Persistence
|
||||
|
||||
State is two SQLite files under the data directory the compose file mounts
|
||||
(`./data` → `/app/data`):
|
||||
|
||||
- `when.sqlite` — appointments, mirrored busy times, and service status (shared by web + worker).
|
||||
- `openworkflow.sqlite` — the background job queue (shared by web/CLI producers and worker).
|
||||
|
||||
Branding overrides live in a separate `public/` directory mounted alongside
|
||||
(`./public` → `/app/public`, overridable with `WHEN_PUBLIC_DIR`); the web app serves
|
||||
them at `/public/...`. The bundled defaults under `/assets/*` are baked into the image
|
||||
and need no mount.
|
||||
|
||||
Back up both databases consistently using SQLite-aware backup handling, not live file
|
||||
copies that omit committed WAL data. Include `when.yaml`, its environment secrets, and
|
||||
`public/` overrides separately. Database paths are configurable under `database`.
|
||||
Both databases are sensitive: the queue includes prepared email envelopes, personal data
|
||||
and guest capability links. Encrypt off-host backups and restrict local access.
|
||||
|
||||
## Grouped workflow rollout
|
||||
|
||||
Deploy web, worker and CLI as one stopped, grouped release. Do not deploy intermediate
|
||||
PR images automatically; release/version/tag operations require separate authorization.
|
||||
Application tests and the read-only drain/backup procedure are the gates, not an
|
||||
assumption of old/new queue compatibility.
|
||||
|
||||
1. **Verify the release.** Run affected jobs/worker/web/CLI checks, lint, coverage and
|
||||
builds, workspace checks and browser regressions. Confirm both CI paths execute the
|
||||
co-located integration tests. Keep the currently deployed artifacts available.
|
||||
2. **Stop producers**, including web, CLI commands and any other enqueuers. Let the
|
||||
**old worker** drain every unfinished old run. Worker shutdown alone is not draining:
|
||||
future pending jobs, parked retries/parents, running jobs and legacy `sleeping` work
|
||||
all count. Inspect the old queue read-only; do not initialize the new jobs client,
|
||||
since connection runs migrations. If recurring dispatch exists, pause it with the
|
||||
matching deployed API so the queue can drain.
|
||||
3. **Halt if it cannot drain.** Require an explicit operator retry/cancel/reset decision
|
||||
after reviewing local and remote state. Never automatically wipe/reset a queue, even
|
||||
an apparently empty one, edit checkpoints, or resume changed old application graphs.
|
||||
Inspect again after any authorized action; cancelling a parent need not cancel its
|
||||
already-started children.
|
||||
4. **Stop the old worker**, recheck that no unfinished runs remain, then take a consistent
|
||||
paired app/queue backup while all writers stay stopped. Include committed WAL data
|
||||
with SQLite-aware handling and verify restoration on copies (examples below).
|
||||
5. **Deploy the grouped artifacts once**, against the same config and database paths,
|
||||
with no old worker or competing maintenance timers. Native `worker.start()` persists
|
||||
schedules and preserves pause; startup then enqueues all three `{}` passes without
|
||||
options, independently of pause. Inspect startup passes, schedules, service status
|
||||
and queue progress. Cron catches up once per overdue schedule and can coexist with
|
||||
startup work. Repeated scans are not coalesced; slow active maintenance blocks other
|
||||
jobs at concurrency one, while native retry waits park. Do not replay old graphs or
|
||||
automatically rerun terminal history; dedup may return an existing terminal run.
|
||||
6. **Rollback only with evidence.** Use a proven compatible binary, or obtain explicit
|
||||
approval for restoring the paired backups and losing all later writes. Old binaries
|
||||
are not assumed compatible with migrated queues. Stop every writer before a paired
|
||||
restore; restore into clean destinations without stale WAL/SHM sidecars. Remote
|
||||
calendar/email effects are not undone by restoring SQLite.
|
||||
|
||||
### Read-only drain inspection
|
||||
|
||||
Set `QUEUE` to the actual old queue path. With the SQLite CLI, inspect the schema first:
|
||||
## inspect logs
|
||||
|
||||
```sh
|
||||
sqlite3 -readonly "$QUEUE" '.schema workflow_runs'
|
||||
docker compose logs --tail=100 -f
|
||||
```
|
||||
|
||||
The installed `0.11.0` schema uses `workflow_runs`, with `namespace_id`, `id`,
|
||||
`workflow_name`, `status`, and `available_at`. Verify these columns against the deployed
|
||||
schema; a mismatch requires investigation, not opening it with a newer client. Installed
|
||||
public status types/terminal logic recognize `completed`, legacy `succeeded`, `failed`,
|
||||
and **`canceled`** (one l) as terminal. `pending`, `running`, and legacy `sleeping` are
|
||||
unfinished. Unknown statuses halt the rollout, even if they look terminal.
|
||||
Visit `/healthz` for liveness and `/admin/health` for recorded service outcomes.
|
||||
|
||||
## environment variables
|
||||
|
||||
Copy the [environment example](.env.example) to `.env` beside `compose.yaml` and
|
||||
supply real values. After editing `.env`, run `docker compose up -d --force-recreate`.
|
||||
A restart keeps the old environment.
|
||||
|
||||
| Variable | Used for |
|
||||
| ------------- | -------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `AUTH_SECRET` | Required Auth.js session-signing secret. Generate 32 random bytes with `openssl rand -base64 32`. |
|
||||
| `ORIGIN` | Public base URL, no trailing slash. Set it behind a proxy or form POSTs can fail with 403. Also the default for `url.app`. |
|
||||
|
||||
Other secret names come from `${ENV_VAR}` references in your config. The example
|
||||
uses:
|
||||
|
||||
| Variable | Needed for |
|
||||
| ------------------------------------ | ---------------------------------------------------------------- |
|
||||
| `WHEN_ADMIN_PASSWORD` | Credentials sign-in. |
|
||||
| `WHEN_OIDC_CLIENT_SECRET` | OIDC sign-in. |
|
||||
| `WHEN_SMTP_PASSWORD` | SMTP. |
|
||||
| `WHEN_PROVIDER_<NAME>_PASSWORD` | CalDAV or Nextcloud. |
|
||||
| `WHEN_PROVIDER_<NAME>_CLIENT_SECRET` | Google. |
|
||||
| `WHEN_PROVIDER_<NAME>_REFRESH_TOKEN` | Connected Google provider; obtained through admin, not invented. |
|
||||
|
||||
Advanced overrides:
|
||||
|
||||
| Variable | Default / purpose |
|
||||
| ------------------------------------------- | ----------------------------------------------------------------------------------------------- |
|
||||
| `CONFIG_PATH` | `/app/config/when.yaml` in production; normally `<cwd>/config/when.yaml` otherwise. |
|
||||
| `WHEN_DATABASE_PATH` / `WHEN_QUEUE_DB_PATH` | Override `database.app` / `database.queue`. |
|
||||
| `WHEN_PUBLIC_DIR` | Override `<deployment root>/public`. |
|
||||
| `PORT` | Web: 3000 in the production image. Worker: 9000. Don't give both the same override by accident. |
|
||||
| `WHEN_URL_INTERNAL` | Default for `url.internal`, the worker-to-web address. Docker targets supply it. |
|
||||
|
||||
## persistence
|
||||
|
||||
Back up both SQLite databases with SQLite-aware tooling; live file copies can miss
|
||||
WAL data. Keep config, `.env` and branding overrides too. These contain private data.
|
||||
|
||||
## updates
|
||||
|
||||
Update web, worker and CLI together. Stop producers, drain old jobs with the old
|
||||
worker, then stop it and back up both databases before starting new versions.
|
||||
Don't replay unfinished jobs through changed workflow graphs.
|
||||
|
||||
## Helper CLI
|
||||
|
||||
`when-cli` validates config and asks the worker to test services. Some commands
|
||||
write config or databases, refresh busy times, or send real mail. Not a dry run.
|
||||
|
||||
Using the sample's environment and shared data mounts:
|
||||
|
||||
```sh
|
||||
sqlite3 -readonly -header -column "$QUEUE" <<'SQL'
|
||||
PRAGMA query_only = ON;
|
||||
SELECT status, COUNT(*) AS runs FROM workflow_runs GROUP BY status;
|
||||
SELECT namespace_id, id, workflow_name, status, available_at
|
||||
FROM workflow_runs
|
||||
WHERE status NOT IN ('completed', 'succeeded', 'failed', 'canceled')
|
||||
ORDER BY namespace_id, created_at, id;
|
||||
SQL
|
||||
docker compose run --rm when-cli config validate --check-env
|
||||
```
|
||||
|
||||
Proceed only when the second query returns no rows and every observed status is known.
|
||||
There is deliberately **no due-time filter**: future pending work is unfinished. Inspect
|
||||
all namespaces and child runs, not just top-level appointment runs. Do not dump inputs,
|
||||
envelopes, errors or capability links into shared logs.
|
||||
|
||||
### Paired backup and restore check
|
||||
|
||||
With producers and worker stopped, set `APP`, `QUEUE`, and `BACKUP` to the real paths
|
||||
and a fresh restricted backup directory. For ordinary paths without single quotes:
|
||||
|
||||
```sh
|
||||
mkdir -m 700 "$BACKUP"
|
||||
sqlite3 -readonly "$APP" ".backup '$BACKUP/when.sqlite'"
|
||||
sqlite3 -readonly "$QUEUE" ".backup '$BACKUP/openworkflow.sqlite'"
|
||||
mkdir -m 700 "$BACKUP/restore-check"
|
||||
cp "$BACKUP/when.sqlite" "$BACKUP/restore-check/when.sqlite"
|
||||
cp "$BACKUP/openworkflow.sqlite" "$BACKUP/restore-check/openworkflow.sqlite"
|
||||
sqlite3 -readonly "$BACKUP/restore-check/when.sqlite" 'PRAGMA integrity_check;'
|
||||
sqlite3 -readonly "$BACKUP/restore-check/openworkflow.sqlite" 'PRAGMA integrity_check;'
|
||||
```
|
||||
|
||||
Both integrity checks must report `ok`. Compare restored appointment counts and queue
|
||||
status counts to the stopped sources, and rerun drain inspection on the restored queue.
|
||||
SQLite `.backup` includes committed WAL state; do not copy live main files alone or
|
||||
remove source WAL files to make backups look clean. Because every writer is stopped,
|
||||
the sequential backups form a consistent pair. Keep that pair together; test copies
|
||||
must never reach real providers or become the running deployment.
|
||||
|
||||
### Interpreting outcomes
|
||||
|
||||
An email action `failed` is an **observed provider attempt**, possibly followed by retries
|
||||
and recovery; local errors are not provider-failure observations. `done` requires a
|
||||
nonempty intended set where every delivery reported `sent` and the row is still eligible
|
||||
at the final write. Mixed/all skipped sets and missing/purged rows get no summary.
|
||||
Engine history is authoritative for terminal failure; no aggregate terminal email-failure
|
||||
summary is synthesized. An already-started sibling can continue independently. SMTP
|
||||
service status is the **last recorded physical attempt outcome**, not an aggregate
|
||||
recipient/job result. Its timestamps describe the send attempt, not a later status-write
|
||||
retry. A delayed success write can overwrite a newer failure and move the recorded
|
||||
timestamps backwards; this is not a time-ordered health history.
|
||||
Handler metrics include replays/suspensions; email metrics count
|
||||
physical attempt outcomes. Crashes before checkpoints can duplicate effects or log
|
||||
entries: this is at-least-once execution, not an exactly-once ledger.
|
||||
|
||||
A failed purge retains the current and later hidden `purged` rows, including local
|
||||
personal data. Earlier successful deletions remain deleted. Web selection skips
|
||||
already-purged rows, so clicking purge again is not promised recovery. Review retained
|
||||
rows, engine history and remote state before choosing explicit operator cleanup; there
|
||||
is no automatic terminal rerun or remote-orphan fallback. A completed maintenance pass
|
||||
likewise does not guarantee every provider succeeded. See the
|
||||
[worker README](../apps/worker/README.md#maintenance-and-shutdown) for cadence and the
|
||||
[jobs README](../packages/jobs/README.md#schedule-management) for public schedule management.
|
||||
|
||||
## Config reloads
|
||||
|
||||
`when.yaml` is watched, and most edits are applied in place. Changes under `auth` or `database`
|
||||
cannot be — the auth provider and the database connections are built once at boot — so the app
|
||||
logs `config change requires a restart` and exits **0**, expecting the supervisor to bring it
|
||||
back. Compose does, via `restart: unless-stopped`.
|
||||
|
||||
If you run the built server under something else, make sure a clean exit restarts it. A systemd
|
||||
unit with `Restart=on-failure` will _not_, and the app will simply stop after an `auth` or
|
||||
`database` edit. Use `Restart=always`.
|
||||
|
||||
## Connecting a Google provider
|
||||
|
||||
Google needs a browser round-trip, so this one credential cannot come from `when.yaml`
|
||||
alone. The flow ends by handing it to you rather than storing it:
|
||||
|
||||
1. Configure the provider's `client_id` and `client_secret`, and register
|
||||
`<your app url>/admin/services/google/callback` as an authorized redirect URI in Google
|
||||
Cloud. `/admin/health` shows the exact URI to paste.
|
||||
2. Press **Connect** on `/admin/health` and complete Google's consent screen.
|
||||
3. The callback shows the refresh token **once**. Put it in `.env` as
|
||||
`WHEN_PROVIDER_<NAME>_REFRESH_TOKEN`, and reference it from the provider's
|
||||
`refresh_token` field in `when.yaml`.
|
||||
4. Recreate the containers: `docker compose -f apps/web/docker-compose.yml up -d`.
|
||||
|
||||
That last step is the one that catches people. **`docker compose restart` is not enough.**
|
||||
Compose reads `.env` when it _creates_ a container, so restarting the container you already
|
||||
have keeps the environment it was born with. Only `up -d` recreates it. Under systemd or
|
||||
another supervisor, the equivalent is whatever re-reads your environment file — a plain
|
||||
service restart may not.
|
||||
|
||||
Because `when.yaml` is watched but `.env` is not, editing the config first is the wrong
|
||||
order: the watcher reloads, the env var is still unset, and interpolation fails with
|
||||
`when.yaml references unset environment variables`. The reload is rejected and the old
|
||||
config kept, which looks like nothing happening. Two ways to avoid it — keep the
|
||||
`refresh_token` line in `when.yaml` from the start using the `:-` fallback form that
|
||||
`config/when.example.yml` ships (then connecting is a single `.env` edit), or edit both
|
||||
files and recreate in one go.
|
||||
|
||||
To disconnect, press **Disconnect**: it revokes the grant at Google and then tells you to
|
||||
clear the env var yourself. Emptying the value is enough to read as not connected.
|
||||
|
||||
## Operating endpoints
|
||||
|
||||
Web app:
|
||||
|
||||
- `GET /healthz` — `200 OK` once the app is up.
|
||||
- `GET /metrics` — Prometheus metrics (`prom-client`).
|
||||
|
||||
Worker:
|
||||
|
||||
- `GET /healthz` on its `PORT` (default `9000`) — `{"status":"ok"}`.
|
||||
|
||||
## Helper CLI (`when-cli`)
|
||||
|
||||
You author `when.yaml` yourself — start from `config/when.example.yml`, which
|
||||
documents every option (and carries a `$schema` header for editor autocomplete).
|
||||
`when-cli` is an operator's toolkit for the parts a text editor can't do: it validates
|
||||
the file and reaches your services over the network. It is read-only — connecting a
|
||||
Google provider happens at `/admin/health`, which shows you the token to save.
|
||||
Run it with `pnpm cli` on the host, or against a deployment with
|
||||
`docker compose -f apps/web/docker-compose.yml run --rm when-cli <args>`.
|
||||
|
||||
Every command takes `-c/--config <path>` (defaults to the standard config location).
|
||||
|
||||
- **Scaffold a starter config** — writes a minimal valid `when.yaml` (placeholders
|
||||
- `${...}` env refs); refuses to overwrite. Edit it, then validate.
|
||||
|
||||
```sh
|
||||
pnpm cli config init
|
||||
```
|
||||
|
||||
- **Validate** — shape + cross-references. `--check-env` also verifies every
|
||||
referenced env var is set (the full boot check). Exits non-zero on failure.
|
||||
|
||||
```sh
|
||||
pnpm cli config validate [path/to/when.yaml]
|
||||
pnpm cli config validate --check-env
|
||||
```
|
||||
|
||||
- **Providers** — report what has been observed about each provider, authenticate one, or
|
||||
list the calendars it exposes (to fill `google_calendar_id` / a CalDAV `path`).
|
||||
|
||||
`provider list` reads the stored status and touches no network. `provider test` and
|
||||
`provider calendars` run in the worker, which holds the credentials and records the
|
||||
result — so a manual check shows up on `/admin/health` the same as the worker's own,
|
||||
and needs a running worker.
|
||||
|
||||
```sh
|
||||
pnpm cli provider list
|
||||
pnpm cli provider test <name>
|
||||
pnpm cli provider calendars <name>
|
||||
```
|
||||
|
||||
- **Calendars** — list configured calendars, or refresh one through the worker to confirm
|
||||
it is reachable. `calendar test` is a refresh a human asked for: it updates the busy
|
||||
mirror and records the outcome, so it doubles as "sync this now".
|
||||
|
||||
```sh
|
||||
pnpm cli calendar list
|
||||
pnpm cli calendar test <name>
|
||||
```
|
||||
|
||||
No command takes a refresh token. The worker reads the one `/admin/health` stored.
|
||||
|
||||
- **Database** — report the schema state, or apply pending migrations. Web and the worker
|
||||
both migrate at boot; this is the way in when neither will start.
|
||||
|
||||
```sh
|
||||
pnpm cli db status
|
||||
pnpm cli db migrate
|
||||
```
|
||||
|
||||
- **Email** — render and send a real test email through the worker (proves your
|
||||
SMTP + branding + templates work). Requires the worker to be running; it's
|
||||
triggered as a background job.
|
||||
|
||||
```sh
|
||||
pnpm cli email test you@example.com
|
||||
```
|
||||
| Command | Effect |
|
||||
| ---------------------------- | -------------------------------------------------------------------------------------------------- |
|
||||
| `config init` | Write starter YAML; refuse to overwrite. |
|
||||
| `config validate [path]` | Check shape and cross-references; `--check-env` checks environment values too. Nonzero on failure. |
|
||||
| `provider list` | Read recorded provider status; no network call. |
|
||||
| `provider test <name>` | Authenticate through the running worker and record the outcome. |
|
||||
| `provider calendars <name>` | Discover calendar `id` / `href` values through the worker. |
|
||||
| `calendar list` | List configured calendars. |
|
||||
| `calendar test <name>` | Refresh that calendar's busy mirror and record status. |
|
||||
| `db status` / `db migrate` | Report schema state / apply pending app migrations. |
|
||||
| `email test you@example.com` | Send a real branded test email through the worker. |
|
||||
+30
-186
@@ -1,201 +1,45 @@
|
||||
# Development
|
||||
|
||||
Working on "When" locally. Read [`architecture.md`](architecture.md) for how the system
|
||||
fits together and [`philosophy.md`](philosophy.md) for the principles changes are
|
||||
measured against.
|
||||
Read the [philosophy](philosophy.md). Then resist the urge to add a plugin system.
|
||||
|
||||
## Setup
|
||||
## run it
|
||||
|
||||
Requires Node 26 (pinned in `mise.toml`) and pnpm. Install dependencies:
|
||||
you can use Node 26 and pnpm (versions in `mise.toml`) directly. or you can use
|
||||
docker. one is documented-ish. the other is not.
|
||||
|
||||
### docker development
|
||||
|
||||
With Docker, mise and fnox installed, start from `apps/web`:
|
||||
|
||||
```sh
|
||||
pnpm install
|
||||
mkdir -p config data public
|
||||
cp -n ../../docs/when.example.yaml config/when.yaml
|
||||
cp -n ../../docs/.env.example .env
|
||||
openssl rand -base64 32
|
||||
```
|
||||
|
||||
## Running locally
|
||||
Fill in config and secrets, put the generated value in `AUTH_SECRET`, and set
|
||||
`ORIGIN=http://localhost:5173`. Create `docker-compose.dev.local.yml`:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
when-app:
|
||||
env_file: .env
|
||||
when-worker:
|
||||
env_file: .env
|
||||
```
|
||||
|
||||
Then run:
|
||||
|
||||
```sh
|
||||
pnpm dev
|
||||
mise docker up --build
|
||||
```
|
||||
|
||||
\`pnpm dev\` runs only the **web** app. To also deliver appointment emails and sync calendars,
|
||||
run the worker against the same config in a second terminal:
|
||||
|
||||
```sh
|
||||
CONFIG_PATH=apps/web/config/when.yaml pnpm --filter @when/worker dev
|
||||
```
|
||||
|
||||
See [`apps/worker/README.md`](../apps/worker/README.md) for worker specifics. For a
|
||||
hot-reloading stack in Docker:
|
||||
|
||||
```sh
|
||||
pnpm dev:docker
|
||||
```
|
||||
|
||||
## Scripts
|
||||
|
||||
Each package exposes the same script names; run one with `pnpm --filter @when/<pkg> <script>`,
|
||||
or across the workspace with `pnpm -r <script>`. Root `build`, `check`, `lint`, and
|
||||
`format` target the web app; root `test` and `test:coverage` run every workspace package.
|
||||
|
||||
- `build` — compile / typecheck the package.
|
||||
- `check` — typecheck (`tsc --noEmit`, or `svelte-check` for web).
|
||||
- `lint` — Prettier (covers Markdown too) + ESLint.
|
||||
- `test` / `test:coverage` — Vitest.
|
||||
|
||||
Build shared packages after installing dependencies or changing package source, then run
|
||||
from the root:
|
||||
|
||||
```sh
|
||||
pnpm build:packages
|
||||
pnpm test # all workspace Vitest suites, including integration tests
|
||||
pnpm test:coverage # the same suites with coverage
|
||||
pnpm test:e2e # Playwright (requires browsers installed)
|
||||
```
|
||||
|
||||
For a focused feedback loop, use `pnpm --filter @when/<pkg> test <file>`.
|
||||
The task combines this override with `docker-compose.dev.yml`. Web is on 5173,
|
||||
Mailpit on 8025. The [sample Compose file](compose.yaml) is for deployment, not
|
||||
hot reload.
|
||||
|
||||
## Testing
|
||||
|
||||
Vitest tests are co-located `*.test.ts` files next to the code. Integration tests use
|
||||
`*.integration.test.ts` and run in the same suite. End-to-end tests live in
|
||||
`apps/web/e2e` (Playwright).
|
||||
|
||||
### What earns a test its place
|
||||
|
||||
Prefer readable, focused regression protection over a coverage percentage. Existing
|
||||
coverage thresholds are guardrails, not a target to maximize.
|
||||
|
||||
- **Name the behavior and observe its outcome.** Assert the saved row, returned result,
|
||||
rendered text, or external request that matters. A test claiming to distinguish two
|
||||
outcomes needs fixture data that makes those outcomes distinguishable.
|
||||
- **Use the cheapest layer that proves the contract.** Pure calculations and validation
|
||||
belong in unit tests; transactions, migrations, and durable retries need real SQLite
|
||||
or workflow integration; hydration and browser navigation need Playwright.
|
||||
- **Mock at boundaries, not through the behavior being tested.** Inject a `Clock`, fake
|
||||
`Mailer`, or fake provider. Use the real workflow engine when the claim concerns
|
||||
checkpoint persistence or retry policy.
|
||||
- **Make setup explicit and isolated.** Prefer small named fixtures with scenario-specific
|
||||
overrides. Do not rely on test ordering, another test's rows, developer config, or a
|
||||
shared temporary filename.
|
||||
- **Permit harmless refactoring.** Assert accessible behavior and meaningful copy rather
|
||||
than source strings, incidental wrappers, object identity, or exact CSS dimensions.
|
||||
Exact output is appropriate when it is the contract, such as an ICS field or escaping.
|
||||
- **Budget runtime and duplication.** Keep each behavior at its lowest sufficient layer;
|
||||
an E2E journey need not repeat a unit-test matrix. Measure slow scenarios before
|
||||
deleting them: remove unnecessary waits before sacrificing integration evidence.
|
||||
- **Keep failures actionable.** Test names should explain the expectation. Use bounded,
|
||||
condition-based waits, retain diagnostics, and investigate retry-recovered failures.
|
||||
|
||||
### End-to-end
|
||||
|
||||
E2E covers what only a browser and a real server round trip can prove: SSR through
|
||||
hydration, form actions and redirects, the auth gate as an HTTP boundary, and the client
|
||||
router reconciling URL state. Availability maths, validation and templates stay in Vitest,
|
||||
where they are cheaper. Browser scenarios also cover guest capabilities, host editing,
|
||||
conditional forms, timezone persistence, and theme initialization.
|
||||
|
||||
It builds once, then runs the production adapter handler in one isolated server per
|
||||
Playwright worker (four by default, overridable with `--workers`). Each server has its
|
||||
own OS-assigned localhost port, copied fixture config, and app/queue databases under a
|
||||
unique `e2e/fixture/data/run-*` directory. Setup copies the web source, static assets,
|
||||
package manifest and build configs into a fresh project there, sharing only installed
|
||||
dependencies. It uses the normal Vite/SvelteKit configuration in every build phase;
|
||||
no previous build output is copied or required. Keep the explicit input list in
|
||||
`e2e/setup.ts` in step with any new local imports in the build configs.
|
||||
|
||||
Kit and adapter build output stay inside the run directory, so another build cannot
|
||||
replace a running test server's route chunks. The build and servers do not load
|
||||
developer dotenv files or inherit application settings. Your `config/when.yaml`,
|
||||
`data/`, build output, and dev server remain untouched; no existing server is reused.
|
||||
|
||||
Specs import `test` and `expect` from `./support/test.ts`. Its automatic fixture clears
|
||||
appointment, busy-mirror, service-status and queue rows before each test, preserving
|
||||
migration bookkeeping and live database connections. No background worker runs: tests
|
||||
assert queue admission, not email delivery or calendar publication. Servers stop before
|
||||
the run directory is removed.
|
||||
|
||||
- **Use the fixture's URL.** `page` and `request` receive the worker's `baseURL`; pass it
|
||||
explicitly to additional browser contexts, cookies, and Origin headers. Never hardcode
|
||||
a port.
|
||||
- **Derive dates.** Pick an available slot from the page, or use `support/dates.ts` for a
|
||||
future weekday and explicit scenario times. Tests need not reserve separate dates or
|
||||
slots for other tests, repeats, or retries.
|
||||
- **Assert the intended result.** `support/seed.ts` creates states needed by a scenario;
|
||||
`support/database.ts` reads back the specific appointment and queued workflow. Use
|
||||
persisted assertions when a heading alone cannot prove what was saved.
|
||||
|
||||
A failed test retains a screenshot and server output in `apps/web/test-results/`; the
|
||||
first retry also records a Playwright trace. CI rejects retry-recovered flaky tests.
|
||||
Inspect the screenshot, server log, and trace when a test fails. A later passing run
|
||||
does not explain an earlier failure; investigate its cause rather than adding retries.
|
||||
|
||||
Repeat scheduling tests locally without retries to check isolation, for example:
|
||||
|
||||
```sh
|
||||
pnpm --filter @when/web test:e2e booking.e2e.ts chain.e2e.ts --repeat-each=3 --retries=0
|
||||
```
|
||||
|
||||
Running it needs `pnpm build:packages` first: `vite build` resolves `@when/*` to `dist/`,
|
||||
not source. Browsers install once per Playwright version, and the version matters —
|
||||
`@playwright/test` and the browsers on disk must match, or Playwright cannot find an
|
||||
executable:
|
||||
|
||||
```sh
|
||||
pnpm --filter @when/web exec playwright install chromium
|
||||
```
|
||||
|
||||
CI runs the suite in both pipelines from a prebuilt image
|
||||
(`eleith/containers` → `playwright`) that carries the browsers and Node 26. Its tag and
|
||||
`@playwright/test` are a matched pair — **bump them together**. Publishing waits on the
|
||||
suite in both pipelines.
|
||||
|
||||
## Conventions
|
||||
|
||||
- **Tooling:** pnpm and Node 26 only — no bun, no `npm`/`npx`. Run TypeScript CLIs under
|
||||
`tsx`.
|
||||
- **Exact versions:** pin dependencies exactly (no `^`/`~` ranges); add with
|
||||
`pnpm add -E`. Shared tool versions live in the `pnpm-workspace.yaml` catalog
|
||||
(`"catalog:"`).
|
||||
- **Type safety at boundaries:** validate every external input (YAML, forms, API) against
|
||||
the config JSON Schema (ajv) — the repo does not use Zod. Database access goes through
|
||||
Kysely.
|
||||
- **Async readability:** assign awaited results to named variables before returning
|
||||
them or passing them as arguments.
|
||||
- **Time:** never call `new Date()` / `Date.now()` in domain logic; inject a `Clock`.
|
||||
- **Comments:** comment non-obvious rationale only. Don't restate what a name already
|
||||
says.
|
||||
- **Secrets:** keep secrets in `${ENV_VAR}` references, never in committed config. Never
|
||||
log request bodies, session tokens, `cancel_token`s, or decrypted secrets.
|
||||
- **Styling:** theme variables only, no Tailwind, copy in the markup — see the
|
||||
[styling section](architecture.md#styling-and-theming-web) of the architecture doc.
|
||||
|
||||
### Changing the config schema
|
||||
|
||||
[schema.ts](file:///home/eleith/dev/when/packages/config/src/schema.ts) (TypeBox) is the source of truth. After editing it, regenerate the relaxed JSON Schema:
|
||||
|
||||
```sh
|
||||
pnpm --filter @when/config generate:schema
|
||||
```
|
||||
|
||||
(This runs as part of `pnpm --filter @when/config build`.) Update [config/when.example.yml](file:///home/eleith/dev/when/apps/web/config/when.example.yml) and the fixtures if you add or change a field — the web app's `example.test.ts` fails until the example documents every schema field.
|
||||
|
||||
## Releases
|
||||
|
||||
When uses [Changesets](https://github.com/changesets/changesets) with fixed app grouping (`apps/web`, `apps/cli`, `apps/worker`) so all deployable applications stay synchronized in versioning.
|
||||
|
||||
1. **Document changes:** when finishing a feature or fix, record a changeset:
|
||||
```sh
|
||||
pnpm changeset
|
||||
```
|
||||
2. **Bump versions & changelogs:** when ready to release:
|
||||
```sh
|
||||
pnpm version:apps
|
||||
```
|
||||
Commit the resulting version bumps and changelog updates (`git commit -m "[when] release X.Y.Z"`).
|
||||
3. **Tag & deploy:**
|
||||
```sh
|
||||
pnpm tag:apps
|
||||
git push origin main --tags
|
||||
```
|
||||
This triggers the automated container image builds in Woodpecker CI and GitHub Actions.
|
||||
write more than zero but not so many they aren't worth their costs.
|
||||
Use the cheapest test that proves the behavior.
|
||||
+16
-46
@@ -1,55 +1,25 @@
|
||||
# Philosophy
|
||||
|
||||
This document explains _what_ "When" is and _why_ it is built the way it is. The
|
||||
tangible "how" lives in [`architecture.md`](architecture.md); read this first, because
|
||||
it explains the constraints every design decision is measured against.
|
||||
Schedule appointments for one person. Do it well. Stop there.
|
||||
|
||||
## What "When" is
|
||||
When is for the self-hoster who want a simple booking page that works well for
|
||||
them and aren't going to get surprised one day with enterprise features they
|
||||
are locked out of or source code that isn't open anymore (cough cough, i'm
|
||||
looking at you every-other-appointment-service-ive-used)
|
||||
|
||||
A scheduling tool for **one** person — the individual self-hoster who wants an appointment
|
||||
page on their own infrastructure. It is not, and will not become, a multi-tenant SaaS.
|
||||
## keep it simple
|
||||
|
||||
## Principles
|
||||
1. Simple: No teams, tenants, round-robin routing or plugin
|
||||
system.
|
||||
2. Fastish: fast enough page loads, slow work done in the worker.
|
||||
3. Low Maintenance: point one, but disguised as point three.
|
||||
|
||||
- **One schedulable user.** There is exactly one owner. No teams, no org accounts, no
|
||||
per-tenant routing. This single assumption removes most of the complexity that
|
||||
scheduling products accumulate, and we spend that saved complexity budget on doing
|
||||
the single-user case well.
|
||||
- **Radical simplicity.** Reject multi-tenancy, team routing, round-robin assignment,
|
||||
and enterprise workflow. Keep the surface area small enough to hold in your head and
|
||||
to test exhaustively.
|
||||
- **Configuration over UI.** The owner's setup lives in `when.yaml`, not in a
|
||||
database edited through admin screens. The app reads config; it does not manage it.
|
||||
This keeps state declarative, reviewable in version control, and free of migration
|
||||
churn for settings.
|
||||
- **No "just-in-case" code.** Build only what the single-user scope needs today. Don't
|
||||
add abstract interfaces, plugin points, or routing layers for features that don't
|
||||
exist yet. The adapter seams that _do_ exist (calendars) earn their keep by removing
|
||||
conditionals we'd otherwise write today — not by anticipating hypothetical providers.
|
||||
- **Explicit over implicit.** Avoid magic. Fail loudly with clear errors and logging.
|
||||
Prefer code that reads like what it does over clever indirection.
|
||||
## just a text file ...
|
||||
|
||||
## Why some of the bigger decisions went the way they did
|
||||
all your configuration lives in `when.yaml`. Edit it, back it up, put it in
|
||||
Git.
|
||||
|
||||
- **A worker process, separate from the web app.** Calendar I/O and email are slow and
|
||||
can fail; running them inside a request would make appointment scheduling feel fragile and slow.
|
||||
Instead the web app does the minimum on the request path (write the appointment, enqueue
|
||||
a job) and a background worker does the rest — sending emails, pushing to calendars,
|
||||
refreshing busy times — with durable retries. The request stays fast and the
|
||||
side-effects become observable and replayable.
|
||||
- **State in `when.yaml` + SQLite, nothing else.** Settings are config; appointments are
|
||||
rows. There is no third place state can hide. SQLite (via Node's built-in
|
||||
`node:sqlite`) means zero external services to run and a single file to back up.
|
||||
- **One JSON Schema as the source of truth for config.** Rather than hand-writing
|
||||
validators (e.g. Zod) _and_ types _and_ editor hints separately, we keep one
|
||||
canonical JSON Schema and generate the rest: strict TypeScript types, and a relaxed
|
||||
copy for editor autocomplete. Validation, types, and editor tooling can't drift
|
||||
because they share one origin. Required-but-defaulted fields stay strict in code (ajv
|
||||
fills the default at load) while the editor copy relaxes them so it doesn't nag.
|
||||
- **A single brand hue.** Branding is one `primary_color`; the rest of the palette is a
|
||||
tonal scale derived from it. One knob to set, a coherent theme guaranteed, and no
|
||||
surface for a self-hoster to build an inconsistent look.
|
||||
## ... and two sqlite files
|
||||
|
||||
When a change tempts you to add a tenant boundary, a settings table, a second brand
|
||||
color, or an abstraction for a provider you don't have — that's the signal to stop and
|
||||
re-read this page.
|
||||
Appointments live in one SQLite. Background jobs live in another. They didn't
|
||||
look as good in yaml.
|
||||
@@ -1,10 +1,8 @@
|
||||
# yaml-language-server: $schema=../../../packages/config/src/config.schema.json
|
||||
#
|
||||
# Example When configuration. Copy this to `when.yaml` in the same directory and
|
||||
# edit. The uncommented blocks are a complete, valid starter config; commented
|
||||
# lines document every remaining field and its default, so you can uncomment
|
||||
# what you need. Secrets are `${ENV_VAR}` references — set those in your
|
||||
# environment (docker loads them from `.env`).
|
||||
# yaml-language-server: $schema=http://localhost:3000/schema.json
|
||||
# Copy to config/when.yaml and replace the placeholders.
|
||||
# The schema comes from the running app; adjust its URL if you use another address.
|
||||
# Optional fields and defaults are commented below. Secrets use ${ENV_VAR};
|
||||
# the sample Compose file passes them from .env.
|
||||
|
||||
# Shape of this file. Defaults to 1 and only changes if the config layout does.
|
||||
# version: 1
|
||||
@@ -124,12 +122,13 @@ meetings:
|
||||
# additional_busy_calendars: ['personal'] # further calendars to check; booking_calendar is always checked
|
||||
# location: 'Suite 200, 123 Main St' # a static address, phone, or link
|
||||
# video_chat:
|
||||
# provider: 'google-service' # generate a video link per booking
|
||||
# provider: 'nextcloud-service' # configure the provider above first
|
||||
# attach:
|
||||
# auto: true # auto: true (default) or auto: false (admin generates on demand)
|
||||
# when: # or conditionally attach based on guest form answers
|
||||
# - field: 'topic'
|
||||
# equals: 'Video Meeting'
|
||||
# auto: true # or false for host-only generation; use auto OR when, not both
|
||||
# # when: # instead of auto, match the form below
|
||||
# # - field: 'topic'
|
||||
# # equals: 'Support'
|
||||
# Google Meet requires a Google booking calendar.
|
||||
# note: 'Please add an agenda when you book.'
|
||||
# notice_minutes: 120 # minimum lead time (default 120)
|
||||
# booking_window_days: 60 # how far ahead bookings open (default 60)
|
||||
@@ -138,12 +137,20 @@ meetings:
|
||||
# daily_booking_limit: null # null = unlimited
|
||||
# show_slots: false # true draws the slots as buttons; false is a bare drag timeline
|
||||
# start_times_every_minutes: 15 # slot step (default 30; independent of the lengths offered)
|
||||
# form_fields: # extra questions asked at booking
|
||||
# form_fields: # replaces the default form; include a required guest_name
|
||||
# - name: 'name'
|
||||
# type: 'guest_name'
|
||||
# label: 'Your name'
|
||||
# required: true
|
||||
# - name: 'email'
|
||||
# type: 'guest_email'
|
||||
# label: 'Your email'
|
||||
# required: true
|
||||
# - name: 'topic'
|
||||
# type: 'text' # guest_name | guest_email | event_location | text | number | phone | paragraph | choice
|
||||
# type: 'choice' # guest_name | guest_email | event_location | text | number | phone | paragraph | choice
|
||||
# label: 'What would you like to discuss?'
|
||||
# required: false
|
||||
# choices: ['Sales', 'Support'] # only for type: choice
|
||||
# choices: ['Sales', 'Support'] # only for choice or event_location
|
||||
# - name: 'phone'
|
||||
# type: 'phone'
|
||||
# label: 'Your phone number'
|
||||
@@ -1,88 +1,4 @@
|
||||
# @when/calendar
|
||||
|
||||
External-calendar I/O for the `when` workspace: provider adapters (Google,
|
||||
CalDAV), provider-expanded busy-time fetching, appointment push/delete, and local
|
||||
helpers like ICS generation.
|
||||
|
||||
The **provider-reaching logic** — the adapters and every network call to an
|
||||
external calendar (busy-time fetch, appointment push/update/delete) — is meant to
|
||||
run **only in the worker**. The worker is the only part of `when` that talks to
|
||||
external calendars: it fetches busy times into a local mirror and pushes
|
||||
appointments to the organizer's calendar. Web reads the already-expanded mirror
|
||||
from `@when/db` and makes no provider call.
|
||||
|
||||
Web does import this package, but only the local, network-free pieces — `buildIcs`
|
||||
(the `.ics` download endpoint) and `setLogger` (logging wiring). Tree-shaking
|
||||
keeps the adapter/provider code out of the web bundle because web never
|
||||
references it.
|
||||
|
||||
Calendar publishing writes appointment content only. Selected video URIs appear in
|
||||
appointment descriptions (including non-HTTP URIs); publishing does not create,
|
||||
copy, clear, or adopt conferences. Google updates use PATCH with an encoded event
|
||||
ID; creates use POST. Neither sends `conferenceData` or applies video attach policy.
|
||||
|
||||
## Busy-time contract
|
||||
|
||||
Both adapters return concrete occurrences with UTC start/end instants and a provider
|
||||
identity for own-event exclusion: CalDAV UID or Google event ID.
|
||||
Google uses `singleEvents=true`; CalDAV requests `CALDAV:expand` for the same bounded
|
||||
window as its query filter ([RFC 4791 §9.6.5](https://www.rfc-editor.org/rfc/rfc4791.html#section-9.6.5)).
|
||||
There is no local recurrence engine or fallback when a server ignores expansion.
|
||||
|
||||
CalDAV imports reject failed resources or calendar-data properties, malformed calendar
|
||||
data, remaining recurrence rules/exclusions, and unresolved timezone references. The
|
||||
expanded-data reader accepts VCALENDAR containing VEVENTs with optional nested VALARMs;
|
||||
unrequested component types are rejected rather than interpreted as event properties.
|
||||
The worker retains the previous busy mirror and records a refresh failure. A successful
|
||||
import with no remaining busy intervals clears it. Cancelled and transparent occurrences are discarded. Multiple occurrences sharing a
|
||||
UID remain distinct, while the common path excludes our own published UIDs and keeps
|
||||
intervals overlapping the requested window without clipping their bounds.
|
||||
|
||||
Date-only and floating date-times retain the existing UTC interpretation. The REPORT
|
||||
supplies a UTC `CALDAV:timezone` ([RFC 4791 §9.8](https://www.rfc-editor.org/rfc/rfc4791.html#section-9.8))
|
||||
so server selection uses that same policy instead of the calendar's default timezone.
|
||||
Explicit timezone-referenced date-times are still resolved by the server and returned
|
||||
in UTC. Durations on concrete occurrences use elapsed UTC time, including 24-hour days;
|
||||
no server-local Date arithmetic is used. Outgoing ICS generation and publishing are unchanged.
|
||||
|
||||
### CalDAV XML parsing
|
||||
|
||||
REPORT imports and PROPFIND discovery share one `@rgrove/parse-xml` boundary for
|
||||
XML 1.0 syntax, entity decoding, and namespace-aware field selection. REPORT checks
|
||||
resource/property statuses and payload cardinality before importing busy times.
|
||||
Discovery reads principal, home-set, and calendar properties from the same tree;
|
||||
hrefs and display names must be text-only and are XML-decoded once, without adding
|
||||
a percent-decoding step. Missing discovery properties retain the base-URL and
|
||||
unnamed-calendar fallbacks, but malformed XML is rejected rather than returning
|
||||
a partial calendar list.
|
||||
|
||||
Comments and processing instructions are ignored; namespace conformance inside
|
||||
ignored extension content is not validated. Literal CRLF/CR becomes LF;
|
||||
numeric carriage-return references remain intact. DTDs are rejected, and parser
|
||||
errors are sanitized so calendar contents do not appear in errors.
|
||||
|
||||
REPORT source text remains limited to 16 × 1024 × 1024 UTF-16 code units, not bytes.
|
||||
The reader checks this while decoding response chunks and cancels an oversized
|
||||
response instead of importing a prefix. Direct parsing checks the same ceiling
|
||||
before newline normalization. There is no application XML nesting ceiling; the
|
||||
library is recursive and may reject deeply nested input. Read or parsing failures
|
||||
leave the previous mirror intact.
|
||||
|
||||
The text ceiling is not a CalDAV requirement, measured capacity target, or total
|
||||
process-memory limit. Received chunks, parsing, and interval construction still
|
||||
require additional memory.
|
||||
|
||||
## Google event I/O
|
||||
|
||||
`getGoogleEvent` reads a stored event. `patchGoogleEvent` sends supplied fields and
|
||||
query parameters to that event. Both reuse Google authentication and return
|
||||
unvalidated response data. PATCH 204 returns `undefined`, distinct from JSON `null`.
|
||||
|
||||
The Google adapter in `@when/video-chat` accepts event operations backed by these
|
||||
functions. Conference payloads and interpretation belong to that adapter, not the
|
||||
calendar transport. Neither package owns worker retries or appointment writes.
|
||||
|
||||
## Scripts
|
||||
|
||||
- `pnpm build` — typecheck (`tsc -p tsconfig.json`) then emit `dist/`.
|
||||
- `pnpm test` / `pnpm test:coverage` / `pnpm lint`.
|
||||
Google and CalDAV adapters, busy-time imports and ICS helpers. Provider calls
|
||||
belong in the worker; web uses only the local helpers.
|
||||
@@ -1,25 +1,4 @@
|
||||
# @when/config
|
||||
|
||||
Shared configuration loading and schema for the `when` workspace.
|
||||
|
||||
Owns the canonical config schema [schema.ts](file:///home/eleith/dev/when/packages/config/src/schema.ts) defined using TypeBox. The loader parses, interpolates `${ENV}` references, fills defaults (including a few that depend on siblings, e.g. a slug derived from a meeting name), validates with TypeBox, and performs cross-reference validation.
|
||||
|
||||
The source schema keeps every defaulted field strict (non-optional) so TypeScript types are strong. The generated JSON Schema ([config.schema.json](file:///home/eleith/dev/when/packages/config/src/config.schema.json)) is programmatically relaxed (omitted-but-defaulted fields are removed from `required` rules) so an editor pointed at it via `# yaml-language-server: $schema=...` doesn't flag them as missing. Point a `when.yaml` to this generated schema.
|
||||
|
||||
Consumed by `@when/web` (producer) and `@when/worker`. App-specific boot glue
|
||||
(SvelteKit `$env`, metrics) stays in the consuming app.
|
||||
|
||||
## API
|
||||
|
||||
- `loadConfigFile(path)` — read + parse + validate a `when.yaml`.
|
||||
- `validateConfig(raw)` — validate an already-parsed object.
|
||||
- `interpolate(node, env?)` — expand `${ENV_VAR}` references.
|
||||
- `ConfigError`, `MissingEnvVarsError` — typed failures.
|
||||
- `schema` / `externalSchema` — the generated relaxed JSON Schema.
|
||||
- Types: `WhenConfiguration`, `Meeting`, and all other statically generated TypeBox types.
|
||||
|
||||
## Scripts
|
||||
|
||||
- `pnpm generate:schema` — regenerate `src/config.schema.json` from `src/schema.ts`.
|
||||
- `pnpm build` — run `generate:schema`, compile TS files via `tsc`, and build assets.
|
||||
- `pnpm test` / `pnpm lint`.
|
||||
YAML loading, defaults and validation from the [TypeBox schema](src/schema.ts).
|
||||
See [configuration](../../docs/config.md) for fields and examples.
|
||||
+2
-62
@@ -1,64 +1,4 @@
|
||||
# @when/db
|
||||
|
||||
Shared SQLite data layer for the `when` workspace.
|
||||
|
||||
Wraps Node's built-in `node:sqlite` behind a small Kysely dialect (no native
|
||||
addons), and owns the schema types, migrations, and migration runner. Consumed
|
||||
by `@when/web` and `@when/worker`, which both open the same `when.sqlite`.
|
||||
|
||||
## API
|
||||
|
||||
- `openDb(path)` — open (and, for file paths, create the parent dir of) a
|
||||
`node:sqlite` database as a typed `Kysely<Database>`.
|
||||
- `runMigrations(db)` — apply pending migrations; returns the applied names (the
|
||||
caller logs — the package stays logger-agnostic).
|
||||
- `migrations` — the ordered migration set.
|
||||
- `withWriteTransaction(db, write)` — reserve SQLite write access before a short
|
||||
read-modify-write operation. The callback gets a leased Kysely connection, not a
|
||||
`Transaction`. Use only that connection and await all queries before returning;
|
||||
do not nest transactions, close or retain it. Returning commits; throwing rolls
|
||||
back. Lock acquisition can still time out.
|
||||
- `findAppointment(db, id)` / `getAppointment(db, id)` — read an appointment by ID.
|
||||
`findAppointment` returns `undefined` when missing; `getAppointment` requires a row
|
||||
and rejects with Kysely's `NoResultError` when missing.
|
||||
- `recordVideoChatGenerationRequest(db, appointmentId)` — store standing `generate`
|
||||
intent and return its calendar revision. Repeating it preserves the entire row;
|
||||
changing it increments only the calendar revision and update time, not ICS or history.
|
||||
Call inside the write transaction that checked eligibility.
|
||||
- `lastCalendarRefreshAt(status)` — proven mirror-refresh timestamp, or `null` when
|
||||
the latest observation cannot establish refresh freshness. Shared by worker and web.
|
||||
- `recordCalendarEventReference(db, appointmentId, reference)` — record which
|
||||
calendar event belongs to an appointment, filling only compatible missing IDs.
|
||||
Recording a new reference invalidates sync evidence; repeating it does nothing.
|
||||
- `markSynced(db, appointmentId, revision, reference)` — record sync evidence only
|
||||
while the revision and both reference IDs still match on a non-purged row. The
|
||||
required reference permits null IDs, compared null-safely.
|
||||
- `recordCalendarEventDeletion(db, appointmentId, revision, reference)` — atomically
|
||||
clear both IDs and record sync evidence only for a matching revision and complete
|
||||
reference on a non-purged row. Records a receipt after provider deletion; does not
|
||||
contact the provider. Replaying after the IDs are cleared is a no-op.
|
||||
- `listActiveAppointmentsForCalendarEvent(db, reference)` — return the pending and
|
||||
confirmed appointments referencing that event. This reads local rows, not the provider.
|
||||
- `parseNotificationStatus(raw)` — parse the `appointments.notification_status`
|
||||
JSON column.
|
||||
- Types: `Database`, `Appointment`, `NewAppointment`, `AppointmentUpdate`,
|
||||
`AppointmentStatus`, `VideoChatRequest`, `NotificationStatus`, `NotificationOutcome`, …
|
||||
|
||||
A `CalendarEventReference` contains `{ calendarId, eventId }`: the configured
|
||||
calendar and the provider's event ID. The query returns the appointments themselves,
|
||||
so callers can distinguish the current appointment from others using the event.
|
||||
|
||||
`video_chat_request` is a standing instruction, not job progress:
|
||||
|
||||
- `NULL` follows configured attachment rules.
|
||||
- `generate` asks for a generated link when one is missing, regardless of attachment rules.
|
||||
- `suppress` prevents generation without removing a selected URL.
|
||||
|
||||
The selected URL remains in `video_chat`. Existing and new appointments default to
|
||||
`NULL`. Migration does not interpret activity history or change existing URLs, ICS
|
||||
sequences or calendar revisions; historical Remove choices are not carried forward.
|
||||
|
||||
## Scripts
|
||||
|
||||
- `pnpm build` — typecheck (`tsc --noEmit`).
|
||||
- `pnpm test` / `pnpm test:coverage` / `pnpm lint`.
|
||||
The app's SQLite schema, queries and migrations, using `node:sqlite` and Kysely.
|
||||
The workflow queue lives in [@when/jobs](../jobs/README.md), not here.
|
||||
+5
-96
@@ -1,99 +1,8 @@
|
||||
# @when/jobs
|
||||
|
||||
Shared workflow contracts for the `when` workspace, built on
|
||||
[OpenWorkflow](https://openworkflow.dev) `0.11.0` over `node:sqlite`.
|
||||
Shared workflow contracts and OpenWorkflow queue access for web, CLI and worker.
|
||||
Producers enqueue work; the worker implements it.
|
||||
|
||||
The producer (`@when/web`, or `@when/cli` for service probes) enqueues work from a
|
||||
shared spec; `@when/worker` registers the implementation. Web never imports worker
|
||||
implementations. Initializing a producer client does not start a worker or register
|
||||
recurring schedules.
|
||||
|
||||
## API
|
||||
|
||||
- `reconcileAppointment` — coordinate appointment calendar/video updates and email.
|
||||
- `purgeAppointment` — clean up purged appointment chains.
|
||||
- `syncCalendars` — `{}` → `requested`, after the actual calendar repair scan finishes.
|
||||
- `refreshCalendars` — `{}` → `refreshed`, after refresh and conflict checks.
|
||||
- `expirePendingAppointments` — `{}` → `expired`, after idempotent expiry SQL.
|
||||
Completed maintenance passes can include recorded provider failures.
|
||||
- `testEmail`, `testProvider`, `listProviderCalendars`, `testCalendar`,
|
||||
`generateVideoChat` — on-demand worker operations.
|
||||
- `initOpenWorkflow({ dbPath, logger? })` — open and migrate the queue database and
|
||||
initialize the process-wide client. The first call wins, including its logger.
|
||||
- `getOpenWorkflow()` — return that client; throws before initialization or after
|
||||
closing.
|
||||
- `closeOpenWorkflow()` — idempotently close the queue connection, **after stopping
|
||||
its worker**. Closing an initialized client is terminal for that process. Calling
|
||||
this before initialization is a no-op.
|
||||
- `getWorkflowRun(db, id)`, `getStepAttempts(db, id)` — read queue history through a
|
||||
caller-owned `DatabaseSync` connection.
|
||||
|
||||
Specs and their input/output types live in `src/specs.ts`. The `./specs` entrypoint
|
||||
exports contracts without the process-wide client helpers.
|
||||
|
||||
Queue migrations run on connection, including producer connections. Do not start a
|
||||
new producer against the old deployment during cutover. Saved results do not make
|
||||
changed application graphs safe to replay. Follow the
|
||||
[grouped rollout runbook](../../docs/deployment.md#grouped-workflow-rollout): drain all
|
||||
unfinished old work before deployment, then stop and back up both SQLite databases
|
||||
consistently. Never automatically rerun terminal history. Reusing an enqueue key
|
||||
can return an existing terminal run within the engine's 24-hour dedup window.
|
||||
|
||||
Prepared envelopes contain personal data and guest capability links; queue files and
|
||||
backups are sensitive. Remote effects and logs can duplicate after a crash before
|
||||
checkpointing, so neither durable steps nor enqueue keys provide exactly-once effects.
|
||||
|
||||
## Schedule management
|
||||
|
||||
Only native worker `start()` persists the declarations from registered implementations.
|
||||
Startup awaits it, then enqueues refresh, expiry and sync independently with `{}` and
|
||||
no run options. Their UTC schedules are `*/5 * * * *`, `0 * * * *`, and `*/10 * * * *`,
|
||||
with IDs `when:refresh-calendars`, `when:expire-pending-appointments`, and
|
||||
`when:sync-calendars`. Upserts preserve pause; startup runs do not depend on cron's pause
|
||||
state. Cron is wall-clock aligned and catches up once per overdue schedule, not per
|
||||
missed tick. No application timers or coalescers supplement it.
|
||||
|
||||
These are the installed `0.11.0` public APIs, not `when-cli` commands. Given an initialized
|
||||
client connected to the intended queue, list every page:
|
||||
|
||||
```ts
|
||||
const client = getOpenWorkflow();
|
||||
let after: string | undefined;
|
||||
do {
|
||||
const page = await client.schedules.list({ limit: 100, after });
|
||||
console.table(
|
||||
page.data.map(({ id, cron, timezone, nextRunAt }) => ({
|
||||
id,
|
||||
cron,
|
||||
timezone,
|
||||
nextRunAt
|
||||
}))
|
||||
);
|
||||
after = page.pagination.next ?? undefined;
|
||||
} while (after);
|
||||
```
|
||||
|
||||
Manage one schedule explicitly (the following are separate operator actions):
|
||||
|
||||
```ts
|
||||
await client.schedules.pause('when:sync-calendars');
|
||||
await client.schedules.resume('when:sync-calendars');
|
||||
await client.schedules.delete('when:sync-calendars');
|
||||
```
|
||||
|
||||
`nextRunAt === null` means paused; resume computes the next future occurrence. Pause
|
||||
and delete do not cancel already queued runs or suppress independent startup runs.
|
||||
Removing a declaration alone leaves its persisted schedule active. To retire a target,
|
||||
stop the worker, delete its schedule through the public API, and remove the declaration
|
||||
before restarting; otherwise native startup recreates it. Schedule-management connections
|
||||
also migrate queues, so use the matching deployed version, not a new client for old-queue
|
||||
read-only inspection.
|
||||
|
||||
## Scripts
|
||||
|
||||
- `build` — typecheck and compile to `dist/`.
|
||||
- `check` — typecheck without emitting.
|
||||
- `test` / `test:coverage` — unit and real-engine integration tests.
|
||||
- `lint` — Prettier and ESLint.
|
||||
|
||||
Tests use temporary queues, never a personal deployment database.
|
||||
Opening a queue client runs migrations. Follow the
|
||||
[update guidance](../../docs/deployment.md#updates) before
|
||||
connecting a new version to an old queue.
|
||||
@@ -1,34 +1,4 @@
|
||||
# @when/video-chat
|
||||
|
||||
Callers can obtain a `VideoChatAdapter` from the configured service, supplying
|
||||
an event binding for Google:
|
||||
|
||||
```ts
|
||||
const adapter = getVideoChatAdapter(service, bookingEvent);
|
||||
const result = await adapter.request({ name, requestId });
|
||||
```
|
||||
|
||||
The shared contract has three operations:
|
||||
|
||||
- `request({ name, requestId })` requests a link. The name labels a standalone room;
|
||||
the request ID supports providers with creation idempotency.
|
||||
- `read(knownUrl)` observes the provider resource. The URL may be `null` when no
|
||||
standalone room is known yet.
|
||||
- `removeLink(knownUrl)` removes the provider's link or room, not appointment data.
|
||||
|
||||
The factory selects from `service.type`. Nextcloud uses its server configuration;
|
||||
Google requires the already-booked event supplied as the second argument. The
|
||||
Google implementation owns that event dependency, not the shared interface.
|
||||
|
||||
Google reads and updates conferencing on that event; removal does not delete the
|
||||
booking event. Its event operations are backed by `@when/calendar`, without a
|
||||
reverse package import. The event object stays in memory, not in checkpoints.
|
||||
|
||||
Nextcloud creates, reads and deletes standalone rooms. A missing URL means no known
|
||||
room; a failed lookup of a known URL is not evidence that it is safe to replace it.
|
||||
`nextcloudRoomToken` identifies canonical room aliases within one installation.
|
||||
|
||||
A successful observation may be ready, pending, absent, failed or ambiguous.
|
||||
`ok: false` means the I/O operation failed, not that a conference is absent.
|
||||
The worker owns current-choice and reference checks, native checkpoints, retries,
|
||||
appointment writes and notification handoff. Adapters do no polling or persistence.
|
||||
Google Meet and Nextcloud Talk adapters. The worker owns retries and persistence;
|
||||
these adapters make provider calls, not scheduling decisions.
|
||||
Reference in new issue
Block a user