Files
NextChair/manager/README.md
T
Stefan Lazic 17eee5b932
CI / ci (push) Canceled after 0s
Updates 1
The session built:

Appointments page: a single list, newest first.
Block Time: edit and delete.
Customer CSV export: with barber notes excluded.
Guest-link regression test: a test that an appointment id alone is rejected, plus fixes to the stale docs.

It built the scheduler, job runner with live progress, backup and restore, and the health alerts. Its own report says a real delete-and-restore on throwaway Docker passed, but I haven’t repeated that. It also says real SMTP and large dumps were not exercised. The manager image needs rebuilding for the backups to work, because it now installs 7zip.
2026-10-17 22:29:00 +01:00

136 lines
6.6 KiB
Markdown

# NextChair Manager
Standalone platform-operator tool for provisioning and managing NextChair
business deployments on the shared multi-business VPS setup (see
`../stacks/`). Not part of the main app — a genuinely separate Next.js
app, own `package.json`, own build, own container.
## Why it's a separate app
An earlier attempt at this exact feature was built as routes inside the
main app (`src/app/manager/...`). It was reverted after repeated,
never-fully-resolved bugs: the main app's `src/proxy.ts`, root layout,
root page, and global `SessionProvider` all statically import Prisma /
Auth.js / business branding — code a Manager (which has no business
database of its own, by definition) kept transitively pulling in, causing
crashes in "Manager mode."
This app avoids that class of bug structurally rather than by careful
avoidance: it never imports anything from the main app's `src/` at all.
A few small pieces (bcrypt convention, the signed-cookie primitive) were
copied and adapted, not imported — see `src/lib/auth.ts` and
`src/lib/session.ts`.
## What it does
- Lists provisioned businesses and their running/stopped state.
- **Add Business**: collects a name/slug/domain, then generates secrets,
creates that business's database + user on the shared MySQL server,
writes its `stacks/<slug>/compose.yaml` + `.env` + Caddy site file,
reloads Caddy, starts the container, and runs its migrations. See
`src/lib/provisioning.ts` for the exact sequence.
- Per business: start / stop / restart / update (recreate the container
against the current shared image tag — not a source rebuild, and run as
a background job with live progress and an optional "back up first") /
view recent logs.
- Does **not** duplicate the `/install` wizard — after provisioning, the
operator is linked to `https://<domain>/install` to finish that
business's own setup.
## Scheduler, jobs, backups and alerts
All of this runs inside the Manager process — no host crontab, no extra
service. State lives next to `global-settings.json`, under
`STACKS_DIR/manager/` (and `STACKS_DIR/backups/`), so it survives rebuilds.
- **Scheduler** (`src/instrumentation.ts` → `src/lib/scheduler*.ts`): one
guarded interval, a task registry with per-task intervals and
per-business overlap guards, last-run results in `scheduler-state.json`
(shown at Settings → Monitoring & scheduler). Tasks: notification cron
(`POST /api/cron/process-notifications` with each stack's `CRON_SECRET`,
every 30 s by default), daily Google reviews sync, health checks (every
3 min by default), scheduled backups, hourly housekeeping, and an
optional external heartbeat (`MANAGER_HEARTBEAT_URL`). Turn it off with
`MANAGER_SCHEDULER_DISABLED=1`.
**Fallback:** `stacks/infra/scripts/process-cron.sh` (host crontab) still
works and does the same notification/reviews calls — use it only if the
scheduler is disabled, never both.
- **Jobs** (`src/lib/jobs.ts`, `locks.ts`): Update, Backup, Restore and
Delete run as background jobs writing `manager/jobs/<id>.json` (status,
steps, log tail); the UI polls `GET /api/jobs/<id>`. Each holds a
per-business lock (`manager/locks/<slug>.json`) so they can't overlap.
A lock whose heartbeat is older than 2 minutes is stale and may be taken
over; on boot, jobs left "running" are marked failed and their locks
released.
- **Backups** (`src/lib/backup*.ts`, `restore.ts`; UI at a business's
*Backups* page and the global *Restore* page): an encrypted `.7z`
(`7zz`, AES-256, encrypted headers) of the database dump, media and
config, in `STACKS_DIR/backups/<slug>/`. Written as `.partial`, verified
with `7zz t`, then renamed; the newest N (default 2, per business) are
kept. Safety backups (before an update or a replacing restore) and final
backups (before a delete) are kept separately and never evict them. One
passphrase protects everything: generated at the first enable / first
*Back up now*, stored encrypted (`MANAGER_SETTINGS_ENCRYPTION_KEY`),
**shown once** — save it, a fresh server needs it to restore. Restore
works onto an existing business (after a safety backup) or a server that
never had it; see `RESTORE_STEPS` in `src/lib/restore.ts`. A backup
whose last migration is newer than the app image's is refused.
- **Alerts** (`src/lib/health-watcher.ts`): per-business ok / degraded /
down. Two consecutive bad checks before the first email; emails on
change and on recovery, and a daily reminder while still bad. Set the
operator address and toggles at Settings → Monitoring & scheduler (uses
the Email settings' SMTP). Businesses stopped from the Manager, or mid
update/backup/restore, are not probed. An external check is still needed
for "the whole VPS is down" — use `MANAGER_HEARTBEAT_URL`.
## How auth works
A single dedicated platform-operator credential (`MANAGER_EMAIL` /
`MANAGER_PASSWORD_HASH`, bcrypt cost 12) — not tied to any one business's
own Super Administrator, since this tool spans every business's separate
database and has no "primary" one of its own. A signed (HMAC, not
encrypted) cookie holds the session; see `src/lib/session.ts`.
## The Docker-socket trust boundary
This container has the host's Docker socket mounted (`docker.sock`) so it
can run `docker compose`/`docker exec` against the host's dockerd — see
`src/lib/docker.ts`. Root inside this container plus that socket
effectively means root on the host. Consequences:
- It must stay behind the login above at all times — never expose it
without that gate, and don't reuse `MANAGER_PASSWORD_HASH` anywhere
else.
- `STACKS_DIR` must be bind-mounted at the *identical absolute path* on
both host and container (see `.env.example`) — a `docker compose -f
<path>` command run from inside this container is executed by the
*host's* dockerd, so the path only means the same file on both sides if
the mount doesn't change it.
## Setup
```bash
cp manager/.env.example stacks/manager/.env # fill in real values
docker compose -f stacks/manager/compose.yaml --env-file stacks/manager/.env up -d --build
```
Requires `stacks/infra/compose.yaml` already running (shared MySQL,
Caddy, the `next-chair-edge` network). Add a Caddy site for this app the
same way as any business (see `stacks/infra/sites/.gitkeep`), pointed at
`next-chair-manager:3000`.
## Local development
```bash
cd manager
npm install
cp .env.example .env.local
npm run dev
```
Requires network access to a real MySQL server and `docker`/`docker
compose` on the machine you run it from for the provisioning/stack
actions to actually work — the dashboard's read-only parts (listing
stacks) will also just show nothing if `STACKS_DIR` doesn't point at a
real directory yet.