CI / ci (push) Canceled after 0s
Built No-show flag and block: Two new numbers under Booking Rules (blank means off). A symbol appears on the clients list, client page, calendar entries and appointment pages. Online booking is refused at the block number, and customers are told at the details step. Staff can still book a blocked customer in, and an owner can reset someone with a two-tap button. The count resets when they attend, and matches the same email or phone, so a guest can’t dodge it with a new profile. Email rules: staff and owners get email only for customer cancellations, schedule changes and invitations. Routine events (new booking, reschedule) are push-only. Nobody is told about their own action. A barber reassignment sends one customer email instead of two. Reward expiry sends one warning. Email redesign: your brand colour, a details card, Add to calendar and View on map links, a preferences link, hidden preview text, and dark mode. Replies now go to the business’s own email. WhatsApp “due now” queue: a list in time order with a highlighted next one, an owner setting for how many hours ahead (12–72), and a once-a-day push telling each barber how many reminders are waiting. Monitoring: /api/health now reports whether notifications are actually going out. The worker resets stuck deliveries and alerts the owner on the first setup failure (such as a bad SMTP login). The app emails your operator address about server errors, and the manager pushes that address to each business. Google scope: now only calendar.app.created. Events go to a calendar the app creates in the staff member’s Google account, called “<Business> bookings”.
137 lines
6.7 KiB
Markdown
137 lines
6.7 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): a plain `.7z`
|
|
(`7zz`, **not password-protected**) 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.
|
|
Because there is no password, an archive holds the business's secrets in
|
|
the clear (its `.env`: database password, auth secret, SMTP password,
|
|
VAPID private key) — files are written owner-only (0600) and the
|
|
download route is behind the Manager login; keep any downloaded copy as
|
|
private as the server itself. 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.
|