Compare commits
47
Commits
9fe5b4f038
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
70cb874ff5 | ||
|
|
21299f0287 | ||
|
|
96d5533638 | ||
|
|
95ff9bd70c | ||
|
|
68a62e02a4 | ||
|
|
17eee5b932 | ||
|
|
9f981173f4 | ||
|
|
31c48296d9 | ||
|
|
766a0808ab | ||
|
|
05ea83e059 | ||
|
|
2ed9c5ecca | ||
|
|
04d6192d51 | ||
|
|
a8a8d1e045 | ||
|
|
8c267eff25 | ||
|
|
a530d00aa2 | ||
|
|
1442d33d55 | ||
|
|
7338c9d9e2 | ||
|
|
410aa07596 | ||
|
|
0d821ce1f8 | ||
|
|
91f3fd77d7 | ||
|
|
4573587b78 | ||
|
|
7224c87cef | ||
|
|
1932cf7774 | ||
|
|
de32175916 | ||
|
|
65e141d527 | ||
|
|
0e39ca7cdc | ||
|
|
b1fce9c002 | ||
|
|
f7fad3d1a0 | ||
|
|
f24d22fdf1 | ||
|
|
bfa13c5f15 | ||
|
|
1a76d1cacb | ||
|
|
cbeccc6c1f | ||
|
|
e9e28a243f | ||
|
|
d7a8b15482 | ||
|
|
217c0b0101 | ||
|
|
4763b50e62 | ||
|
|
973cb9efc6 | ||
|
|
479cefd085 | ||
|
|
106f89a6da | ||
|
|
2d4f9c5bf0 | ||
|
|
973187dcc7 | ||
|
|
94aa1b2f26 | ||
|
|
ba46334179 | ||
|
|
2e739c9a26 | ||
|
|
3b07124d7e | ||
|
|
c92ee46d76 | ||
|
|
a0d39026c0 |
No files matched your search
@@ -6,6 +6,12 @@
|
|||||||
"runtimeExecutable": "npm",
|
"runtimeExecutable": "npm",
|
||||||
"runtimeArgs": ["run", "dev"],
|
"runtimeArgs": ["run", "dev"],
|
||||||
"port": 3000
|
"port": 3000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "nextchair-manager-dev",
|
||||||
|
"runtimeExecutable": "npm",
|
||||||
|
"runtimeArgs": ["--prefix", "manager", "run", "dev", "--", "-p", "3100"],
|
||||||
|
"port": 3100
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,13 @@ node_modules
|
|||||||
generated
|
generated
|
||||||
.git
|
.git
|
||||||
.gitea
|
.gitea
|
||||||
|
# A fully separate app (own package.json, own Dockerfile) — has no
|
||||||
|
# business being copied into the main app's build context at all, and
|
||||||
|
# its own @/* import alias resolving against the main app's tsconfig
|
||||||
|
# (via the include glob's **/*.ts) broke a plain `docker build` here
|
||||||
|
# before this line existed.
|
||||||
|
manager
|
||||||
|
stacks
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
|
|||||||
+56
-27
@@ -1,10 +1,11 @@
|
|||||||
# One business's config on the shared multi-business VPS setup — see
|
# One business's config on the shared multi-business VPS setup — see
|
||||||
# docker-compose.infra.yaml for the one-time setup this depends on
|
# stacks/infra/compose.yaml for the one-time setup this depends on
|
||||||
# (creating this business's database on the shared MySQL server) and
|
# (creating this business's database on the shared MySQL server) and
|
||||||
# docker-compose.<business>.yaml for how this file is used. Copy this to
|
# stacks/<slug>/compose.yaml for how this file is used. Copy this to
|
||||||
# `.env.business.<slug>` (e.g. `.env.business.blake-heritage` — kept out
|
# `stacks/<slug>/.env` (kept out of git, see .gitignore) per business and
|
||||||
# of git, see .gitignore) per business and fill in real values. Nothing
|
# fill in real values. Nothing here is a real credential. Normally the
|
||||||
# here is a real credential.
|
# management UI (manager/) generates this file for you — this template is
|
||||||
|
# for the by-hand setup path.
|
||||||
#
|
#
|
||||||
# The app starts and runs a complete first-install flow with ONLY the
|
# The app starts and runs a complete first-install flow with ONLY the
|
||||||
# "Required for basic startup" section below filled in — every other
|
# "Required for basic startup" section below filled in — every other
|
||||||
@@ -12,47 +13,63 @@
|
|||||||
# disabled state rather than crashing when its variables are left blank.
|
# disabled state rather than crashing when its variables are left blank.
|
||||||
|
|
||||||
# ── Required for basic startup ─────────────────────────────────────────
|
# ── Required for basic startup ─────────────────────────────────────────
|
||||||
# The public domain this business is served on — used by Traefik's
|
# Manager-only bookkeeping (shown on the dashboard/edit screen, editable
|
||||||
# routing label (see docker-compose.<business>.yaml) to decide which
|
# there) — the app itself never reads this; its own business name lives
|
||||||
# container handles requests for it, so this must be set and must match
|
# in the database, set via /install.
|
||||||
# the domain's real DNS (pointed at the VPS) before Traefik can issue a
|
BUSINESS_NAME="Example Business"
|
||||||
|
# The public domain this business is served on — must match the .caddy
|
||||||
|
# site file's own domain (see stacks/infra/sites/.gitkeep) and that
|
||||||
|
# domain's real DNS (pointed at the VPS) before Caddy can issue a
|
||||||
# certificate for it.
|
# certificate for it.
|
||||||
DOMAIN="blake-heritage.example.com"
|
DOMAIN="example-business.example.com"
|
||||||
|
|
||||||
# The app reaches MySQL via the shared infra's service hostname
|
# The app reaches MySQL via the shared infra's service hostname
|
||||||
# `next-chair-mysql`, never `localhost` — see docker-compose.infra.yaml.
|
# `next-chair-mysql`, never `localhost` — see stacks/infra/compose.yaml.
|
||||||
# DATABASE_NAME/USER/PASSWORD must match exactly what you created for
|
# DATABASE_NAME/USER/PASSWORD must match exactly what you created for
|
||||||
# this business on the shared server (see docker-compose.infra.yaml's
|
# this business on the shared server (see stacks/infra/compose.yaml's
|
||||||
# "CREATE DATABASE ..." step) — this file doesn't create them, it only
|
# "CREATE DATABASE ..." step) — this file doesn't create them, it only
|
||||||
# tells the app what to connect as.
|
# tells the app what to connect as.
|
||||||
DATABASE_HOST="next-chair-mysql"
|
DATABASE_HOST="next-chair-mysql"
|
||||||
DATABASE_PORT="3306"
|
DATABASE_PORT="3306"
|
||||||
DATABASE_NAME="blake_heritage"
|
DATABASE_NAME="example_business"
|
||||||
DATABASE_USER="blake_heritage"
|
DATABASE_USER="example_business"
|
||||||
DATABASE_PASSWORD="replace-me"
|
DATABASE_PASSWORD="replace-me"
|
||||||
# Generate with: npx auth secret
|
# Generate with: npx auth secret
|
||||||
AUTH_SECRET="replace-me-with-a-32-byte-random-string"
|
AUTH_SECRET="replace-me-with-a-32-byte-random-string"
|
||||||
# Auth.js needs to trust the host it's served from when not on localhost:3000.
|
# Auth.js needs to trust the host it's served from when not on localhost:3000.
|
||||||
AUTH_TRUST_HOST="true"
|
AUTH_TRUST_HOST="true"
|
||||||
NEXT_PUBLIC_APP_URL="https://blake-heritage.example.com"
|
# Explicit, not just inferred from AUTH_TRUST_HOST + request headers —
|
||||||
|
# header-based detection (Host/X-Forwarded-Host) behaves inconsistently
|
||||||
|
# across reverse-proxy setups, and a wrong guess sends every post-sign-in
|
||||||
|
# redirect to the wrong origin. Must match DOMAIN above exactly.
|
||||||
|
AUTH_URL="https://example-business.example.com"
|
||||||
|
NEXT_PUBLIC_APP_URL="https://example-business.example.com"
|
||||||
# One-time setup token the installation wizard (/install) requires before
|
# One-time setup token the installation wizard (/install) requires before
|
||||||
# it will create the first business/administrator — generate with:
|
# it will create the first business/administrator — generate with:
|
||||||
# openssl rand -hex 32
|
# openssl rand -hex 32
|
||||||
INSTALLATION_TOKEN="replace-me"
|
INSTALLATION_TOKEN="replace-me"
|
||||||
# Shared secret an external scheduler presents to
|
# Shared secret an external scheduler presents to
|
||||||
# /api/cron/process-notifications — generate with: openssl rand -hex 32
|
# /api/cron/process-notifications and /api/cron/sync-google-reviews —
|
||||||
|
# generate with: openssl rand -hex 32. Without something actually calling
|
||||||
|
# those on a schedule, queued notifications (staff invites, reminders,
|
||||||
|
# etc.) never get sent — see the root README's "Notification delivery"
|
||||||
|
# section: the Manager's built-in scheduler normally does this (the host
|
||||||
|
# crontab script is only a fallback).
|
||||||
CRON_SECRET="replace-me"
|
CRON_SECRET="replace-me"
|
||||||
|
|
||||||
|
# Where the app emails unhandled server errors (one email per distinct error per hour, max 20 a day).
|
||||||
|
# The Manager fills this in from Settings > Monitoring. Blank = no error emails.
|
||||||
|
OPERATOR_ALERT_EMAIL=""
|
||||||
|
|
||||||
# ── Super Administrator (optional — see scripts/ensure-super-admin.ts) ──
|
# ── Super Administrator (optional — see scripts/ensure-super-admin.ts) ──
|
||||||
# Platform-level account with every Owner permission. Not run
|
# Platform-level account with every Owner permission for THIS business.
|
||||||
# automatically by this compose file (that's a Docker-entrypoint-only
|
# Runs automatically on every container start (see docker/entrypoint.sh)
|
||||||
# behaviour, see docker/entrypoint.sh) — run it manually, once, the same
|
# — filling these in and recreating the container is enough, no manual
|
||||||
# way migrations were run for the Vercel/Hostinger deployment earlier:
|
# `docker exec` needed. No password — signs in the same OTP way as
|
||||||
# docker exec -it next-chair-blake-heritage npx tsx scripts/ensure-super-admin.ts
|
# everyone else, via a one-time code emailed to SUPER_ADMIN_EMAIL. Leave
|
||||||
# Leave SUPER_ADMIN_EMAIL blank to skip entirely and use the /install
|
# SUPER_ADMIN_EMAIL blank to skip entirely and use the /install wizard's
|
||||||
# wizard's own administrator account instead.
|
# own administrator account instead.
|
||||||
SUPER_ADMIN_EMAIL=""
|
SUPER_ADMIN_EMAIL=""
|
||||||
SUPER_ADMIN_PASSWORD=""
|
|
||||||
SUPER_ADMIN_FIRST_NAME=""
|
SUPER_ADMIN_FIRST_NAME=""
|
||||||
SUPER_ADMIN_LAST_NAME=""
|
SUPER_ADMIN_LAST_NAME=""
|
||||||
|
|
||||||
@@ -65,7 +82,7 @@ AUTH_GOOGLE_SECRET=""
|
|||||||
# OAuth client from AUTH_GOOGLE_ID/SECRET above (different scopes).
|
# OAuth client from AUTH_GOOGLE_ID/SECRET above (different scopes).
|
||||||
GOOGLE_CALENDAR_CLIENT_ID=""
|
GOOGLE_CALENDAR_CLIENT_ID=""
|
||||||
GOOGLE_CALENDAR_CLIENT_SECRET=""
|
GOOGLE_CALENDAR_CLIENT_SECRET=""
|
||||||
GOOGLE_CALENDAR_REDIRECT_URI="https://blake-heritage.example.com/api/staff/calendar/google/callback"
|
GOOGLE_CALENDAR_REDIRECT_URI="https://example-business.example.com/api/staff/calendar/google/callback"
|
||||||
# Encrypts stored refresh tokens at rest — generate with: openssl rand -hex 32
|
# Encrypts stored refresh tokens at rest — generate with: openssl rand -hex 32
|
||||||
CALENDAR_TOKEN_ENCRYPTION_KEY=""
|
CALENDAR_TOKEN_ENCRYPTION_KEY=""
|
||||||
|
|
||||||
@@ -83,18 +100,30 @@ MEDIA_MAX_UPLOAD_BYTES="5000000"
|
|||||||
GOOGLE_PLACES_API_KEY=""
|
GOOGLE_PLACES_API_KEY=""
|
||||||
|
|
||||||
# ── Email (optional — defaults to logging to the console instead of sending) ─
|
# ── Email (optional — defaults to logging to the console instead of sending) ─
|
||||||
|
# Managed centrally by the Manager's Settings > Email page and pushed
|
||||||
|
# into every business's .env automatically — see manager/src/lib/
|
||||||
|
# global-settings.ts. There's normally no reason to hand-edit these for
|
||||||
|
# one business only; this by-hand template still needs them set if
|
||||||
|
# you're not using the Manager.
|
||||||
EMAIL_PROVIDER="console"
|
EMAIL_PROVIDER="console"
|
||||||
EMAIL_SMTP_HOST="smtp-relay.brevo.com"
|
EMAIL_SMTP_HOST="smtp-relay.brevo.com"
|
||||||
EMAIL_SMTP_PORT="587"
|
EMAIL_SMTP_PORT="587"
|
||||||
EMAIL_SMTP_USERNAME=""
|
EMAIL_SMTP_USERNAME=""
|
||||||
EMAIL_SMTP_PASSWORD=""
|
EMAIL_SMTP_PASSWORD=""
|
||||||
|
EMAIL_SMTP_ENCRYPTION="starttls"
|
||||||
EMAIL_FROM_ADDRESS=""
|
EMAIL_FROM_ADDRESS=""
|
||||||
EMAIL_FROM_NAME=""
|
EMAIL_FROM_NAME=""
|
||||||
|
EMAIL_REPLY_TO=""
|
||||||
|
|
||||||
# ── Web Push (optional — defaults to logging to the console instead of sending) ─
|
# ── Web Push (optional — defaults to logging to the console instead of sending) ─
|
||||||
# Generate with: npx web-push generate-vapid-keys
|
# Managed centrally by the Manager's Settings > Push Notifications page and
|
||||||
|
# pushed into every business's .env automatically — see manager/src/lib/
|
||||||
|
# global-settings.ts. There's normally no reason to hand-edit these for
|
||||||
|
# one business only; this by-hand template still needs them set if you're
|
||||||
|
# not using the Manager. No NEXT_PUBLIC_VAPID_PUBLIC_KEY here — the app
|
||||||
|
# serves its public key at runtime via GET /api/push/vapid-public-key
|
||||||
|
# instead (see that route's own header for why).
|
||||||
PUSH_PROVIDER="console"
|
PUSH_PROVIDER="console"
|
||||||
VAPID_PUBLIC_KEY=""
|
VAPID_PUBLIC_KEY=""
|
||||||
NEXT_PUBLIC_VAPID_PUBLIC_KEY=""
|
|
||||||
VAPID_PRIVATE_KEY=""
|
VAPID_PRIVATE_KEY=""
|
||||||
VAPID_SUBJECT="mailto:owner@example.com"
|
VAPID_SUBJECT="mailto:owner@example.com"
|
||||||
+24
-11
@@ -32,27 +32,40 @@ GOOGLE_PLACES_API_KEY=""
|
|||||||
# Email (Phase 7) — see src/services/notifications/providers.
|
# Email (Phase 7) — see src/services/notifications/providers.
|
||||||
# "console" logs would-be emails to the server console instead of sending
|
# "console" logs would-be emails to the server console instead of sending
|
||||||
# (safe default — never send real email from an unconfigured environment).
|
# (safe default — never send real email from an unconfigured environment).
|
||||||
# "brevo" sends for real over Brevo's SMTP relay via nodemailer (see
|
# "brevo" sends for real over SMTP via nodemailer (see brevo-email-
|
||||||
# brevo-email-provider.ts for why SMTP rather than Brevo's REST API).
|
# provider.ts — it's genuinely generic SMTP, not Brevo-API-specific,
|
||||||
|
# despite the name). On a multi-business deployment these are normally
|
||||||
|
# managed centrally by the Manager's Settings > Email page and pushed into
|
||||||
|
# every business's .env automatically — you don't need to fill these in
|
||||||
|
# by hand there. This single-business template still needs them set.
|
||||||
EMAIL_PROVIDER="console"
|
EMAIL_PROVIDER="console"
|
||||||
EMAIL_SMTP_HOST="smtp-relay.brevo.com"
|
EMAIL_SMTP_HOST="smtp-relay.brevo.com"
|
||||||
EMAIL_SMTP_PORT="587"
|
EMAIL_SMTP_PORT="587"
|
||||||
EMAIL_SMTP_USERNAME=""
|
EMAIL_SMTP_USERNAME=""
|
||||||
EMAIL_SMTP_PASSWORD=""
|
EMAIL_SMTP_PASSWORD=""
|
||||||
|
# "none" | "starttls" | "tls" — unset infers from port (465 = tls, else
|
||||||
|
# starttls) for backward compatibility; set explicitly to represent "none".
|
||||||
|
EMAIL_SMTP_ENCRYPTION="starttls"
|
||||||
EMAIL_FROM_ADDRESS=""
|
EMAIL_FROM_ADDRESS=""
|
||||||
# EMAIL_FROM_NAME is optional and is only used when no business name can
|
# EMAIL_FROM_NAME is optional and is only used when no business name can
|
||||||
# be resolved. Normal business emails use the installed business name
|
# be resolved. Normal business emails use the installed business name
|
||||||
# automatically (see docs/phase-8-email-sender-summary.md) — you do not
|
# automatically (see docs/phase-8-email-sender-summary.md) — you do not
|
||||||
# need to set or keep this in sync with Business Settings.
|
# need to set or keep this in sync with Business Settings.
|
||||||
EMAIL_FROM_NAME=""
|
EMAIL_FROM_NAME=""
|
||||||
|
# Optional — a distinct reply-to address, separate from the verified sender address.
|
||||||
|
EMAIL_REPLY_TO=""
|
||||||
|
|
||||||
# Web Push (Phase 7) — generate with: npx web-push generate-vapid-keys
|
# Web Push — generate with: npx web-push generate-vapid-keys
|
||||||
# NEXT_PUBLIC_VAPID_PUBLIC_KEY must be the same value as VAPID_PUBLIC_KEY —
|
# On a multi-business deployment these are normally managed centrally by
|
||||||
# it's duplicated because the browser needs it for pushManager.subscribe.
|
# the Manager's Settings > Push Notifications page and pushed into every
|
||||||
|
# business's .env automatically — you don't need to fill these in by hand
|
||||||
|
# there. This single-business template still needs them set. The public
|
||||||
|
# key reaches the browser via GET /api/push/vapid-public-key at runtime,
|
||||||
|
# not a NEXT_PUBLIC_* build-time variable — see that route's own header
|
||||||
|
# for why (every business shares one prebuilt image).
|
||||||
# PUSH_PROVIDER="web-push" to actually send; defaults to "console" (logs only).
|
# PUSH_PROVIDER="web-push" to actually send; defaults to "console" (logs only).
|
||||||
PUSH_PROVIDER="console"
|
PUSH_PROVIDER="console"
|
||||||
VAPID_PUBLIC_KEY=""
|
VAPID_PUBLIC_KEY=""
|
||||||
NEXT_PUBLIC_VAPID_PUBLIC_KEY=""
|
|
||||||
VAPID_PRIVATE_KEY=""
|
VAPID_PRIVATE_KEY=""
|
||||||
VAPID_SUBJECT="mailto:owner@example.com"
|
VAPID_SUBJECT="mailto:owner@example.com"
|
||||||
|
|
||||||
@@ -69,12 +82,12 @@ INSTALLATION_TOKEN=""
|
|||||||
# Super Administrator (Phase 9) — platform-level account with every Owner
|
# Super Administrator (Phase 9) — platform-level account with every Owner
|
||||||
# permission, provisioned from these env vars rather than the installation
|
# permission, provisioned from these env vars rather than the installation
|
||||||
# wizard (see scripts/ensure-super-admin.ts, run on every container start).
|
# wizard (see scripts/ensure-super-admin.ts, run on every container start).
|
||||||
# Has no StaffProfile by design: never appears in Team, the customer-facing
|
# No password — signs in the same OTP way as everyone else, via a
|
||||||
# barber picker, or staff totals, and can never receive an appointment. All
|
# one-time code emailed to SUPER_ADMIN_EMAIL. Has no StaffProfile by
|
||||||
# four optional — leave SUPER_ADMIN_EMAIL unset to skip provisioning one
|
# design: never appears in Team, the customer-facing barber picker, or
|
||||||
# entirely. Re-run to rotate the password (re-hashed every run).
|
# staff totals, and can never receive an appointment. All three optional
|
||||||
|
# — leave SUPER_ADMIN_EMAIL unset to skip provisioning one entirely.
|
||||||
SUPER_ADMIN_EMAIL=""
|
SUPER_ADMIN_EMAIL=""
|
||||||
SUPER_ADMIN_PASSWORD=""
|
|
||||||
SUPER_ADMIN_FIRST_NAME=""
|
SUPER_ADMIN_FIRST_NAME=""
|
||||||
SUPER_ADMIN_LAST_NAME=""
|
SUPER_ADMIN_LAST_NAME=""
|
||||||
|
|
||||||
|
|||||||
+10
-6
@@ -1,12 +1,12 @@
|
|||||||
# Shared infrastructure config — see docker-compose.infra.yaml for the
|
# Shared infrastructure config — see stacks/infra/compose.yaml for the
|
||||||
# full setup sequence. Copy this to `.env.infra` (kept out of git — see
|
# full setup sequence. Copy this to `stacks/infra/.env` (kept out of git
|
||||||
# .gitignore) and fill in real values.
|
# — see .gitignore) and fill in real values.
|
||||||
#
|
#
|
||||||
# docker network create next-chair-edge
|
# docker network create next-chair-edge
|
||||||
# docker compose -f docker-compose.infra.yaml --env-file .env.infra up -d
|
# docker compose -f stacks/infra/compose.yaml --env-file stacks/infra/.env up -d
|
||||||
|
|
||||||
# ── MySQL (shared server — one database per business, created manually
|
# ── MySQL (shared server — one database per business, created manually
|
||||||
# per business, see docker-compose.infra.yaml's own header) ────────────
|
# per business, see stacks/infra/compose.yaml's own header) ────────────
|
||||||
MYSQL_ROOT_PASSWORD="replace-me"
|
MYSQL_ROOT_PASSWORD="replace-me"
|
||||||
# Host-machine port MySQL is reachable on, e.g. for a GUI client over an
|
# Host-machine port MySQL is reachable on, e.g. for a GUI client over an
|
||||||
# SSH tunnel — every business's app container always reaches it via the
|
# SSH tunnel — every business's app container always reaches it via the
|
||||||
@@ -14,7 +14,11 @@ MYSQL_ROOT_PASSWORD="replace-me"
|
|||||||
# this.
|
# this.
|
||||||
MYSQL_HOST_PORT=3306
|
MYSQL_HOST_PORT=3306
|
||||||
|
|
||||||
# ── Traefik (reverse proxy + automatic TLS via Let's Encrypt) ──────────
|
# ── Caddy (reverse proxy + automatic TLS via Let's Encrypt) ────────────
|
||||||
# Let's Encrypt sends renewal/expiry notices here — use a real address
|
# Let's Encrypt sends renewal/expiry notices here — use a real address
|
||||||
# you actually check, not a placeholder.
|
# you actually check, not a placeholder.
|
||||||
ACME_EMAIL="you@example.com"
|
ACME_EMAIL="you@example.com"
|
||||||
|
# Leave these at 80/443 on a real deployment — only override for local
|
||||||
|
# testing on a machine already using those ports for something else.
|
||||||
|
CADDY_HTTP_PORT=80
|
||||||
|
CADDY_HTTPS_PORT=443
|
||||||
+19
-12
@@ -26,14 +26,25 @@ dist/
|
|||||||
.env.production
|
.env.production
|
||||||
|
|
||||||
# per-deployment production env files (multi-business VPS setup — see
|
# per-deployment production env files (multi-business VPS setup — see
|
||||||
# docker-compose.infra.yaml / docker-compose.<business>.yaml). Named
|
# stacks/infra/compose.yaml / stacks/<business>/compose.yaml). Every real
|
||||||
# .env.business.* (not .env.<business> directly) specifically so this one
|
# stack's .env lives at stacks/<name>/.env (one directory per stack,
|
||||||
# wildcard rule keeps covering every future business without another
|
# same convention NextChair Manager's provisioning uses) — this one
|
||||||
# .gitignore edit. The template itself (.env.business.example) stays
|
# wildcard rule covers every current and future stack without another
|
||||||
# tracked, same as .env.local.example above.
|
# .gitignore edit. .env.infra.example and .env.business.example (the
|
||||||
.env.infra
|
# templates you copy into each stacks/<name>/ directory) stay tracked,
|
||||||
.env.business.*
|
# same as .env.local.example above.
|
||||||
!.env.business.example
|
stacks/*/.env
|
||||||
|
|
||||||
|
# Real per-business Caddy site files (deployment-specific domains, not
|
||||||
|
# something to hand-author into the repo) — only the placeholder stays
|
||||||
|
# tracked.
|
||||||
|
stacks/infra/sites/*.caddy
|
||||||
|
!stacks/infra/sites/.gitkeep
|
||||||
|
|
||||||
|
# Manager's global platform settings (Settings > Email, etc. — see
|
||||||
|
# manager/src/lib/global-settings.ts) — contains an encrypted SMTP
|
||||||
|
# password, generated at runtime, never hand-authored into the repo.
|
||||||
|
stacks/manager/global-settings.json
|
||||||
|
|
||||||
# prisma
|
# prisma
|
||||||
prisma/dev.db
|
prisma/dev.db
|
||||||
@@ -57,10 +68,6 @@ yarn-error.log*
|
|||||||
.idea/
|
.idea/
|
||||||
|
|
||||||
/generated/prisma
|
/generated/prisma
|
||||||
.env.example
|
|
||||||
.env.local.example
|
|
||||||
.env.example
|
|
||||||
.env.example
|
|
||||||
|
|
||||||
.agents/
|
.agents/
|
||||||
.claude/
|
.claude/
|
||||||
|
|||||||
@@ -43,6 +43,14 @@ ENV DATABASE_PORT="3306"
|
|||||||
ENV DATABASE_NAME="build"
|
ENV DATABASE_NAME="build"
|
||||||
ENV DATABASE_USER="build"
|
ENV DATABASE_USER="build"
|
||||||
ENV DATABASE_PASSWORD="build"
|
ENV DATABASE_PASSWORD="build"
|
||||||
|
# Skip `next build`'s TypeScript pass in the image. It's a redundant second
|
||||||
|
# check — `npx tsc --noEmit` already gates every change before it's built —
|
||||||
|
# and it's by far the heaviest step of the build: on a low-memory VPS it can
|
||||||
|
# swap for many minutes or get OOM-killed (exit 137). Type errors are still
|
||||||
|
# caught everywhere else; to type-check inside the image anyway, build with
|
||||||
|
# `docker build --build-arg SKIP_TYPECHECK=false .`
|
||||||
|
ARG SKIP_TYPECHECK=true
|
||||||
|
ENV SKIP_TYPECHECK=$SKIP_TYPECHECK
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
FROM node:22-alpine AS runner
|
FROM node:22-alpine AS runner
|
||||||
|
|||||||
@@ -1,3 +1,290 @@
|
|||||||
# NextChair
|
# NextChair
|
||||||
|
|
||||||
Booking app as an alternative to Booksy
|
Booking app as an alternative to Booksy
|
||||||
|
|
||||||
|
## VPS deployment (multi-business)
|
||||||
|
|
||||||
|
Everything below runs on a fresh VPS with Docker and the Compose plugin
|
||||||
|
already installed — no other prerequisite (no host Node.js/npm needed;
|
||||||
|
every command either uses Docker itself or plain shell tools). Every code
|
||||||
|
block is meant to be pasted as-is, in order.
|
||||||
|
|
||||||
|
### 0. One-time: clone the repo and generate your secrets
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# STACKS_DIR later on must be this exact absolute path — every stack
|
||||||
|
# shells `docker compose` back out to the HOST's dockerd via a mounted
|
||||||
|
# socket, so the path only resolves to the same thing on both sides if
|
||||||
|
# it's cloned here and never moved.
|
||||||
|
git clone <your-repo-url> /opt/nextchair
|
||||||
|
cd /opt/nextchair
|
||||||
|
```
|
||||||
|
|
||||||
|
Generate the secrets you'll paste into the `.env` files in the steps
|
||||||
|
below. Run these now and keep the output somewhere safe (a password
|
||||||
|
manager) — you'll need each value again in a moment.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# MYSQL_ROOT_PASSWORD — goes into BOTH stacks/infra/.env and manager/.env
|
||||||
|
# (the same value in both places).
|
||||||
|
echo "MYSQL_ROOT_PASSWORD=$(openssl rand -hex 24)"
|
||||||
|
|
||||||
|
# MANAGER_SESSION_SECRET — manager/.env only.
|
||||||
|
echo "MANAGER_SESSION_SECRET=$(openssl rand -hex 32)"
|
||||||
|
|
||||||
|
# MANAGER_SETTINGS_ENCRYPTION_KEY — manager/.env only. Encrypts the
|
||||||
|
# SMTP/VAPID secrets you'll enter in the Manager's Settings page later.
|
||||||
|
# Losing/rotating this makes any already-saved secret there undecryptable,
|
||||||
|
# so store it as carefully as the other secrets on this page.
|
||||||
|
echo "MANAGER_SETTINGS_ENCRYPTION_KEY=$(openssl rand -hex 32)"
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# MANAGER_PASSWORD_HASH — manager/.env only. Replace "your-password" with
|
||||||
|
# your chosen Manager login password first. Run via a throwaway container
|
||||||
|
# so this works with nothing but Docker installed on the host.
|
||||||
|
docker run --rm node:22-alpine sh -c \
|
||||||
|
"mkdir -p /tmp/gen && cd /tmp/gen && npm install bcryptjs --no-audit --no-fund >/dev/null 2>&1 && \
|
||||||
|
node -e \"const h=require('/tmp/gen/node_modules/bcryptjs').hashSync('your-password',12); console.log('MANAGER_PASSWORD_HASH=\\\"'+h.replace(/\\$/g,'\\$\\$')+'\\\"')\""
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1. Shared infrastructure — network, MySQL and Caddy
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker network create next-chair-edge
|
||||||
|
|
||||||
|
cp .env.infra.example stacks/infra/.env
|
||||||
|
```
|
||||||
|
|
||||||
|
Edit `stacks/infra/.env`:
|
||||||
|
- `MYSQL_ROOT_PASSWORD` — the first secret you generated above.
|
||||||
|
- `MYSQL_HOST_PORT` — set this to `127.0.0.1:3306` so MySQL is only exposed on the VPS loopback interface, not publicly. You can still connect to it remotely through an SSH tunnel.
|
||||||
|
- `ACME_EMAIL` — a real address you check (Let's Encrypt renewal notices).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f stacks/infra/compose.yaml --env-file stacks/infra/.env up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Build the shared application image
|
||||||
|
|
||||||
|
Every business (and no other step in this guide) runs this same image —
|
||||||
|
you only rebuild it here, once, and again whenever you ship a code
|
||||||
|
update (see "Updating" below).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t nextchair-app:latest .
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. The management UI (Manager)
|
||||||
|
|
||||||
|
This is what you'll use to add every business from here on.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp manager/.env.example stacks/manager/.env
|
||||||
|
```
|
||||||
|
|
||||||
|
Edit `stacks/manager/.env`:
|
||||||
|
- `MANAGER_EMAIL` — the email you'll log in with.
|
||||||
|
- `MANAGER_PASSWORD_HASH` — the bcrypt hash generated in step 0.
|
||||||
|
- `MANAGER_SESSION_SECRET` and `MANAGER_SETTINGS_ENCRYPTION_KEY` — the two
|
||||||
|
other values generated in step 0.
|
||||||
|
- `MANAGER_PUBLIC_URL` — `https://` + the domain you'll point at this app
|
||||||
|
in the next step.
|
||||||
|
- `MYSQL_ROOT_PASSWORD` — same value as `stacks/infra/.env`.
|
||||||
|
- `STACKS_DIR` — same absolute path as step 1.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f stacks/manager/compose.yaml --env-file stacks/manager/.env up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Point your chosen Manager domain's DNS (A/AAAA record) at the VPS, then
|
||||||
|
give it a Caddy site and reload:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat > stacks/infra/sites/manager.caddy << 'EOF'
|
||||||
|
manager.example.com {
|
||||||
|
reverse_proxy next-chair-manager:3000
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
docker exec next-chair-caddy caddy reload --config /etc/caddy/Caddyfile
|
||||||
|
```
|
||||||
|
|
||||||
|
(Replace `manager.example.com` with your real domain — Caddy issues its
|
||||||
|
TLS certificate automatically on first request.)
|
||||||
|
|
||||||
|
### 4. Configure platform-wide settings (once)
|
||||||
|
|
||||||
|
Log into the Manager at `https://<your-manager-domain>` and open
|
||||||
|
**Settings**. Both sections are optional to fill in now, but doing it
|
||||||
|
before adding your first business means that business never needs its
|
||||||
|
own SMTP/push setup:
|
||||||
|
|
||||||
|
- **Email / SMTP** — your SMTP relay's host/port/username/password and a
|
||||||
|
from-address. Use **Send Test Email** to confirm it before saving for
|
||||||
|
real, then **Save Changes** to apply it to every business.
|
||||||
|
- **Push Notifications** — click **Generate VAPID Keys** once (a contact
|
||||||
|
email is enough to start). This is a one-time, platform-wide key pair —
|
||||||
|
see that section's own copy for what happens if you ever regenerate it.
|
||||||
|
|
||||||
|
### 5. Add your first business
|
||||||
|
|
||||||
|
Still in the Manager: **Add Business** → name, slug, domain. This creates
|
||||||
|
its database, generates its secrets, starts its container, and runs its
|
||||||
|
migrations for you — no manual per-business `.env` editing needed.
|
||||||
|
|
||||||
|
Point that business's own domain's DNS at the VPS (the Manager doesn't do
|
||||||
|
this for you), then finish its setup by visiting
|
||||||
|
`https://<business-domain>/install`.
|
||||||
|
|
||||||
|
Repeat this step for each additional business, whenever you want one.
|
||||||
|
|
||||||
|
### 6. Notification delivery (handled by the Manager)
|
||||||
|
|
||||||
|
Staff invitation emails, appointment reminders, push notifications,
|
||||||
|
loyalty reward emails, WhatsApp-reminder nudges and Google Reviews sync are
|
||||||
|
**not sent by the app itself** — it only queues them; something has to
|
||||||
|
trigger delivery on a schedule by calling two `CRON_SECRET`-gated
|
||||||
|
endpoints (`/api/cron/process-notifications`,
|
||||||
|
`/api/cron/sync-google-reviews` — see their route files for the reasoning).
|
||||||
|
Without a trigger, every business's queued notifications sit as `PENDING`
|
||||||
|
forever, even though the UI says "sent" the moment something is queued.
|
||||||
|
|
||||||
|
**The Manager's built-in scheduler does this for you** — nothing to
|
||||||
|
install on the host. Once the Manager is running it calls every
|
||||||
|
business's notification cron every 30 seconds (adjustable at Settings →
|
||||||
|
Monitoring & scheduler, 10 s – 1 hour) and the reviews sync once a day,
|
||||||
|
including for businesses added later. That page lists the last run of each
|
||||||
|
task per business, so you can confirm it's working.
|
||||||
|
|
||||||
|
Each business's `/api/health` also reports whether notifications are
|
||||||
|
actually going out (`status: "degraded"` plus the reasons, when the
|
||||||
|
processor hasn't run for 10 minutes, deliveries are stuck, or every delivery
|
||||||
|
in the last day failed), and the Manager emails you when that happens.
|
||||||
|
|
||||||
|
#### Removing the old crontab
|
||||||
|
|
||||||
|
If you set up the host crontab from an earlier version of this guide, remove
|
||||||
|
it once the Manager scheduler is confirmed working — running both is safe
|
||||||
|
(deliveries are claimed one at a time, so nothing is sent twice) but
|
||||||
|
pointless:
|
||||||
|
|
||||||
|
1. Open Settings → Monitoring & scheduler in the Manager and check that
|
||||||
|
"Process notifications" shows a recent successful run for each business.
|
||||||
|
2. On the VPS, as the user that owns `/opt/nextchair`, run `crontab -e` and
|
||||||
|
delete the two `process-cron.sh` lines (notifications and reviews).
|
||||||
|
3. Optionally delete `/opt/nextchair/nextchair-cron.log`.
|
||||||
|
|
||||||
|
#### Fallback: host crontab
|
||||||
|
|
||||||
|
Only if you run with `MANAGER_SCHEDULER_DISABLED=1`, or want an independent
|
||||||
|
trigger. [stacks/infra/scripts/process-cron.sh](stacks/infra/scripts/process-cron.sh)
|
||||||
|
calls both endpoints for **every** business under `STACKS_DIR` in one run.
|
||||||
|
Many minimal VPS images don't ship `cron` — install it first
|
||||||
|
(`sudo apt update && sudo apt install -y cron && sudo systemctl enable --now cron`),
|
||||||
|
then add this with `crontab -e` as the user that owns `/opt/nextchair`
|
||||||
|
(**not** `sudo crontab -e`, so the log file is writable):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Deliver queued notifications every minute (the finest a crontab can do).
|
||||||
|
* * * * * STACKS_DIR=/opt/nextchair/stacks /opt/nextchair/stacks/infra/scripts/process-cron.sh notifications >> /opt/nextchair/nextchair-cron.log 2>&1
|
||||||
|
|
||||||
|
# Refresh Google Reviews once a day.
|
||||||
|
0 3 * * * STACKS_DIR=/opt/nextchair/stacks /opt/nextchair/stacks/infra/scripts/process-cron.sh reviews >> /opt/nextchair/nextchair-cron.log 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
A healthy run logs `<slug> process-notifications ok` for every business. Use
|
||||||
|
only one of the two mechanisms at a time as your normal setup.
|
||||||
|
|
||||||
|
## Updating after a code change
|
||||||
|
|
||||||
|
For an existing NextChair server, **do not re-run the fresh VPS setup above** and do not overwrite your production `.env` files.
|
||||||
|
|
||||||
|
First pull the latest code:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/nextchair
|
||||||
|
git pull
|
||||||
|
```
|
||||||
|
|
||||||
|
Before rebuilding, check whether either example environment file has gained new settings since your last deployment:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Review only — do not copy these files over your existing production .env files.
|
||||||
|
diff -u stacks/infra/.env .env.infra.example || true
|
||||||
|
diff -u stacks/manager/.env manager/.env.example || true
|
||||||
|
```
|
||||||
|
|
||||||
|
If a new variable has been added, copy **only that variable** into the corresponding production `.env` and give it an appropriate value. Keep all existing passwords, secrets and generated values unchanged.
|
||||||
|
|
||||||
|
Then rebuild the one shared image every business uses:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/nextchair
|
||||||
|
docker build -t nextchair-app:latest .
|
||||||
|
```
|
||||||
|
|
||||||
|
Then, for **each** business: use its **Update** button in the Manager UI,
|
||||||
|
or run this directly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/nextchair/stacks/<slug>
|
||||||
|
docker compose --env-file .env up -d --force-recreate
|
||||||
|
```
|
||||||
|
|
||||||
|
Either way this recreates that business's container against the freshly
|
||||||
|
built image — and its own startup automatically runs
|
||||||
|
`prisma migrate deploy`, so any new database migration is applied on the
|
||||||
|
same step. No separate migration command needed.
|
||||||
|
|
||||||
|
If the code change touched the **Manager app itself** (anything under
|
||||||
|
`manager/`), rebuild/recreate the Manager too:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/nextchair
|
||||||
|
docker compose -f stacks/manager/compose.yaml --env-file stacks/manager/.env up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
If both the business app and Manager changed, do both update steps.
|
||||||
|
|
||||||
|
## Upgrading from a version that included Dockge (one-time)
|
||||||
|
|
||||||
|
Dockge used to run alongside NextChair Manager as a separate stack
|
||||||
|
management UI. It's no longer part of the shared infrastructure stack —
|
||||||
|
Manager is the only tool needed to provision and manage business stacks,
|
||||||
|
so the two were redundant. If your server was set up before this change,
|
||||||
|
existing tenant databases, tenant containers, business Compose files, and
|
||||||
|
the global SMTP/VAPID settings are all unaffected — this only removes the
|
||||||
|
old Dockge container.
|
||||||
|
|
||||||
|
Pull the change, then re-apply the infra stack with `--remove-orphans` so
|
||||||
|
Docker removes the container that's no longer defined in
|
||||||
|
`stacks/infra/compose.yaml`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/nextchair
|
||||||
|
git pull
|
||||||
|
|
||||||
|
docker compose \
|
||||||
|
-f stacks/infra/compose.yaml \
|
||||||
|
--env-file stacks/infra/.env \
|
||||||
|
up -d --remove-orphans
|
||||||
|
```
|
||||||
|
|
||||||
|
`--remove-orphans` only removes containers Compose no longer recognizes
|
||||||
|
(the old `next-chair-dockge` container) — it does not touch Caddy, MySQL,
|
||||||
|
or their volumes.
|
||||||
|
|
||||||
|
This does **not** delete Dockge's own data volume
|
||||||
|
(`next-chair-dockge-data`) — Docker never removes volumes on its own, and
|
||||||
|
this command has no reason to either. Once you've confirmed Caddy, MySQL
|
||||||
|
and every business are still healthy, you can remove that now-unused
|
||||||
|
volume by hand:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker volume rm next-chair-dockge-data
|
||||||
|
```
|
||||||
|
|
||||||
|
Finally, `STACKS_DIR` and `DOCKGE_HOST_PORT` in `stacks/infra/.env` were
|
||||||
|
only ever used by Dockge and are safe to delete from that file — nothing
|
||||||
|
else in the infra stack reads them. (Manager keeps its own separate
|
||||||
|
`STACKS_DIR` in `stacks/manager/.env`; leave that one alone.)
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
name: next-chair-blake-heritage
|
|
||||||
|
|
||||||
# One business's app container on the shared multi-business VPS setup —
|
|
||||||
# see docker-compose.infra.yaml for the shared MySQL/Traefik this attaches
|
|
||||||
# to, and that file's header for the exact one-time setup steps (create
|
|
||||||
# this business's database, copy .env.business.example) needed before
|
|
||||||
# this file can start.
|
|
||||||
#
|
|
||||||
# docker compose -f docker-compose.blake-heritage.yaml --env-file .env.business.blake-heritage up -d --build
|
|
||||||
#
|
|
||||||
# `docker-compose.marios.yaml` is the identical shape for the second
|
|
||||||
# business — copy this file's pattern again for any future business.
|
|
||||||
|
|
||||||
services:
|
|
||||||
next-chair-blake-heritage:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
# Builds the image fresh here. Once there are more than a couple of
|
|
||||||
# businesses, it's worth building once (`docker build -t
|
|
||||||
# nextchair-app:latest .`) and swapping this `build:` block for
|
|
||||||
# `image: nextchair-app:latest` in every business's file instead, so
|
|
||||||
# a code update doesn't mean rebuilding the identical image N times —
|
|
||||||
# not necessary to start with.
|
|
||||||
container_name: next-chair-blake-heritage
|
|
||||||
restart: unless-stopped
|
|
||||||
env_file:
|
|
||||||
- .env.business.blake-heritage
|
|
||||||
volumes:
|
|
||||||
- next-chair-media-blake-heritage:/app/storage/media
|
|
||||||
healthcheck:
|
|
||||||
# No curl/wget in the node:22-alpine base image — a plain Node
|
|
||||||
# request avoids adding a package just for this. See
|
|
||||||
# src/app/api/health/route.ts: healthy as soon as the database is
|
|
||||||
# reachable, even before installation completes.
|
|
||||||
test:
|
|
||||||
[
|
|
||||||
"CMD",
|
|
||||||
"node",
|
|
||||||
"-e",
|
|
||||||
"require('http').get('http://localhost:3000/api/health', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))",
|
|
||||||
]
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 10
|
|
||||||
start_period: 20s
|
|
||||||
labels:
|
|
||||||
- "traefik.enable=true"
|
|
||||||
- "traefik.http.routers.blake-heritage.rule=Host(`${DOMAIN}`)"
|
|
||||||
- "traefik.http.routers.blake-heritage.entrypoints=websecure"
|
|
||||||
- "traefik.http.routers.blake-heritage.tls.certresolver=letsencrypt"
|
|
||||||
- "traefik.http.services.blake-heritage.loadbalancer.server.port=3000"
|
|
||||||
networks:
|
|
||||||
- next-chair-edge
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
next-chair-media-blake-heritage:
|
|
||||||
name: next-chair-media-blake-heritage
|
|
||||||
|
|
||||||
networks:
|
|
||||||
next-chair-edge:
|
|
||||||
name: next-chair-edge
|
|
||||||
external: true
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
name: next-chair-infra
|
|
||||||
|
|
||||||
# Shared infrastructure for a multi-business VPS deployment: one Traefik
|
|
||||||
# reverse proxy (routes each business's domain to its own app container,
|
|
||||||
# handles TLS via Let's Encrypt) and one MySQL server shared by every
|
|
||||||
# business — a separate database + user per business, NOT one shared
|
|
||||||
# schema (see step 1 below).
|
|
||||||
#
|
|
||||||
# Deliberately separate from docker-compose.local.yaml, which is the
|
|
||||||
# single-business local/Docker-Desktop setup and stays untouched by this.
|
|
||||||
#
|
|
||||||
# ── One-time setup ───────────────────────────────────────────────────────
|
|
||||||
# docker network create next-chair-edge
|
|
||||||
# cp .env.infra.example .env.infra (then fill in real values)
|
|
||||||
# docker compose -f docker-compose.infra.yaml --env-file .env.infra up -d
|
|
||||||
#
|
|
||||||
# ── Per business, after infra is running (see docker-compose.<business>
|
|
||||||
# .yaml — this repo ships one per business, e.g. docker-compose.blake-
|
|
||||||
# heritage.yaml) ────────────────────────────────────────────────────────
|
|
||||||
# 1. Create that business's database + user on the shared MySQL server.
|
|
||||||
# There's no automatic way to do this per business via env vars —
|
|
||||||
# the mysql:8.0 image's MYSQL_DATABASE/USER/PASSWORD vars only
|
|
||||||
# provision ONE database at first boot, and /docker-entrypoint-
|
|
||||||
# initdb.d scripts only ever run once against an empty data
|
|
||||||
# directory — so this one step is manual, run once per business:
|
|
||||||
# docker exec -it next-chair-mysql mysql -uroot -p
|
|
||||||
# CREATE DATABASE blake_heritage CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
|
|
||||||
# CREATE USER 'blake_heritage'@'%' IDENTIFIED BY 'choose-a-strong-password';
|
|
||||||
# GRANT ALL PRIVILEGES ON blake_heritage.* TO 'blake_heritage'@'%';
|
|
||||||
# FLUSH PRIVILEGES;
|
|
||||||
# 2. Copy .env.business.example to .env.business.<slug>, fill it in
|
|
||||||
# with the credentials just created and that business's domain.
|
|
||||||
# 3. docker compose -f docker-compose.<business>.yaml --env-file .env.business.<slug> up -d --build
|
|
||||||
|
|
||||||
services:
|
|
||||||
traefik:
|
|
||||||
image: traefik:v3.2
|
|
||||||
container_name: next-chair-traefik
|
|
||||||
restart: unless-stopped
|
|
||||||
command:
|
|
||||||
- "--providers.docker=true"
|
|
||||||
# Only containers with an explicit traefik.enable=true label are
|
|
||||||
# ever routed to — nothing becomes reachable by accident just for
|
|
||||||
# sitting on the shared network.
|
|
||||||
- "--providers.docker.exposedbydefault=false"
|
|
||||||
- "--entrypoints.web.address=:80"
|
|
||||||
- "--entrypoints.websecure.address=:443"
|
|
||||||
# Plain HTTP always redirects to HTTPS — no business is ever served
|
|
||||||
# unencrypted, even before you've thought about it per-business.
|
|
||||||
- "--entrypoints.web.http.redirections.entrypoint.to=websecure"
|
|
||||||
- "--entrypoints.web.http.redirections.entrypoint.scheme=https"
|
|
||||||
- "--certificatesresolvers.letsencrypt.acme.httpchallenge=true"
|
|
||||||
- "--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web"
|
|
||||||
- "--certificatesresolvers.letsencrypt.acme.email=${ACME_EMAIL}"
|
|
||||||
- "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json"
|
|
||||||
ports:
|
|
||||||
- "80:80"
|
|
||||||
- "443:443"
|
|
||||||
volumes:
|
|
||||||
# Read-only — Traefik only needs to observe container labels/state
|
|
||||||
# to discover routes, never control the daemon.
|
|
||||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
|
||||||
- next-chair-traefik-certs:/letsencrypt
|
|
||||||
networks:
|
|
||||||
- next-chair-edge
|
|
||||||
|
|
||||||
next-chair-mysql:
|
|
||||||
image: mysql:8.0
|
|
||||||
container_name: next-chair-mysql
|
|
||||||
restart: unless-stopped
|
|
||||||
env_file:
|
|
||||||
- .env.infra
|
|
||||||
# No MYSQL_DATABASE/MYSQL_USER/MYSQL_PASSWORD here deliberately — this
|
|
||||||
# server hosts one database per business (see step 1 above), not one
|
|
||||||
# shared schema. Only MYSQL_ROOT_PASSWORD (in .env.infra) is required
|
|
||||||
# for the image to boot.
|
|
||||||
command: --character-set-server=utf8mb4 --collation-server=utf8mb4_0900_ai_ci
|
|
||||||
ports:
|
|
||||||
# Host-machine access (e.g. a GUI client over an SSH tunnel) — every
|
|
||||||
# business's app container reaches this over the shared Docker
|
|
||||||
# network via the service name `next-chair-mysql`, never this.
|
|
||||||
- "${MYSQL_HOST_PORT:-3306}:3306"
|
|
||||||
volumes:
|
|
||||||
- next-chair-mysql-data:/var/lib/mysql
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-u", "root", "-p${MYSQL_ROOT_PASSWORD:-change-me-too}"]
|
|
||||||
interval: 5s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 20
|
|
||||||
start_period: 10s
|
|
||||||
networks:
|
|
||||||
- next-chair-edge
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
next-chair-mysql-data:
|
|
||||||
name: next-chair-mysql-data
|
|
||||||
next-chair-traefik-certs:
|
|
||||||
name: next-chair-traefik-certs
|
|
||||||
|
|
||||||
networks:
|
|
||||||
next-chair-edge:
|
|
||||||
name: next-chair-edge
|
|
||||||
external: true
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
name: next-chair-marios
|
|
||||||
|
|
||||||
# One business's app container on the shared multi-business VPS setup —
|
|
||||||
# see docker-compose.infra.yaml for the shared MySQL/Traefik this attaches
|
|
||||||
# to, and that file's header for the exact one-time setup steps (create
|
|
||||||
# this business's database, copy .env.business.example) needed before
|
|
||||||
# this file can start.
|
|
||||||
#
|
|
||||||
# docker compose -f docker-compose.marios.yaml --env-file .env.business.marios up -d --build
|
|
||||||
#
|
|
||||||
# `docker-compose.blake-heritage.yaml` is the identical shape for the
|
|
||||||
# first business — copy this file's pattern again for any future business.
|
|
||||||
|
|
||||||
services:
|
|
||||||
next-chair-marios:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
# Builds the image fresh here. Once there are more than a couple of
|
|
||||||
# businesses, it's worth building once (`docker build -t
|
|
||||||
# nextchair-app:latest .`) and swapping this `build:` block for
|
|
||||||
# `image: nextchair-app:latest` in every business's file instead, so
|
|
||||||
# a code update doesn't mean rebuilding the identical image N times —
|
|
||||||
# not necessary to start with.
|
|
||||||
container_name: next-chair-marios
|
|
||||||
restart: unless-stopped
|
|
||||||
env_file:
|
|
||||||
- .env.business.marios
|
|
||||||
volumes:
|
|
||||||
- next-chair-media-marios:/app/storage/media
|
|
||||||
healthcheck:
|
|
||||||
# No curl/wget in the node:22-alpine base image — a plain Node
|
|
||||||
# request avoids adding a package just for this. See
|
|
||||||
# src/app/api/health/route.ts: healthy as soon as the database is
|
|
||||||
# reachable, even before installation completes.
|
|
||||||
test:
|
|
||||||
[
|
|
||||||
"CMD",
|
|
||||||
"node",
|
|
||||||
"-e",
|
|
||||||
"require('http').get('http://localhost:3000/api/health', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))",
|
|
||||||
]
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 10
|
|
||||||
start_period: 20s
|
|
||||||
labels:
|
|
||||||
- "traefik.enable=true"
|
|
||||||
- "traefik.http.routers.marios.rule=Host(`${DOMAIN}`)"
|
|
||||||
- "traefik.http.routers.marios.entrypoints=websecure"
|
|
||||||
- "traefik.http.routers.marios.tls.certresolver=letsencrypt"
|
|
||||||
- "traefik.http.services.marios.loadbalancer.server.port=3000"
|
|
||||||
networks:
|
|
||||||
- next-chair-edge
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
next-chair-media-marios:
|
|
||||||
name: next-chair-media-marios
|
|
||||||
|
|
||||||
networks:
|
|
||||||
next-chair-edge:
|
|
||||||
name: next-chair-edge
|
|
||||||
external: true
|
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# Calendar
|
||||||
|
|
||||||
|
A living reference for the day calendar on `/staff` (a barber's own day) and
|
||||||
|
`/owner/calendar` (everyone, one column per barber). Both render
|
||||||
|
`src/features/staff/components/day-calendar.tsx`.
|
||||||
|
|
||||||
|
## What it's built on
|
||||||
|
|
||||||
|
[react-big-calendar](https://github.com/jquense/react-big-calendar) (MIT) with
|
||||||
|
its drag-and-drop addon, restyled to match the app in `day-calendar.css`
|
||||||
|
(scoped under `.day-cal`). It was chosen over DayPilot Lite mainly because
|
||||||
|
per-barber columns, a current-time line and sideways scrolling on a phone all
|
||||||
|
work out of the box; FullCalendar and Schedule-X put per-resource columns
|
||||||
|
behind a paid licence.
|
||||||
|
|
||||||
|
Only the day view is used. The agenda (list) view is a separate component.
|
||||||
|
|
||||||
|
## Time zones — read this before touching it
|
||||||
|
|
||||||
|
The library works in JavaScript `Date`s, which carry the **browser's** time
|
||||||
|
zone; the app's scheduling data is **business-local minutes since midnight on
|
||||||
|
a business-local date**. So the calendar never sees a real instant: times go in
|
||||||
|
as "floating" dates (minutes laid onto one fixed day — `calendar-time.ts`) and
|
||||||
|
a drop comes back out as plain minutes. The real date and the business time
|
||||||
|
zone are applied on the server in `moveCalendarAppointmentAction`.
|
||||||
|
|
||||||
|
## Drag to reschedule
|
||||||
|
|
||||||
|
Turned on per page with the `reschedule` prop (`"owner"` or `"staff"`).
|
||||||
|
|
||||||
|
- A drop **never saves on its own.** It opens a confirmation ("From 11:00 AM
|
||||||
|
with Leo → To 1:00 PM with Leo… the customer will be notified"), because a
|
||||||
|
reschedule emails the customer.
|
||||||
|
- `"owner"`: any appointment in the business, and it can be dropped into
|
||||||
|
another barber's column (a reassignment). `"staff"`: their own bookings, along
|
||||||
|
the time axis only.
|
||||||
|
- Only `pending_approval`, `confirmed` and `arrived` bookings can be moved —
|
||||||
|
the same statuses `rescheduleBooking` accepts. Breaks, time off, and finished
|
||||||
|
or in-progress bookings are not draggable. Resizing (changing the length) is
|
||||||
|
off: duration comes from the services.
|
||||||
|
- **The server is the gate.** `moveCalendarAppointmentAction` delegates to the
|
||||||
|
existing `ownerRescheduleAppointmentAction` / `rescheduleStaffAppointmentAction`,
|
||||||
|
so working hours, breaks, overlaps, the staff member's services, the business's
|
||||||
|
minimum-notice rule, the audit log, notifications, reminders and Google
|
||||||
|
Calendar sync all behave exactly as they do from the appointment screen. A
|
||||||
|
rejected move shows the reason inside the dialog and nothing is saved.
|
||||||
|
- The calendar itself does not stop you dropping onto a break or outside
|
||||||
|
someone's hours — it just fails the server check.
|
||||||
|
|
||||||
|
## Phones
|
||||||
|
|
||||||
|
Columns keep a 190px minimum and the grid scrolls sideways, with the hour
|
||||||
|
labels pinned on the left and the header row scrolling with it. Dragging was
|
||||||
|
verified with a mouse only; the library has touch handlers but this has not been
|
||||||
|
tried on a real touch screen. The appointment screen's Reschedule always works.
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
# Notifications reference
|
||||||
|
|
||||||
|
A living reference for every notification NextChair sends — what it is,
|
||||||
|
who gets it, on which channels, and whether the recipient can turn it
|
||||||
|
off. Unlike the other files in `docs/` (dated phase summaries), this one
|
||||||
|
is meant to be kept current as notifications change; if you add, remove,
|
||||||
|
or retarget one, update this file in the same change.
|
||||||
|
|
||||||
|
## Two separate mechanisms
|
||||||
|
|
||||||
|
Most of what's on this page goes through the **queued notification
|
||||||
|
system**: an event happens, a `Notification` + one `NotificationDelivery`
|
||||||
|
row per applicable channel is created immediately, but the actual
|
||||||
|
EMAIL/PUSH send only happens when the background processor runs (see
|
||||||
|
`src/services/notifications/reminder-worker.ts`, triggered by
|
||||||
|
`POST /api/cron/process-notifications` — see the root README's
|
||||||
|
"Notification delivery" section for how that's scheduled on a real
|
||||||
|
deployment). `IN_APP` deliveries have no send step; they're just a
|
||||||
|
database row the notification bell/centre reads directly.
|
||||||
|
|
||||||
|
**One-time codes** (below, in their own section) are different — they're
|
||||||
|
sent synchronously, the moment they're requested, with no queue at all.
|
||||||
|
|
||||||
|
Either way, nothing actually reaches an inbox unless that business has
|
||||||
|
`EMAIL_PROVIDER=brevo` configured (Manager's Settings → Email, or a
|
||||||
|
business's own `.env`) — with the default `EMAIL_PROVIDER=console`,
|
||||||
|
every send just logs to the container's console instead.
|
||||||
|
|
||||||
|
## Channels
|
||||||
|
|
||||||
|
- **EMAIL** — sent only if the recipient has a resolvable email address
|
||||||
|
(a registered user's `User.email`, or a guest customer's
|
||||||
|
`CustomerProfile.email`).
|
||||||
|
- **PUSH** — sent only if the recipient has at least one active (not
|
||||||
|
revoked) browser push subscription.
|
||||||
|
- **IN_APP** — only meaningful for a recipient with a real account
|
||||||
|
(`recipientUserId` set) — a guest customer never gets one, since they
|
||||||
|
have nothing to sign into.
|
||||||
|
|
||||||
|
## Preferences — who can turn what off, and what's on by default
|
||||||
|
|
||||||
|
See `src/services/notifications/types.ts`'s `PREFERENCE_CONTROLLED_TYPES`
|
||||||
|
— this is the single source of truth for which types have a toggle at
|
||||||
|
all; everything not listed there is **required** (always sent on every
|
||||||
|
channel it has a valid target for, no toggle anywhere — see "Fixed
|
||||||
|
no-email types" below for a separate, smaller mechanism that trims one
|
||||||
|
channel off a handful of these without making them toggleable).
|
||||||
|
|
||||||
|
- **Customer-facing**: only `BOOKING_REMINDER` is optional (and only
|
||||||
|
controls the customer reminder — see "Reminder timing"). Everything else
|
||||||
|
(confirmations, changes, cancellations, loyalty) always notifies the
|
||||||
|
customer on every resolvable channel — not finding out your
|
||||||
|
appointment was cancelled is a real risk, not just noise.
|
||||||
|
- **Staff- and owner-facing**: every type is optional. The owner of a
|
||||||
|
business is commonly also its only barber — one `StaffProfile`, one
|
||||||
|
account — so without this, a single new booking would unconditionally
|
||||||
|
notify that one person twice (`STAFF_NEW_APPOINTMENT` +
|
||||||
|
`OWNER_NEW_BOOKING`) on every channel, including in-app, with no way to
|
||||||
|
quiet either copy. (Cross-role dedup, below, now also prevents the
|
||||||
|
literal duplicate even when both happen to be enabled.)
|
||||||
|
|
||||||
|
Toggles live at **Settings → Notifications** — customers see one
|
||||||
|
"Appointment reminders" group; staff see their own six groups, plus five
|
||||||
|
more owner-only groups if they hold `VIEW_REPORTING` (i.e. are actually
|
||||||
|
an owner — see `src/services/notifications/recipients.ts`'s
|
||||||
|
`getOwnerUserIds`, which checks the same permission).
|
||||||
|
|
||||||
|
**Defaults**: `types.ts`'s `defaultPreferenceIsEnabled(type)` is the
|
||||||
|
single source of truth for what a toggle shows/does before anyone has
|
||||||
|
ever saved a preference row for it — used both by the actual send
|
||||||
|
decision (`notification.service.ts`'s `isChannelEnabled`) and by the
|
||||||
|
Settings pages themselves, so a toggle's initial on/off state always
|
||||||
|
matches what will actually happen. Once a user explicitly saves a
|
||||||
|
preference, that stored value always wins, in either direction — this
|
||||||
|
function only governs the "nobody has touched this yet" fallback.
|
||||||
|
|
||||||
|
Most staff/owner types default to **on**. These default to **off**
|
||||||
|
(on every channel)
|
||||||
|
instead — a solo owner-barber otherwise gets emailed/pushed/in-app'd for
|
||||||
|
everything the moment they sign in, with no gentle on-ramp:
|
||||||
|
|
||||||
|
| Type | Why |
|
||||||
|
|---|---|
|
||||||
|
| `STAFF_NEW_APPOINTMENT` | Noisy for a busy barber; `STAFF_APPOINTMENT_CANCELLED` (which stays on) is the one that actually needs acting on. |
|
||||||
|
| `STAFF_APPOINTMENT_RESCHEDULED` | Same reasoning. |
|
||||||
|
| `OWNER_NEW_BOOKING` | Same as STAFF_NEW_APPOINTMENT, from the owner's side. |
|
||||||
|
| `OWNER_NO_SHOW` | The barber who marked it already knows — there's no staff-facing equivalent by design. |
|
||||||
|
|
||||||
|
Defaults can also differ **per channel**, not just per type
|
||||||
|
(`DEFAULT_DISABLED_CHANNELS` in `types.ts`). Currently one:
|
||||||
|
`STAFF_APPOINTMENT_REMINDER` is **on** for push and in-app but **off** for
|
||||||
|
email — a barber gets a nudge on their phone two hours out without a second
|
||||||
|
email for something that's about to happen anyway. Email is opt-in under
|
||||||
|
Settings → Notifications.
|
||||||
|
|
||||||
|
## Fixed no-email types
|
||||||
|
|
||||||
|
A few types are `EMAIL` never, always, and unconditionally — not a
|
||||||
|
default that can be toggled back on, because the type either has no
|
||||||
|
preference toggle at all or (for `BOOKING_BARBER_CHANGED`) the decision
|
||||||
|
is per-booking context a stored preference can't express. This is
|
||||||
|
`NotifyParams.skipEmail` in `notification.service.ts` — a single boolean
|
||||||
|
the caller passes, checked in `resolveChannels()`'s EMAIL branch
|
||||||
|
independently of the preference system. PUSH/IN_APP are unaffected in
|
||||||
|
every case below.
|
||||||
|
|
||||||
|
| Type | Condition |
|
||||||
|
|---|---|
|
||||||
|
| `BOOKING_COMPLETED` | Always — a "thanks for visiting" email after every single cut was more noise than value. |
|
||||||
|
| `LOYALTY_STAMP_ADDED` | Always — a stamp is added on nearly every visit. |
|
||||||
|
| `LOYALTY_REWARD_REDEEMED` | Always — the customer is present when a reward is redeemed; an email receipt afterward added little. |
|
||||||
|
| `BOOKING_BARBER_CHANGED` | Only when the customer originally booked without picking a specific staff member ("any available barber" — see `Appointment.staffSelectedByCustomer`) and hasn't been reassigned before. Someone who booked "any available" is less likely to care who they end up with; someone who picked this barber specifically still gets emailed. |
|
||||||
|
|
||||||
|
## Cross-role deduplication
|
||||||
|
|
||||||
|
A person who holds two roles for the same booking (most commonly a solo
|
||||||
|
owner-barber: one `User`, one `StaffProfile`, and a role with
|
||||||
|
`VIEW_REPORTING`) can be the target of two *equivalent* notifications for
|
||||||
|
one underlying event — e.g. a new booking fires both
|
||||||
|
`STAFF_NEW_APPOINTMENT` (they're the assigned staff) and
|
||||||
|
`OWNER_NEW_BOOKING` (they're also an owner). Since both resolve to the
|
||||||
|
identical `recipientUserId`, `notification.service.ts`'s `notify()`
|
||||||
|
detects this and suppresses the less-specific one — on every channel,
|
||||||
|
not just email — rather than relying on the person to manually mute one
|
||||||
|
side.
|
||||||
|
|
||||||
|
`EQUIVALENCE_GROUPS` in that file lists which types are considered the
|
||||||
|
same underlying event, and their relative priority:
|
||||||
|
|
||||||
|
| Group | Types (higher priority first) |
|
||||||
|
|---|---|
|
||||||
|
| `new_appointment` | `STAFF_NEW_APPOINTMENT` > `OWNER_NEW_BOOKING` |
|
||||||
|
| `appointment_cancelled` | `STAFF_APPOINTMENT_CANCELLED` > `OWNER_BOOKING_CANCELLED` |
|
||||||
|
|
||||||
|
When an equivalent, higher-or-equal-priority notification already exists
|
||||||
|
for the same `(businessId, appointmentId, recipientUserId)`, the new one
|
||||||
|
is still created as a `Notification` row (audit trail, keeps
|
||||||
|
`idempotencyKey` semantics intact) but with **zero** deliveries — nothing
|
||||||
|
sends on any channel. This relies on call order: every real call site
|
||||||
|
(`booking-notifications.ts`) already notifies the STAFF_* type before
|
||||||
|
looping the OWNER_* type for the same event, so the more specific one is
|
||||||
|
always created first. Only types explicitly listed in
|
||||||
|
`EQUIVALENCE_GROUPS` ever participate — e.g. `OWNER_NO_SHOW` has no staff
|
||||||
|
equivalent and is never touched by this, and any future owner-only
|
||||||
|
*actionable* type (e.g. "approve this booking") would likewise stay
|
||||||
|
untouched simply by not being added to the map. Unrelated events for the
|
||||||
|
same appointment (a confirmation, then later a cancellation) are never
|
||||||
|
conflated — they're different types in different groups, or no group at
|
||||||
|
all.
|
||||||
|
|
||||||
|
## Reminder timing
|
||||||
|
|
||||||
|
`src/services/scheduling/reminder-scheduling.ts` schedules reminders at
|
||||||
|
fixed offsets before the appointment, independently for the customer and
|
||||||
|
the assigned staff member:
|
||||||
|
|
||||||
|
- **Customer** (`BOOKING_REMINDER`): one reminder, **24 hours** before. If
|
||||||
|
the booking was made too late for that (a same-day or next-morning
|
||||||
|
booking), they get one **2 hours** before instead — never both, and never
|
||||||
|
nothing, unless the appointment is under 2 hours away.
|
||||||
|
- **Staff** (`STAFF_APPOINTMENT_REMINDER`): one reminder, **2 hours**
|
||||||
|
before. No 24-hour one. Push and in-app on by default, email opt-in —
|
||||||
|
see "Defaults" above.
|
||||||
|
- **Owner**: no reminders.
|
||||||
|
|
||||||
|
A reminder time that's already in the past when the booking is
|
||||||
|
made/approved is never scheduled retroactively.
|
||||||
|
|
||||||
|
**In-app reminders are held back until they're due.** Every scheduled
|
||||||
|
notification's in-app copy is created `SCHEDULED`, not `SENT`, so it stays
|
||||||
|
out of the notification bell and unread count until the background
|
||||||
|
processor releases it at the reminder time (`releaseDueInAppReminders` in
|
||||||
|
`reminder-worker.ts`). Cancelling or rescheduling cancels it along with the
|
||||||
|
email/push copies, so a stale reminder never appears.
|
||||||
|
|
||||||
|
**Stale-send guard.** Before sending (or releasing) a reminder, the worker
|
||||||
|
checks the appointment is still `CONFIRMED` and hasn't started; if not, the
|
||||||
|
delivery is marked `CANCELLED` (not `FAILED`, so it doesn't count toward the
|
||||||
|
owner's delivery-failure alert). This is a backstop for the processor being
|
||||||
|
down long enough that a "24 hours before" reminder would otherwise arrive
|
||||||
|
after the appointment.
|
||||||
|
|
||||||
|
**Guest links.** A guest has no account, so every customer-facing link in
|
||||||
|
their emails carries an access token (`customerAppointmentLink` in
|
||||||
|
`guest-access.service.ts`). The booking-time token is random and only its
|
||||||
|
hash is stored, so later emails use a token *derived* from the appointment
|
||||||
|
id and `AUTH_SECRET` instead; `validateGuestAccessToken` accepts either, and
|
||||||
|
revoking a guest appointment's access kills both.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Customer-facing
|
||||||
|
|
||||||
|
| Type | Fires when | Default subject | Email |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `BOOKING_CONFIRMED` | A new booking is created and doesn't need approval | "Booking confirmed" | Always |
|
||||||
|
| `BOOKING_PENDING_APPROVAL` | A new booking is created but the business requires approval first | "Booking request received" | Always |
|
||||||
|
| `BOOKING_APPROVED` | An owner approves a pending booking | "Booking approved" | Always |
|
||||||
|
| `BOOKING_RESCHEDULED` | The appointment's time changes (including an owner reassigning staff, which also triggers a reschedule) | "Booking rescheduled" | Always |
|
||||||
|
| `BOOKING_CANCELLED` | The appointment is cancelled, by anyone | "Booking cancelled" (carries a "This was a late cancellation." note when applicable — not a separate type) | Always |
|
||||||
|
| `BOOKING_BARBER_CHANGED` | An owner reassigns the appointment to a different staff member | "Your barber has changed" | Only if the customer originally picked a specific staff member — see "Fixed no-email types" |
|
||||||
|
| `BOOKING_COMPLETED` | The appointment is marked completed | "Thanks for visiting" | Never |
|
||||||
|
| `BOOKING_REMINDER` *(optional)* | 24 hours before a confirmed appointment (2 hours before if booked with less than 24 hours' notice) | "Upcoming appointment reminder" | Default on |
|
||||||
|
| `LOYALTY_STAMP_ADDED` | A loyalty stamp is added to their card | "You've earned a loyalty stamp" | Never |
|
||||||
|
| `LOYALTY_REWARD_EARNED` | Their stamp count reaches the reward threshold | "You've earned a reward" | Always |
|
||||||
|
| `LOYALTY_REWARD_EXPIRING` | A reward's expiry is within 7 days — sent once when it enters that window, and once more when it's down to its last day (two at most per reward) | "Your reward is expiring soon" | Always |
|
||||||
|
| `LOYALTY_REWARD_REDEEMED` | Their reward is applied to an appointment | "Reward applied" | Never |
|
||||||
|
|
||||||
|
## Staff-facing
|
||||||
|
|
||||||
|
| Type | Fires when | Default subject | Default |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `STAFF_NEW_APPOINTMENT` *(optional)* | A new appointment is booked with them | "New appointment" | Off |
|
||||||
|
| `STAFF_APPOINTMENT_RESCHEDULED` *(optional)* | One of their appointments is rescheduled | "Appointment rescheduled" | Off |
|
||||||
|
| `STAFF_APPOINTMENT_CANCELLED` *(optional)* | One of their appointments is cancelled | "Appointment cancelled" | On |
|
||||||
|
| `STAFF_APPOINTMENT_REMINDER` *(optional)* | 2 hours before their appointment | "Upcoming appointment reminder" | In-app and push on, email off |
|
||||||
|
| `STAFF_SCHEDULE_CHANGED` *(optional)* | An **owner** edits their working hours/breaks/time-off on their behalf — never fires from the self-service editors, since notifying someone about their own edit would just be noise | "Your schedule was updated" | On |
|
||||||
|
| `STAFF_INVITATION_RECEIVED` *(optional)* | They're added as a new team member | "You've been invited to join `{business}`" | On |
|
||||||
|
| `STAFF_APPOINTMENT_CHECKED_IN` | Defined in the schema, has a recipient assigned — **but nothing in the codebase currently triggers it.** Preference-controlled for forward-compatibility, but has no toggle in the UI since there's nothing to preview. Left dormant on purpose | — | — |
|
||||||
|
|
||||||
|
## Owner-facing
|
||||||
|
|
||||||
|
Sent to *every* active staff member whose role holds `VIEW_REPORTING` —
|
||||||
|
that can be more than one person; there's no single "the owner" column
|
||||||
|
anywhere in the schema.
|
||||||
|
|
||||||
|
| Type | Fires when | Default subject | Default |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `OWNER_NEW_BOOKING` *(optional)* | A new booking is made anywhere in the business | "New booking" | Off |
|
||||||
|
| `OWNER_BOOKING_CANCELLED` *(optional)* | A booking is cancelled anywhere in the business | "Booking cancelled" | On |
|
||||||
|
| `OWNER_NO_SHOW` *(optional)* | A customer is marked as a no-show (staff-facing equivalent doesn't exist — the barber who marked it already knows) | "No-show recorded" | Off |
|
||||||
|
| `OWNER_STAFF_INVITATION_ACCEPTED` *(optional)* | An invited staff member verifies their email and joins | "Invitation accepted" | On |
|
||||||
|
| `OWNER_REPEATED_DELIVERY_FAILURE` *(optional)* | The background processor itself detects repeated delivery failures that day — a self-monitoring alert, not triggered by a domain event. At most once per business per day | "Some notifications failed to send" | On |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## One-time codes (not part of the system above)
|
||||||
|
|
||||||
|
Sent immediately and synchronously — see `src/lib/auth/otp.service.ts`.
|
||||||
|
Not queued, not preference-controlled, no in-app record.
|
||||||
|
|
||||||
|
| Purpose | Sent to | When |
|
||||||
|
|---|---|---|
|
||||||
|
| `login` | The email being signed in with | Every sign-in attempt |
|
||||||
|
| `register` | The email registering | Customer self-registration |
|
||||||
|
| `staff-invite` | The invited email | Accepting a staff invitation (`/invite/accept`) |
|
||||||
|
| `install` | The administrator's email | The install wizard's administrator-verification step |
|
||||||
|
| `email-change` | The **new** email address | A signed-in user changing their account email |
|
||||||
|
|
||||||
|
## Platform-level (Manager, not tied to any business)
|
||||||
|
|
||||||
|
- **Settings → Email → Send Test Email** — a manual, one-off admin action
|
||||||
|
in the Manager app, unrelated to any business's own notification queue.
|
||||||
|
See `manager/src/lib/test-email.ts`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Where things live, if you're changing any of this
|
||||||
|
|
||||||
|
- **What exists / who it's for**: `prisma/schema.prisma`'s
|
||||||
|
`NotificationType` enum, and `RECIPIENT_KIND` in
|
||||||
|
`src/services/notifications/types.ts`.
|
||||||
|
- **Whether it's optional, and its default**: `PREFERENCE_CONTROLLED_TYPES`
|
||||||
|
and `defaultPreferenceIsEnabled()`, same file.
|
||||||
|
- **Fixed no-email types**: `NotifyParams.skipEmail` in
|
||||||
|
`notification.service.ts`, set at the relevant call site in
|
||||||
|
`booking-notifications.ts`/`loyalty-notifications.ts`.
|
||||||
|
- **Cross-role dedup**: `EQUIVALENCE_GROUPS` in `notification.service.ts`.
|
||||||
|
- **Whether the customer picked a specific barber originally**:
|
||||||
|
`Appointment.staffSelectedByCustomer`, set once at booking creation in
|
||||||
|
`booking.service.ts`'s `createBooking()`, read at reassignment time in
|
||||||
|
`src/features/owner/actions/appointments.ts`.
|
||||||
|
- **When it actually fires**: `src/services/scheduling/booking-notifications.ts`,
|
||||||
|
`reminder-scheduling.ts`, `src/services/loyalty/loyalty-notifications.ts`,
|
||||||
|
`src/services/staff/staff-notifications.ts`,
|
||||||
|
`src/services/staff/staff-invitation.service.ts`, and
|
||||||
|
`reminder-worker.ts` (for the self-monitoring alert).
|
||||||
|
- **Channel resolution / preference enforcement**:
|
||||||
|
`src/services/notifications/notification.service.ts`'s
|
||||||
|
`resolveChannels`/`isChannelEnabled`.
|
||||||
|
- **The actual send**: `src/services/notifications/providers/` (Brevo/
|
||||||
|
console email, web-push/console push) plus `reminder-worker.ts`, which
|
||||||
|
processes the queue.
|
||||||
|
- **Settings UI**: `src/features/staff/components/notification-preferences-form.tsx`
|
||||||
|
(shared by customer/staff/owner), wired up per audience in
|
||||||
|
`src/app/(customer)/settings/notifications/page.tsx` and
|
||||||
|
`src/app/(staff)/staff/settings/notifications/page.tsx`.
|
||||||
|
- **Guest-to-account merge** (what happens to a guest's history when they
|
||||||
|
later sign up): `src/services/customer/guest-merge.service.ts`, called from
|
||||||
|
`authorizeCredentials` (OTP sign-in), `src/auth.ts`'s Google `signIn`
|
||||||
|
event, and `createCustomerByStaff`.
|
||||||
@@ -69,7 +69,7 @@ Documented in full in `src/services/customer/customer.service.ts`; summary:
|
|||||||
2. A guest caller is matched against an existing profile only if **both** email (case-insensitive, normalised in application code — not Prisma's `mode: "insensitive"`, which is Postgres/Mongo-only and would break on a MySQL swap) **and** phone match exactly, against `isGuest = true AND userId IS NULL` profiles only.
|
2. A guest caller is matched against an existing profile only if **both** email (case-insensitive, normalised in application code — not Prisma's `mode: "insensitive"`, which is Postgres/Mongo-only and would break on a MySQL swap) **and** phone match exactly, against `isGuest = true AND userId IS NULL` profiles only.
|
||||||
3. A guest is never matched against a registered customer's profile, even on an exact email match — that would attach a guest's booking to a real account without verification.
|
3. A guest is never matched against a registered customer's profile, even on an exact email match — that would attach a guest's booking to a real account without verification.
|
||||||
4. No match → a new guest profile is created. Existing-profile fields are never overwritten on a match.
|
4. No match → a new guest profile is created. Existing-profile fields are never overwritten on a match.
|
||||||
5. **Guest appointment access is "order tracking" by unguessable id**, not by verified identity — no magic-link/OTP system was built this phase. This is a deliberate, documented scope limitation (see §12), not an oversight.
|
5. ~~**Guest appointment access is "order tracking" by unguessable id**, not by verified identity~~ — *superseded in Phase 7.* A guest appointment now needs a single-purpose access token in the link (`?token=…`), checked by `assertCanAccessAppointment` against `GuestAppointmentAccessToken` (see `src/services/scheduling/guest-access.service.ts` and `docs/notifications.md`). The appointment id alone is rejected; the page returns "not found" and the actions refuse. This is covered by `src/services/scheduling/__tests__/guest-access.integration.test.ts` ("an appointment id alone is rejected"). What was originally described here — no magic-link/OTP flow in Phase 4 — is still true of the phase as shipped.
|
||||||
|
|
||||||
## 8. Security / ownership rules
|
## 8. Security / ownership rules
|
||||||
|
|
||||||
@@ -117,11 +117,11 @@ Found and fixed while verifying, not left in place:
|
|||||||
- **A silent test-cleanup leak** in the Phase 3 integration fixtures: `cleanupTestBusiness` tried to delete the test `Business` first, but `Appointment.staff` is `onDelete: Restrict` (a deliberate production safeguard), which blocked the cascade to `StaffProfile` for any test that had actually booked something — the failure was swallowed by a bare `.catch(() => {})`. This had been silently leaking a "Test Barbershop" row per affected test run since Phase 3; it only surfaced now because a manual verification pass queried `getPrimaryBusiness()` against a database that had just run the integration suite and picked up a leaked row instead of the real seed. Fixed by deleting the business's appointments before the business itself.
|
- **A silent test-cleanup leak** in the Phase 3 integration fixtures: `cleanupTestBusiness` tried to delete the test `Business` first, but `Appointment.staff` is `onDelete: Restrict` (a deliberate production safeguard), which blocked the cascade to `StaffProfile` for any test that had actually booked something — the failure was swallowed by a bare `.catch(() => {})`. This had been silently leaking a "Test Barbershop" row per affected test run since Phase 3; it only surfaced now because a manual verification pass queried `getPrimaryBusiness()` against a database that had just run the integration suite and picked up a leaked row instead of the real seed. Fixed by deleting the business's appointments before the business itself.
|
||||||
- **Desktop layout was effectively unstyled for the "browsing" screens** (Home, Appointments, Settings, and the bottom nav) — content simply didn't reflow or cap its width above mobile size, leaving a large empty right-hand column rather than anything intentional. Applied a **baseline** fix (a centered `max-w-2xl` content column, matching the pattern the booking flow's individual steps already used) across Home, Appointments, Settings, the shared `BottomNav`, `BarberDateTimePicker`, and `DetailsForm`. **This is not a full desktop redesign** — no side-by-side / grid layouts were built for browsing screens, only a consistent centered treatment so nothing looks broken. A genuine desktop-specific layout (e.g. a wider services grid, a split-pane appointment list) is a reasonable Phase 5+ item if desktop usage turns out to matter.
|
- **Desktop layout was effectively unstyled for the "browsing" screens** (Home, Appointments, Settings, and the bottom nav) — content simply didn't reflow or cap its width above mobile size, leaving a large empty right-hand column rather than anything intentional. Applied a **baseline** fix (a centered `max-w-2xl` content column, matching the pattern the booking flow's individual steps already used) across Home, Appointments, Settings, the shared `BottomNav`, `BarberDateTimePicker`, and `DetailsForm`. **This is not a full desktop redesign** — no side-by-side / grid layouts were built for browsing screens, only a consistent centered treatment so nothing looks broken. A genuine desktop-specific layout (e.g. a wider services grid, a split-pane appointment list) is a reasonable Phase 5+ item if desktop usage turns out to matter.
|
||||||
- **Reschedule's route was moved out of the `(customer)` route group** (`(customer)/appointments/[id]/reschedule` → top-level `appointments/[id]/reschedule`) after discovering the shared picker's own fixed bottom action bar was being covered by the persistent bottom nav on that route, blocking the "Confirm Reschedule" button. Matches the same reasoning `/book`'s layout already documents.
|
- **Reschedule's route was moved out of the `(customer)` route group** (`(customer)/appointments/[id]/reschedule` → top-level `appointments/[id]/reschedule`) after discovering the shared picker's own fixed bottom action bar was being covered by the persistent bottom nav on that route, blocking the "Confirm Reschedule" button. Matches the same reasoning `/book`'s layout already documents.
|
||||||
- **Guest appointment access remains order-tracking-by-id only** (§7, point 5) — not a bug, but worth restating as the clearest remaining security-adjacent scope boundary: a guest with the appointment id can view/reschedule/cancel it without further verification. Fine for this phase's threat model (ids are unguessable `cuid`s, never enumerable), but a real magic-link/OTP flow would close this gap properly.
|
- ~~**Guest appointment access remains order-tracking-by-id only**~~ — *closed in Phase 7.* A guest with only the appointment id can no longer view, reschedule, cancel or export the booking; they need the token from their confirmation page or email link (§7, point 5). Still open and deliberate: the token is a bearer link (anyone holding it has access until it expires, 90 days after the appointment ends) rather than a verified identity such as an OTP.
|
||||||
|
|
||||||
## 13. Before Phase 5
|
## 13. Before Phase 5
|
||||||
|
|
||||||
- Decide whether guest-booking access needs stronger verification (magic link / OTP) before this becomes a compliance concern, or whether "unguessable id" remains acceptable for the foreseeable future.
|
- ~~Decide whether guest-booking access needs stronger verification~~ — answered in Phase 7 with per-appointment access tokens; a magic-link/OTP step on top remains optional.
|
||||||
- If desktop usage is expected to matter, budget real design time for Home/Appointments rather than the baseline centering applied here.
|
- If desktop usage is expected to matter, budget real design time for Home/Appointments rather than the baseline centering applied here.
|
||||||
- The `e2e/` suite and `test:integration` script both assume a disposable Postgres the caller provides — there's no CI wiring yet to spin one up automatically; that's worth doing before this becomes the main safety net.
|
- The `e2e/` suite and `test:integration` script both assume a disposable Postgres the caller provides — there's no CI wiring yet to spin one up automatically; that's worth doing before this becomes the main safety net.
|
||||||
- Loyalty UI, staff/owner-facing screens, notifications, and payments are all still untouched, exactly as scoped.
|
- Loyalty UI, staff/owner-facing screens, notifications, and payments are all still untouched, exactly as scoped.
|
||||||
@@ -157,16 +157,16 @@ npm run test:e2e # needs DATABASE_URL → a real (throwaway) Postgres
|
|||||||
|
|
||||||
## 19. Known limitations
|
## 19. Known limitations
|
||||||
|
|
||||||
- **Working-hours editing has no conflict detection**, on both the self-service (Phase 5) and owner-on-behalf-of-staff (this phase) editors — unlike breaks, time off, business hours, staff deactivation, and service-eligibility removal, all of which do. A pre-existing Phase 5 gap this phase chose not to retroactively fix, given the size of the remaining Phase 6 scope; flagged again here rather than silently carried forward.
|
- **Working-hours editing had no conflict detection** (both the self-service and owner-on-behalf editors) — **closed in Phase 7** ("Working-hours conflict review": `findWorkingHoursConflicts` / `setWorkingHours({ acknowledgeConflicts })` in `staff.service.ts`, the conflict list in `working-hours-form.tsx`, covered by `staff.service.integration.test.ts`). Kept here only as history.
|
||||||
- **Owner-editable breaks/whole-day time off for a staff member were not built** — only partial-day "Block Time" (via `OwnerBlockTimeButton`) satisfies "add time off on behalf of staff" this phase; a staff member's own recurring breaks and whole-day time off can currently only be edited by that staff member themselves.
|
- **~~Owner-editable breaks/whole-day time off for a staff member were not built~~** — *resolved.* Owner-on-behalf-of editing of a staff member's breaks and whole-day time off was built in Phase 7 (Manage → Team → staff → Breaks / Time Off). The one remaining piece, editing and deleting partial-day "Block Time" entries (which `OwnerBlockTimeButton` creates), now also works: they are listed on the same Time Off page, for the owner (`MANAGE_STAFF`) and for staff on their own Time Off settings page, using the same update/delete actions and conflict review.
|
||||||
- **Staff service eligibility is edited only from the Staff Detail page**, not duplicated on the Services & Categories page (the brief mentions "assign eligible staff" under Services too) — one edit surface, avoiding two places that could drift out of sync.
|
- **Staff service eligibility is edited only from the Staff Detail page**, not duplicated on the Services & Categories page (the brief mentions "assign eligible staff" under Services too) — one edit surface, avoiding two places that could drift out of sync.
|
||||||
- **"Requires staff approval to redeem" is informational text only** in the loyalty config UI — redemption is already gated behind the `MANAGE_LOYALTY_STAMPS` permission and there is no customer-initiated redemption request to approve against, so no separate approval-queue workflow exists.
|
- **"Requires staff approval to redeem" and "Apply reward automatically" were removed from the loyalty settings form** — neither did anything (redemption is always staff-initiated and gated by `MANAGE_LOYALTY_STAMPS`). The two `LoyaltyProgramme` columns still exist, unused.
|
||||||
- **A repeated run of the E2E suite against the same (non-reseeded) database is not fully idempotent**: journey 10 (redeem reward) consumes the seed's one AVAILABLE reward, and one seed appointment (`appt-today-checkedin`, timed via `minutesFromNow` to reliably exercise `selectCurrentAppointment`'s CHECKED_IN tier) can occasionally start a few minutes before the business's 10:00 opening depending on exactly when the seed happens to run, which the opening-hours conflict test now handles defensively rather than assuming a clean save — this is a real, if narrow, characteristic of time-relative seed fixtures re-run without a fresh database, same category of issue Phase 5's own summary flagged (§13 there); it does **not** affect a single authoritative run against a freshly seeded database, which is how CI should always run it.
|
- **A repeated run of the E2E suite against the same (non-reseeded) database is not fully idempotent**: journey 10 (redeem reward) consumes the seed's one AVAILABLE reward, and one seed appointment (`appt-today-checkedin`, timed via `minutesFromNow` to reliably exercise `selectCurrentAppointment`'s CHECKED_IN tier) can occasionally start a few minutes before the business's 10:00 opening depending on exactly when the seed happens to run, which the opening-hours conflict test now handles defensively rather than assuming a clean save — this is a real, if narrow, characteristic of time-relative seed fixtures re-run without a fresh database, same category of issue Phase 5's own summary flagged (§13 there); it does **not** affect a single authoritative run against a freshly seeded database, which is how CI should always run it.
|
||||||
- **Notification delivery, payments, deposits, advanced financial reporting, payroll, multi-business/multi-location, PWA, and offline** remain entirely out of scope, per the brief's own boundary.
|
- **Notification delivery, payments, deposits, advanced financial reporting, payroll, multi-business/multi-location, PWA, and offline** remain entirely out of scope, per the brief's own boundary.
|
||||||
|
|
||||||
## 20. Review items for Phase 7
|
## 20. Review items for Phase 7
|
||||||
|
|
||||||
- Decide whether owner-editable breaks and whole-day time off (on behalf of staff) should be built, or whether "Block Time" (partial-day) remains the only owner-side lever indefinitely.
|
- ~~Decide whether owner-editable breaks and whole-day time off (on behalf of staff) should be built~~ — built in Phase 7; Block Time edit/delete followed later (see §19).
|
||||||
- Decide whether working-hours edits (both self-service and owner-on-behalf-of) should gain the same conflict-review treatment as breaks/time-off/business-hours, closing the gap flagged in §19.
|
- Decide whether working-hours edits (both self-service and owner-on-behalf-of) should gain the same conflict-review treatment as breaks/time-off/business-hours, closing the gap flagged in §19.
|
||||||
- Real notification delivery (email/SMS/push) is still entirely unbuilt — both the Phase 5 preference grid and this phase's invite-link workaround are waiting on it.
|
- Real notification delivery (email/SMS/push) is still entirely unbuilt — both the Phase 5 preference grid and this phase's invite-link workaround are waiting on it.
|
||||||
- Payments/deposits and any real financial reporting beyond the operational dashboard estimate are unstarted; `Appointment.discountAmount` exists and is populated correctly, but nothing yet reconciles it against an actual payment.
|
- Payments/deposits and any real financial reporting beyond the operational dashboard estimate are unstarted; `Appointment.discountAmount` exists and is populated correctly, but nothing yet reconciles it against an actual payment.
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { IN_APP_NOTIFICATIONS_VISIBLE } from "../src/services/notifications/in-app";
|
||||||
import { SEEDED, completeBookingFlow, completeRescheduleFlow, findAppointmentCard, signIn, uniqueEmail } from "./helpers";
|
import { SEEDED, completeBookingFlow, completeRescheduleFlow, findAppointmentCard, signIn, uniqueEmail } from "./helpers";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -50,7 +51,7 @@ test.describe("customer booking journeys", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("2. registered customer books an appointment", async ({ page }) => {
|
test("2. registered customer books an appointment", async ({ page }) => {
|
||||||
await signIn(page, SEEDED.registeredCustomer.email, SEEDED.registeredCustomer.password);
|
await signIn(page, SEEDED.registeredCustomer.email);
|
||||||
|
|
||||||
await page.goto("/book");
|
await page.goto("/book");
|
||||||
await completeBookingFlow(page, {
|
await completeBookingFlow(page, {
|
||||||
@@ -65,29 +66,37 @@ test.describe("customer booking journeys", () => {
|
|||||||
await expect(findAppointmentCard(page, SEEDED.services.skinfade, SEEDED.barberSkinfadeBeard.name).first()).toBeVisible();
|
await expect(findAppointmentCard(page, SEEDED.services.skinfade, SEEDED.barberSkinfadeBeard.name).first()).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("3. customer views upcoming appointments", async ({ page }) => {
|
test("3. customer views all appointments in one list", async ({ page }) => {
|
||||||
await signIn(page, SEEDED.registeredCustomer.email, SEEDED.registeredCustomer.password);
|
await signIn(page, SEEDED.registeredCustomer.email);
|
||||||
await page.goto("/appointments");
|
await page.goto("/appointments");
|
||||||
|
|
||||||
// Seeded: an upcoming CONFIRMED Haircut & Style with Marcus Reed.
|
// One list, no tabs.
|
||||||
await expect(page.getByRole("tab", { name: "Upcoming" })).toHaveAttribute("data-state", "active");
|
await expect(page.getByRole("tab")).toHaveCount(0);
|
||||||
const upcomingSeeded = findAppointmentCard(page, SEEDED.services.haircut, SEEDED.owner.name);
|
|
||||||
|
// Seeded: an upcoming CONFIRMED Haircut & Style with Marcus Reed — reschedulable and cancellable.
|
||||||
|
// The seed also has a completed Haircut & Style with Marcus Reed, so pick the card by status too.
|
||||||
|
const upcomingSeeded = findAppointmentCard(page, SEEDED.services.haircut, SEEDED.owner.name).filter({ hasText: "Confirmed" });
|
||||||
await expect(upcomingSeeded).toBeVisible();
|
await expect(upcomingSeeded).toBeVisible();
|
||||||
await expect(upcomingSeeded.getByText("Confirmed")).toBeVisible();
|
await expect(upcomingSeeded.getByText("Confirmed")).toBeVisible();
|
||||||
|
await expect(upcomingSeeded.getByRole("link", { name: "Reschedule" })).toBeVisible();
|
||||||
|
await expect(upcomingSeeded.getByRole("button", { name: "Cancel", exact: true })).toBeVisible();
|
||||||
|
await expect(upcomingSeeded.getByRole("button", { name: "Book Again" })).toHaveCount(0);
|
||||||
|
|
||||||
// Seeded: a COMPLETED Haircut & Style with Leo Vance.
|
// Seeded: a COMPLETED Haircut & Style with Leo Vance — same list, Book Again instead.
|
||||||
await page.getByRole("tab", { name: "Past" }).click();
|
const pastSeeded = findAppointmentCard(page, SEEDED.services.haircut, SEEDED.barberHaircutOnly.name).filter({ hasText: "Completed" });
|
||||||
const pastSeeded = findAppointmentCard(page, SEEDED.services.haircut, SEEDED.barberHaircutOnly.name);
|
|
||||||
await expect(pastSeeded).toBeVisible();
|
await expect(pastSeeded).toBeVisible();
|
||||||
await expect(pastSeeded.getByText("Completed")).toBeVisible();
|
await expect(pastSeeded.getByText("Completed")).toBeVisible();
|
||||||
|
await expect(pastSeeded.getByRole("button", { name: "Book Again" })).toBeVisible();
|
||||||
|
await expect(pastSeeded.getByRole("link", { name: "Reschedule" })).toHaveCount(0);
|
||||||
|
|
||||||
// Alex Chen has no cancelled appointments in the seed data.
|
// Newest first: the upcoming appointment is above the completed one.
|
||||||
await page.getByRole("tab", { name: "Cancelled" }).click();
|
const upcomingBox = await upcomingSeeded.boundingBox();
|
||||||
await expect(page.getByText("No cancelled appointments")).toBeVisible();
|
const pastBox = await pastSeeded.boundingBox();
|
||||||
|
expect(upcomingBox!.y).toBeLessThan(pastBox!.y);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("4. customer reschedules an appointment", async ({ page }) => {
|
test("4. customer reschedules an appointment", async ({ page }) => {
|
||||||
await signIn(page, SEEDED.registeredCustomer.email, SEEDED.registeredCustomer.password);
|
await signIn(page, SEEDED.registeredCustomer.email);
|
||||||
|
|
||||||
// Book a dedicated appointment for this test so it doesn't depend on the seeded one or on other tests.
|
// Book a dedicated appointment for this test so it doesn't depend on the seeded one or on other tests.
|
||||||
await page.goto("/book");
|
await page.goto("/book");
|
||||||
@@ -113,17 +122,17 @@ test.describe("customer booking journeys", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("5. customer cancels an appointment", async ({ page }) => {
|
test("5. customer cancels an appointment", async ({ page }) => {
|
||||||
await signIn(page, SEEDED.registeredCustomer.email, SEEDED.registeredCustomer.password);
|
await signIn(page, SEEDED.registeredCustomer.email);
|
||||||
|
|
||||||
await page.goto("/book");
|
await page.goto("/book");
|
||||||
await completeBookingFlow(page, { serviceName: SEEDED.services.beardTrim, staffName: SEEDED.barberSkinfadeBeard.name });
|
await completeBookingFlow(page, { serviceName: SEEDED.services.beardTrim, staffName: SEEDED.barberSkinfadeBeard.name });
|
||||||
|
|
||||||
await page.goto("/appointments");
|
await page.goto("/appointments");
|
||||||
const upcomingCard = findAppointmentCard(page, SEEDED.services.beardTrim, SEEDED.barberSkinfadeBeard.name);
|
const upcomingCard = findAppointmentCard(page, SEEDED.services.beardTrim, SEEDED.barberSkinfadeBeard.name).first();
|
||||||
await expect(upcomingCard).toBeVisible();
|
await expect(upcomingCard).toBeVisible();
|
||||||
|
|
||||||
await upcomingCard.getByRole("button", { name: "Cancel", exact: true }).click();
|
await upcomingCard.getByRole("button", { name: "Cancel", exact: true }).click();
|
||||||
// Scoped to the currently-open <dialog> — the Upcoming tab can have several
|
// Scoped to the currently-open <dialog> — the list can have several
|
||||||
// cancellable cards, each with its own (closed) dialog in the DOM.
|
// cancellable cards, each with its own (closed) dialog in the DOM.
|
||||||
const dialog = page.locator("dialog[open]");
|
const dialog = page.locator("dialog[open]");
|
||||||
await expect(dialog.getByRole("heading", { name: "Cancel Appointment?" })).toBeVisible();
|
await expect(dialog.getByRole("heading", { name: "Cancel Appointment?" })).toBeVisible();
|
||||||
@@ -133,21 +142,21 @@ test.describe("customer booking journeys", () => {
|
|||||||
await expect(dialog.getByText("Your appointment has been cancelled.")).toBeVisible();
|
await expect(dialog.getByText("Your appointment has been cancelled.")).toBeVisible();
|
||||||
await dialog.getByRole("button", { name: "Done" }).click();
|
await dialog.getByRole("button", { name: "Done" }).click();
|
||||||
|
|
||||||
// Cancellation never deletes — it moves from Upcoming to the Cancelled tab.
|
// Cancellation never deletes — the same card stays in the list, now with a Cancelled pill
|
||||||
await expect(findAppointmentCard(page, SEEDED.services.beardTrim, SEEDED.barberSkinfadeBeard.name)).toHaveCount(0);
|
// and Book Again instead of Reschedule/Cancel.
|
||||||
await page.getByRole("tab", { name: "Cancelled" }).click();
|
const cancelledCard = findAppointmentCard(page, SEEDED.services.beardTrim, SEEDED.barberSkinfadeBeard.name).first();
|
||||||
const cancelledCard = findAppointmentCard(page, SEEDED.services.beardTrim, SEEDED.barberSkinfadeBeard.name);
|
|
||||||
await expect(cancelledCard).toBeVisible();
|
await expect(cancelledCard).toBeVisible();
|
||||||
await expect(cancelledCard.getByText("Cancelled")).toBeVisible();
|
await expect(cancelledCard.getByText("Cancelled")).toBeVisible();
|
||||||
|
await expect(cancelledCard.getByRole("button", { name: "Book Again" })).toBeVisible();
|
||||||
|
await expect(cancelledCard.getByRole("link", { name: "Reschedule" })).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("6. customer books again from history", async ({ page }) => {
|
test("6. customer books again from history", async ({ page }) => {
|
||||||
await signIn(page, SEEDED.registeredCustomer.email, SEEDED.registeredCustomer.password);
|
await signIn(page, SEEDED.registeredCustomer.email);
|
||||||
await page.goto("/appointments");
|
await page.goto("/appointments");
|
||||||
|
|
||||||
// Seeded COMPLETED appointment (Haircut & Style with Leo Vance).
|
// Seeded COMPLETED appointment (Haircut & Style with Leo Vance).
|
||||||
await page.getByRole("tab", { name: "Past" }).click();
|
const completedCard = findAppointmentCard(page, SEEDED.services.haircut, SEEDED.barberHaircutOnly.name).filter({ hasText: "Completed" });
|
||||||
const completedCard = findAppointmentCard(page, SEEDED.services.haircut, SEEDED.barberHaircutOnly.name);
|
|
||||||
await completedCard.getByRole("button", { name: "Book Again" }).click();
|
await completedCard.getByRole("button", { name: "Book Again" }).click();
|
||||||
|
|
||||||
// Services (and the original barber, if still bookable) come preselected — the customer only re-confirms a fresh date/time.
|
// Services (and the original barber, if still bookable) come preselected — the customer only re-confirms a fresh date/time.
|
||||||
@@ -160,13 +169,13 @@ test.describe("customer booking journeys", () => {
|
|||||||
|
|
||||||
// A brand new appointment now exists alongside the historical one — the completed appointment is untouched.
|
// A brand new appointment now exists alongside the historical one — the completed appointment is untouched.
|
||||||
await page.goto("/appointments");
|
await page.goto("/appointments");
|
||||||
await expect(findAppointmentCard(page, SEEDED.services.haircut, SEEDED.barberHaircutOnly.name)).toBeVisible();
|
|
||||||
await page.getByRole("tab", { name: "Past" }).click();
|
|
||||||
await expect(completedCard).toBeVisible();
|
await expect(completedCard).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("7. booking confirmation creates an in-app notification the customer can read and mark read", async ({ page }) => {
|
test("7. booking confirmation creates an in-app notification the customer can read and mark read", async ({ page }) => {
|
||||||
await signIn(page, SEEDED.registeredCustomer.email, SEEDED.registeredCustomer.password);
|
test.skip(!IN_APP_NOTIFICATIONS_VISIBLE, "In-app notifications are hidden — see src/features/notifications/in-app.ts");
|
||||||
|
|
||||||
|
await signIn(page, SEEDED.registeredCustomer.email);
|
||||||
|
|
||||||
await page.goto("/book");
|
await page.goto("/book");
|
||||||
await completeBookingFlow(page, {
|
await completeBookingFlow(page, {
|
||||||
@@ -197,7 +206,7 @@ test.describe("customer booking journeys", () => {
|
|||||||
test("8. customer sees their available loyalty reward on Home and the Loyalty page", async ({ page }) => {
|
test("8. customer sees their available loyalty reward on Home and the Loyalty page", async ({ page }) => {
|
||||||
// Seeded at exactly 10/10 stamps with one AVAILABLE reward (see prisma/seed.ts) —
|
// Seeded at exactly 10/10 stamps with one AVAILABLE reward (see prisma/seed.ts) —
|
||||||
// must run before owner-journeys.spec.ts's journey 10, which redeems it.
|
// must run before owner-journeys.spec.ts's journey 10, which redeems it.
|
||||||
await signIn(page, SEEDED.secondCustomer.email, SEEDED.secondCustomer.password);
|
await signIn(page, SEEDED.secondCustomer.email);
|
||||||
|
|
||||||
await page.goto("/home");
|
await page.goto("/home");
|
||||||
await expect(page.getByText(/Reward available/)).toBeVisible();
|
await expect(page.getByText(/Reward available/)).toBeVisible();
|
||||||
@@ -215,7 +224,7 @@ test.describe("customer booking journeys", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("9. clicking Book on a Home service preselects it in the booking draft", async ({ page }) => {
|
test("9. clicking Book on a Home service preselects it in the booking draft", async ({ page }) => {
|
||||||
await signIn(page, SEEDED.registeredCustomer.email, SEEDED.registeredCustomer.password);
|
await signIn(page, SEEDED.registeredCustomer.email);
|
||||||
await page.goto("/home");
|
await page.goto("/home");
|
||||||
|
|
||||||
// The description stays visible on the Services tab row — the Book button doesn't hide it.
|
// The description stays visible on the Services tab row — the Book button doesn't hide it.
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ export default function globalSetup() {
|
|||||||
// when the caller hasn't already set their own (same fallback pattern
|
// when the caller hasn't already set their own (same fallback pattern
|
||||||
// as AUTH_SECRET in playwright.config.ts).
|
// as AUTH_SECRET in playwright.config.ts).
|
||||||
SUPER_ADMIN_EMAIL: process.env.SUPER_ADMIN_EMAIL || "superadmin@blakeheritage.co.uk",
|
SUPER_ADMIN_EMAIL: process.env.SUPER_ADMIN_EMAIL || "superadmin@blakeheritage.co.uk",
|
||||||
SUPER_ADMIN_PASSWORD: process.env.SUPER_ADMIN_PASSWORD || "password123",
|
|
||||||
SUPER_ADMIN_FIRST_NAME: process.env.SUPER_ADMIN_FIRST_NAME || "Super",
|
SUPER_ADMIN_FIRST_NAME: process.env.SUPER_ADMIN_FIRST_NAME || "Super",
|
||||||
SUPER_ADMIN_LAST_NAME: process.env.SUPER_ADMIN_LAST_NAME || "Admin",
|
SUPER_ADMIN_LAST_NAME: process.env.SUPER_ADMIN_LAST_NAME || "Admin",
|
||||||
};
|
};
|
||||||
|
|||||||
+41
-6
@@ -1,20 +1,26 @@
|
|||||||
import { expect } from "@playwright/test";
|
import { expect } from "@playwright/test";
|
||||||
import type { Page } from "@playwright/test";
|
import type { Page } from "@playwright/test";
|
||||||
|
|
||||||
/** Seeded in prisma/seed.ts — see that file for the full fixture set this suite relies on. */
|
/**
|
||||||
|
* Seeded in prisma/seed.ts — see that file for the full fixture set this
|
||||||
|
* suite relies on. No `password` fields here anymore: sign-in is OTP-only
|
||||||
|
* now (see signIn() below), so a seeded user's passwordHash (still written
|
||||||
|
* by prisma/seed.ts / ensure-super-admin.ts) is vestigial and unused by
|
||||||
|
* any auth path.
|
||||||
|
*/
|
||||||
export const SEEDED = {
|
export const SEEDED = {
|
||||||
registeredCustomer: { email: "alex.chen@example.com", password: "password123", name: "Alex Chen" },
|
registeredCustomer: { email: "alex.chen@example.com", name: "Alex Chen" },
|
||||||
// Seeded right at their loyalty reward threshold (10/10 stamps, one
|
// Seeded right at their loyalty reward threshold (10/10 stamps, one
|
||||||
// AVAILABLE reward) — see prisma/seed.ts. owner-journeys.spec.ts's
|
// AVAILABLE reward) — see prisma/seed.ts. owner-journeys.spec.ts's
|
||||||
// journey 10 redeems this exact reward, so any test relying on it still
|
// journey 10 redeems this exact reward, so any test relying on it still
|
||||||
// being AVAILABLE must run before that (customer-journeys.spec.ts runs
|
// being AVAILABLE must run before that (customer-journeys.spec.ts runs
|
||||||
// first in the suite, so this is safe there).
|
// first in the suite, so this is safe there).
|
||||||
secondCustomer: { email: "sam.osei@example.com", password: "password123", name: "Sam Osei" },
|
secondCustomer: { email: "sam.osei@example.com", name: "Sam Osei" },
|
||||||
owner: { email: "marcus@blakeheritage.co.uk", name: "Marcus Reed" },
|
owner: { email: "marcus@blakeheritage.co.uk", name: "Marcus Reed" },
|
||||||
// Provisioned by global-setup.ts via scripts/ensure-super-admin.ts, not
|
// Provisioned by global-setup.ts via scripts/ensure-super-admin.ts, not
|
||||||
// prisma/seed.ts — a Super Administrator deliberately has no StaffProfile
|
// prisma/seed.ts — a Super Administrator deliberately has no StaffProfile
|
||||||
// (see isSuperAdmin) and stays out of the dev/demo seed data on purpose.
|
// (see isSuperAdmin) and stays out of the dev/demo seed data on purpose.
|
||||||
superAdmin: { email: "superadmin@blakeheritage.co.uk", password: "password123", name: "Super Admin" },
|
superAdmin: { email: "superadmin@blakeheritage.co.uk", name: "Super Admin" },
|
||||||
barberHaircutOnly: { email: "leo@blakeheritage.co.uk", name: "Leo Vance" },
|
barberHaircutOnly: { email: "leo@blakeheritage.co.uk", name: "Leo Vance" },
|
||||||
barberSkinfadeBeard: { email: "jamie@blakeheritage.co.uk", name: "Jamie Wallace" },
|
barberSkinfadeBeard: { email: "jamie@blakeheritage.co.uk", name: "Jamie Wallace" },
|
||||||
services: {
|
services: {
|
||||||
@@ -24,7 +30,32 @@ export const SEEDED = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function signIn(page: Page, email: string, password: string): Promise<void> {
|
/**
|
||||||
|
* Reads a just-sent OTP code back out of the dev server's ConsoleEmailProvider
|
||||||
|
* capture buffer via /api/test/otp-code (see that route's own header — it
|
||||||
|
* 404s unless E2E_TEST_MODE is set, which playwright.config.ts's webServer
|
||||||
|
* does for exactly this). Polls briefly since the email is sent by a
|
||||||
|
* Server Action that may not have resolved the instant the client-side
|
||||||
|
* step transition happens.
|
||||||
|
*
|
||||||
|
* `sentAfter` (epoch ms) must be captured right before whatever action
|
||||||
|
* triggers the send — the same seeded users sign in repeatedly across this
|
||||||
|
* suite, so without it this could hand back a *previous*, already-consumed
|
||||||
|
* code still sitting in the buffer instead of the one just requested (see
|
||||||
|
* that route's own `since` param doc for the full reasoning).
|
||||||
|
*/
|
||||||
|
async function fetchOtpCode(page: Page, email: string, sentAfter: number): Promise<string> {
|
||||||
|
let code: string | null = null;
|
||||||
|
await expect(async () => {
|
||||||
|
const res = await page.request.get(`/api/test/otp-code?email=${encodeURIComponent(email)}&since=${sentAfter}`);
|
||||||
|
expect(res.ok()).toBe(true);
|
||||||
|
code = ((await res.json()) as { code: string }).code;
|
||||||
|
}).toPass({ timeout: 10_000 });
|
||||||
|
if (!code) throw new Error(`Test setup failed: no OTP code captured for ${email}.`);
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function signIn(page: Page, email: string): Promise<void> {
|
||||||
await page.goto("/sign-in");
|
await page.goto("/sign-in");
|
||||||
// Turbopack dev-mode compiles each route group on first visit — on a
|
// Turbopack dev-mode compiles each route group on first visit — on a
|
||||||
// cold server, a click that lands before that finishes (and before
|
// cold server, a click that lands before that finishes (and before
|
||||||
@@ -33,7 +64,11 @@ export async function signIn(page: Page, email: string, password: string): Promi
|
|||||||
// race the very first visit to an as-yet-uncompiled route.
|
// race the very first visit to an as-yet-uncompiled route.
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
await page.getByLabel("Email Address").fill(email);
|
await page.getByLabel("Email Address").fill(email);
|
||||||
await page.getByLabel("Password", { exact: true }).fill(password);
|
const requestedAt = Date.now();
|
||||||
|
await page.getByRole("button", { name: "Continue", exact: true }).click();
|
||||||
|
|
||||||
|
const code = await fetchOtpCode(page, email, requestedAt);
|
||||||
|
await page.getByLabel("Verification Code").fill(code);
|
||||||
await page.getByRole("button", { name: "Sign In", exact: true }).click();
|
await page.getByRole("button", { name: "Sign In", exact: true }).click();
|
||||||
// Not asserting a specific destination here — a bare sign-in (no
|
// Not asserting a specific destination here — a bare sign-in (no
|
||||||
// callbackUrl, which this helper never sets) now lands on a role-aware
|
// callbackUrl, which this helper never sets) now lands on a role-aware
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ function sidebar(page: Page) {
|
|||||||
test.describe.serial("merged role-aware navigation", () => {
|
test.describe.serial("merged role-aware navigation", () => {
|
||||||
test("1. barber sees exactly the 4 merged bottom-nav items (mobile)", async ({ page }) => {
|
test("1. barber sees exactly the 4 merged bottom-nav items (mobile)", async ({ page }) => {
|
||||||
await page.setViewportSize(MOBILE);
|
await page.setViewportSize(MOBILE);
|
||||||
await signIn(page, SEEDED.barberHaircutOnly.email, "password123");
|
await signIn(page, SEEDED.barberHaircutOnly.email);
|
||||||
await page.goto("/staff");
|
await page.goto("/staff");
|
||||||
|
|
||||||
const bar = bottomBar(page);
|
const bar = bottomBar(page);
|
||||||
@@ -52,7 +52,7 @@ test.describe.serial("merged role-aware navigation", () => {
|
|||||||
|
|
||||||
test("2. barber does not see Manage and cannot reach owner routes directly", async ({ page }) => {
|
test("2. barber does not see Manage and cannot reach owner routes directly", async ({ page }) => {
|
||||||
await page.setViewportSize(MOBILE);
|
await page.setViewportSize(MOBILE);
|
||||||
await signIn(page, SEEDED.barberHaircutOnly.email, "password123");
|
await signIn(page, SEEDED.barberHaircutOnly.email);
|
||||||
await page.goto("/staff");
|
await page.goto("/staff");
|
||||||
|
|
||||||
await expect(bottomBar(page).getByRole("link", { name: "Manage" })).toHaveCount(0);
|
await expect(bottomBar(page).getByRole("link", { name: "Manage" })).toHaveCount(0);
|
||||||
@@ -71,7 +71,7 @@ test.describe.serial("merged role-aware navigation", () => {
|
|||||||
test.beforeAll(async ({ browser }) => {
|
test.beforeAll(async ({ browser }) => {
|
||||||
page = await browser.newPage();
|
page = await browser.newPage();
|
||||||
await page.setViewportSize(MOBILE);
|
await page.setViewportSize(MOBILE);
|
||||||
await signIn(page, SEEDED.owner.email, "password123");
|
await signIn(page, SEEDED.owner.email);
|
||||||
});
|
});
|
||||||
|
|
||||||
test.afterAll(async () => {
|
test.afterAll(async () => {
|
||||||
@@ -161,7 +161,7 @@ test.describe.serial("merged role-aware navigation", () => {
|
|||||||
|
|
||||||
test("11. desktop sidebar uses the same merged structure as mobile", async ({ page }) => {
|
test("11. desktop sidebar uses the same merged structure as mobile", async ({ page }) => {
|
||||||
await page.setViewportSize(DESKTOP);
|
await page.setViewportSize(DESKTOP);
|
||||||
await signIn(page, SEEDED.owner.email, "password123");
|
await signIn(page, SEEDED.owner.email);
|
||||||
await page.goto("/owner");
|
await page.goto("/owner");
|
||||||
|
|
||||||
const side = sidebar(page);
|
const side = sidebar(page);
|
||||||
@@ -175,7 +175,7 @@ test.describe.serial("merged role-aware navigation", () => {
|
|||||||
|
|
||||||
test("13. a Super Administrator sees the business-wide merged nav with no personal schedule tab", async ({ page }) => {
|
test("13. a Super Administrator sees the business-wide merged nav with no personal schedule tab", async ({ page }) => {
|
||||||
await page.setViewportSize(MOBILE);
|
await page.setViewportSize(MOBILE);
|
||||||
await signIn(page, SEEDED.superAdmin.email, SEEDED.superAdmin.password);
|
await signIn(page, SEEDED.superAdmin.email);
|
||||||
await page.goto("/owner");
|
await page.goto("/owner");
|
||||||
|
|
||||||
const bar = bottomBar(page);
|
const bar = bottomBar(page);
|
||||||
|
|||||||
+14
-14
@@ -32,7 +32,7 @@ test.describe.serial("owner administration journeys", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("1. owner sees a real, data-backed dashboard with configuration warnings", async ({ page }) => {
|
test("1. owner sees a real, data-backed dashboard with configuration warnings", async ({ page }) => {
|
||||||
await signIn(page, SEEDED.owner.email, "password123");
|
await signIn(page, SEEDED.owner.email);
|
||||||
await page.goto("/owner");
|
await page.goto("/owner");
|
||||||
|
|
||||||
// The Manage hub's H1 (see (owner)/owner/page.tsx) — Dashboard content
|
// The Manage hub's H1 (see (owner)/owner/page.tsx) — Dashboard content
|
||||||
@@ -51,7 +51,7 @@ test.describe.serial("owner administration journeys", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("2. owner views the all-staff calendar, filters by staff, and switches to agenda", async ({ page }) => {
|
test("2. owner views the all-staff calendar, filters by staff, and switches to agenda", async ({ page }) => {
|
||||||
await signIn(page, SEEDED.owner.email, "password123");
|
await signIn(page, SEEDED.owner.email);
|
||||||
await page.goto("/owner/calendar");
|
await page.goto("/owner/calendar");
|
||||||
|
|
||||||
await expect(page.getByRole("heading", { name: "Calendar" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "Calendar" })).toBeVisible();
|
||||||
@@ -79,7 +79,7 @@ test.describe.serial("owner administration journeys", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("3. owner edits business details and the change persists", async ({ page }) => {
|
test("3. owner edits business details and the change persists", async ({ page }) => {
|
||||||
await signIn(page, SEEDED.owner.email, "password123");
|
await signIn(page, SEEDED.owner.email);
|
||||||
await page.goto("/owner/settings/business");
|
await page.goto("/owner/settings/business");
|
||||||
|
|
||||||
await expect(page.getByRole("heading", { name: "Business Details" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "Business Details" })).toBeVisible();
|
||||||
@@ -100,7 +100,7 @@ test.describe.serial("owner administration journeys", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("4. owner tightens opening hours, reviews the appointment conflict, and saves anyway without cancelling it", async ({ page }) => {
|
test("4. owner tightens opening hours, reviews the appointment conflict, and saves anyway without cancelling it", async ({ page }) => {
|
||||||
await signIn(page, SEEDED.owner.email, "password123");
|
await signIn(page, SEEDED.owner.email);
|
||||||
|
|
||||||
// Marcus and Leo each have a seeded future CONFIRMED appointment (see
|
// Marcus and Leo each have a seeded future CONFIRMED appointment (see
|
||||||
// prisma/seed.ts, appt-upcoming-confirmed / appt-leo-future). Those rows
|
// prisma/seed.ts, appt-upcoming-confirmed / appt-leo-future). Those rows
|
||||||
@@ -158,7 +158,7 @@ test.describe.serial("owner administration journeys", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("5. owner adds a new barber and gets a dev invite link", async ({ page }) => {
|
test("5. owner adds a new barber and gets a dev invite link", async ({ page }) => {
|
||||||
await signIn(page, SEEDED.owner.email, "password123");
|
await signIn(page, SEEDED.owner.email);
|
||||||
await page.goto("/owner/team/add");
|
await page.goto("/owner/team/add");
|
||||||
|
|
||||||
await expect(page.getByRole("heading", { name: "Add Staff" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "Add Staff" })).toBeVisible();
|
||||||
@@ -179,7 +179,7 @@ test.describe.serial("owner administration journeys", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("6. owner changes a barber's role and service eligibility", async ({ page }) => {
|
test("6. owner changes a barber's role and service eligibility", async ({ page }) => {
|
||||||
await signIn(page, SEEDED.owner.email, "password123");
|
await signIn(page, SEEDED.owner.email);
|
||||||
await page.goto("/owner/team?q=Jamie");
|
await page.goto("/owner/team?q=Jamie");
|
||||||
await page.getByRole("link", { name: /Jamie Wallace/ }).click();
|
await page.getByRole("link", { name: /Jamie Wallace/ }).click();
|
||||||
|
|
||||||
@@ -212,7 +212,7 @@ test.describe.serial("owner administration journeys", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("7. owner creates a service category and service, visible immediately on the customer booking flow", async ({ page }) => {
|
test("7. owner creates a service category and service, visible immediately on the customer booking flow", async ({ page }) => {
|
||||||
await signIn(page, SEEDED.owner.email, "password123");
|
await signIn(page, SEEDED.owner.email);
|
||||||
await page.goto("/owner/services");
|
await page.goto("/owner/services");
|
||||||
await expect(page.getByRole("heading", { name: "Services", exact: true })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "Services", exact: true })).toBeVisible();
|
||||||
|
|
||||||
@@ -239,7 +239,7 @@ test.describe.serial("owner administration journeys", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("8. owner requires booking approval, and a new customer booking reflects it immediately", async ({ page }) => {
|
test("8. owner requires booking approval, and a new customer booking reflects it immediately", async ({ page }) => {
|
||||||
await signIn(page, SEEDED.owner.email, "password123");
|
await signIn(page, SEEDED.owner.email);
|
||||||
await page.goto("/owner/settings/booking-rules");
|
await page.goto("/owner/settings/booking-rules");
|
||||||
await expect(page.getByRole("heading", { name: "Booking Rules" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "Booking Rules" })).toBeVisible();
|
||||||
|
|
||||||
@@ -269,7 +269,7 @@ test.describe.serial("owner administration journeys", () => {
|
|||||||
await expect(page.getByText("Pending Approval")).toBeVisible();
|
await expect(page.getByText("Pending Approval")).toBeVisible();
|
||||||
|
|
||||||
// Restore the seeded default so later journeys (and re-runs) see auto-confirmed bookings again.
|
// Restore the seeded default so later journeys (and re-runs) see auto-confirmed bookings again.
|
||||||
await signIn(page, SEEDED.owner.email, "password123");
|
await signIn(page, SEEDED.owner.email);
|
||||||
await page.goto("/owner/settings/booking-rules");
|
await page.goto("/owner/settings/booking-rules");
|
||||||
await page.getByRole("switch", { name: "Require approval for new bookings" }).click();
|
await page.getByRole("switch", { name: "Require approval for new bookings" }).click();
|
||||||
await page.getByRole("button", { name: "Save", exact: true }).click();
|
await page.getByRole("button", { name: "Save", exact: true }).click();
|
||||||
@@ -277,7 +277,7 @@ test.describe.serial("owner administration journeys", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("9. owner configures the loyalty programme", async ({ page }) => {
|
test("9. owner configures the loyalty programme", async ({ page }) => {
|
||||||
await signIn(page, SEEDED.owner.email, "password123");
|
await signIn(page, SEEDED.owner.email);
|
||||||
await page.goto("/owner/settings/loyalty");
|
await page.goto("/owner/settings/loyalty");
|
||||||
await expect(page.getByRole("heading", { name: "Loyalty Programme" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "Loyalty Programme" })).toBeVisible();
|
||||||
|
|
||||||
@@ -297,7 +297,7 @@ test.describe.serial("owner administration journeys", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("10. owner redeems a customer's available loyalty reward on an appointment", async ({ page }) => {
|
test("10. owner redeems a customer's available loyalty reward on an appointment", async ({ page }) => {
|
||||||
await signIn(page, SEEDED.owner.email, "password123");
|
await signIn(page, SEEDED.owner.email);
|
||||||
await page.goto("/owner/calendar?view=agenda");
|
await page.goto("/owner/calendar?view=agenda");
|
||||||
|
|
||||||
// Seeded: Sam Osei has a CHECKED_IN appointment with Leo today, and sits
|
// Seeded: Sam Osei has a CHECKED_IN appointment with Leo today, and sits
|
||||||
@@ -322,7 +322,7 @@ test.describe.serial("owner administration journeys", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("11. a barber without MANAGE_STAFF is denied access to owner administration", async ({ page }) => {
|
test("11. a barber without MANAGE_STAFF is denied access to owner administration", async ({ page }) => {
|
||||||
await signIn(page, SEEDED.barberHaircutOnly.email, "password123");
|
await signIn(page, SEEDED.barberHaircutOnly.email);
|
||||||
|
|
||||||
await page.goto("/owner");
|
await page.goto("/owner");
|
||||||
await expect(page).toHaveURL(/\/staff$/);
|
await expect(page).toHaveURL(/\/staff$/);
|
||||||
@@ -331,7 +331,7 @@ test.describe.serial("owner administration journeys", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("12. the owner reaches barber tools straight from the merged nav — no mode switch", async ({ page }) => {
|
test("12. the owner reaches barber tools straight from the merged nav — no mode switch", async ({ page }) => {
|
||||||
await signIn(page, SEEDED.owner.email, "password123");
|
await signIn(page, SEEDED.owner.email);
|
||||||
await page.goto("/owner");
|
await page.goto("/owner");
|
||||||
|
|
||||||
// The old "Switch to Owner Admin"/"Switch to Barber Tools" links are
|
// The old "Switch to Owner Admin"/"Switch to Barber Tools" links are
|
||||||
@@ -345,7 +345,7 @@ test.describe.serial("owner administration journeys", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("13. owner reports show real, data-backed appointment/service/staff/customer/loyalty/notification figures", async ({ page }) => {
|
test("13. owner reports show real, data-backed appointment/service/staff/customer/loyalty/notification figures", async ({ page }) => {
|
||||||
await signIn(page, SEEDED.owner.email, "password123");
|
await signIn(page, SEEDED.owner.email);
|
||||||
await page.goto("/owner");
|
await page.goto("/owner");
|
||||||
// "View Reports" was the old Dashboard footer's button label; Reports is
|
// "View Reports" was the old Dashboard footer's button label; Reports is
|
||||||
// now one of the Manage hub's grouped sections (see (owner)/owner/page.tsx).
|
// now one of the Manage hub's grouped sections (see (owner)/owner/page.tsx).
|
||||||
|
|||||||
+13
-10
@@ -1,6 +1,7 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
import { createPool } from "mariadb";
|
import { createPool } from "mariadb";
|
||||||
import { buildMysqlPoolConfig } from "../src/lib/db/mysql-pool-config";
|
import { buildMysqlPoolConfig } from "../src/lib/db/mysql-pool-config";
|
||||||
|
import { IN_APP_NOTIFICATIONS_VISIBLE } from "../src/services/notifications/in-app";
|
||||||
import { SEEDED, completeStaffBookingFlow, signIn } from "./helpers";
|
import { SEEDED, completeStaffBookingFlow, signIn } from "./helpers";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -38,7 +39,7 @@ test.describe.serial("barber operations journeys", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("1. barber signs in and sees today's schedule", async ({ page }) => {
|
test("1. barber signs in and sees today's schedule", async ({ page }) => {
|
||||||
await signIn(page, SEEDED.owner.email, "password123");
|
await signIn(page, SEEDED.owner.email);
|
||||||
await page.goto("/staff");
|
await page.goto("/staff");
|
||||||
|
|
||||||
await expect(page.getByRole("heading", { name: "Calendar" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "Calendar" })).toBeVisible();
|
||||||
@@ -50,7 +51,7 @@ test.describe.serial("barber operations journeys", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("2. barber opens the current appointment", async ({ page }) => {
|
test("2. barber opens the current appointment", async ({ page }) => {
|
||||||
await signIn(page, SEEDED.owner.email, "password123");
|
await signIn(page, SEEDED.owner.email);
|
||||||
await page.goto("/staff/current");
|
await page.goto("/staff/current");
|
||||||
|
|
||||||
// Tier 1 of selectCurrentAppointment: the IN_PROGRESS row wins.
|
// Tier 1 of selectCurrentAppointment: the IN_PROGRESS row wins.
|
||||||
@@ -60,7 +61,7 @@ test.describe.serial("barber operations journeys", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("3. barber books, checks in, and starts an appointment", async ({ page }) => {
|
test("3. barber books, checks in, and starts an appointment", async ({ page }) => {
|
||||||
await signIn(page, SEEDED.owner.email, "password123");
|
await signIn(page, SEEDED.owner.email);
|
||||||
|
|
||||||
await page.goto("/staff/book?q=Jordan");
|
await page.goto("/staff/book?q=Jordan");
|
||||||
await page.getByRole("button", { name: /Jordan Blake/ }).click();
|
await page.getByRole("button", { name: /Jordan Blake/ }).click();
|
||||||
@@ -82,7 +83,7 @@ test.describe.serial("barber operations journeys", () => {
|
|||||||
|
|
||||||
test("4. barber completes the appointment and adds a loyalty stamp", async ({ page }) => {
|
test("4. barber completes the appointment and adds a loyalty stamp", async ({ page }) => {
|
||||||
if (!chainedAppointmentUrl) throw new Error("journey 3 must run first to create the appointment");
|
if (!chainedAppointmentUrl) throw new Error("journey 3 must run first to create the appointment");
|
||||||
await signIn(page, SEEDED.owner.email, "password123");
|
await signIn(page, SEEDED.owner.email);
|
||||||
await page.goto(chainedAppointmentUrl);
|
await page.goto(chainedAppointmentUrl);
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Complete Appointment", exact: true }).click();
|
await page.getByRole("button", { name: "Complete Appointment", exact: true }).click();
|
||||||
@@ -104,7 +105,7 @@ test.describe.serial("barber operations journeys", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("5. barber finds a customer and adds a note", async ({ page }) => {
|
test("5. barber finds a customer and adds a note", async ({ page }) => {
|
||||||
await signIn(page, SEEDED.owner.email, "password123");
|
await signIn(page, SEEDED.owner.email);
|
||||||
|
|
||||||
await page.goto("/staff/clients");
|
await page.goto("/staff/clients");
|
||||||
await page.getByPlaceholder("Search by name, email, or phone").fill("Osei");
|
await page.getByPlaceholder("Search by name, email, or phone").fill("Osei");
|
||||||
@@ -120,7 +121,7 @@ test.describe.serial("barber operations journeys", () => {
|
|||||||
|
|
||||||
test("6. barber books a follow-up from a completed appointment", async ({ page }) => {
|
test("6. barber books a follow-up from a completed appointment", async ({ page }) => {
|
||||||
if (!chainedAppointmentUrl) throw new Error("journey 4 must run first to complete the appointment");
|
if (!chainedAppointmentUrl) throw new Error("journey 4 must run first to complete the appointment");
|
||||||
await signIn(page, SEEDED.owner.email, "password123");
|
await signIn(page, SEEDED.owner.email);
|
||||||
await page.goto(chainedAppointmentUrl);
|
await page.goto(chainedAppointmentUrl);
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Book Follow-Up" }).click();
|
await page.getByRole("button", { name: "Book Follow-Up" }).click();
|
||||||
@@ -143,7 +144,7 @@ test.describe.serial("barber operations journeys", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("7. barber changes their working hours", async ({ page }) => {
|
test("7. barber changes their working hours", async ({ page }) => {
|
||||||
await signIn(page, SEEDED.barberHaircutOnly.email, "password123");
|
await signIn(page, SEEDED.barberHaircutOnly.email);
|
||||||
await page.goto("/staff/settings/working-hours");
|
await page.goto("/staff/settings/working-hours");
|
||||||
|
|
||||||
const sundaySwitch = page.getByRole("switch", { name: "Sunday working" });
|
const sundaySwitch = page.getByRole("switch", { name: "Sunday working" });
|
||||||
@@ -176,7 +177,7 @@ test.describe.serial("barber operations journeys", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("8. barber adds a recurring break", async ({ page }) => {
|
test("8. barber adds a recurring break", async ({ page }) => {
|
||||||
await signIn(page, SEEDED.barberSkinfadeBeard.email, "password123");
|
await signIn(page, SEEDED.barberSkinfadeBeard.email);
|
||||||
await page.goto("/staff/settings/breaks");
|
await page.goto("/staff/settings/breaks");
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Add Break", exact: true }).click();
|
await page.getByRole("button", { name: "Add Break", exact: true }).click();
|
||||||
@@ -198,7 +199,7 @@ test.describe.serial("barber operations journeys", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("9. barber adds time off and resolves a scheduling conflict", async ({ page }) => {
|
test("9. barber adds time off and resolves a scheduling conflict", async ({ page }) => {
|
||||||
await signIn(page, SEEDED.owner.email, "password123");
|
await signIn(page, SEEDED.owner.email);
|
||||||
await page.goto("/staff/settings/time-off");
|
await page.goto("/staff/settings/time-off");
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -240,13 +241,15 @@ test.describe.serial("barber operations journeys", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("10. barber receives a new-booking notification", async ({ page }) => {
|
test("10. barber receives a new-booking notification", async ({ page }) => {
|
||||||
|
test.skip(!IN_APP_NOTIFICATIONS_VISIBLE, "In-app notifications are hidden — see src/features/notifications/in-app.ts");
|
||||||
|
|
||||||
// Deliberately books its own fresh appointment rather than relying on journey 3's — every
|
// Deliberately books its own fresh appointment rather than relying on journey 3's — every
|
||||||
// other independent journey in this file follows that same "book a fresh appointment first"
|
// other independent journey in this file follows that same "book a fresh appointment first"
|
||||||
// rule (see this file's header comment), and this one was the exception, which made it fail
|
// rule (see this file's header comment), and this one was the exception, which made it fail
|
||||||
// in confusing ways whenever it ran outside the full serial file (e.g. `-g` filtering past
|
// in confusing ways whenever it ran outside the full serial file (e.g. `-g` filtering past
|
||||||
// journey 3). See NotificationService/booking-notifications.ts, notifyBookingCreated, which
|
// journey 3). See NotificationService/booking-notifications.ts, notifyBookingCreated, which
|
||||||
// queues STAFF_NEW_APPOINTMENT for the assigned barber on every booking.
|
// queues STAFF_NEW_APPOINTMENT for the assigned barber on every booking.
|
||||||
await signIn(page, SEEDED.owner.email, "password123");
|
await signIn(page, SEEDED.owner.email);
|
||||||
|
|
||||||
await page.goto("/staff/book?q=Jordan");
|
await page.goto("/staff/book?q=Jordan");
|
||||||
await page.getByRole("button", { name: /Jordan Blake/ }).click();
|
await page.getByRole("button", { name: /Jordan Blake/ }).click();
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@ import nextTypescript from "eslint-config-next/typescript";
|
|||||||
|
|
||||||
/** @type {import('eslint').Linter.Config[]} */
|
/** @type {import('eslint').Linter.Config[]} */
|
||||||
const eslintConfig = [
|
const eslintConfig = [
|
||||||
{ ignores: ["templates/**", "docs/**", "generated/**", ".next/**", "node_modules/**"] },
|
{ ignores: ["templates/**", "docs/**", "generated/**", ".next/**", "node_modules/**", "manager/**"] },
|
||||||
...nextCoreWebVitals,
|
...nextCoreWebVitals,
|
||||||
...nextTypescript,
|
...nextTypescript,
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
.git
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
.DS_Store
|
||||||
|
npm-debug.log*
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# Copy to stacks/manager/.env (kept out of git — see the repo root's
|
||||||
|
# .gitignore) and fill in real values.
|
||||||
|
|
||||||
|
# ── Platform-operator login ─────────────────────────────────────────────
|
||||||
|
MANAGER_EMAIL="you@example.com"
|
||||||
|
# bcrypt hash (cost 12) of your chosen password — generate one with:
|
||||||
|
# node -e "console.log(require('bcryptjs').hashSync('your-password', 12))"
|
||||||
|
# (run from the manager/ directory so it resolves the bcryptjs dependency)
|
||||||
|
MANAGER_PASSWORD_HASH="replace-me"
|
||||||
|
# Signs the session cookie — generate with: openssl rand -hex 32
|
||||||
|
MANAGER_SESSION_SECRET="replace-me"
|
||||||
|
# Encrypts secrets (e.g. the SMTP password) stored in Settings > Email —
|
||||||
|
# see src/lib/global-settings.ts. Must be a 32-byte value, hex-encoded.
|
||||||
|
# Generate with: openssl rand -hex 32
|
||||||
|
# Losing/rotating this makes any previously-saved secret undecryptable —
|
||||||
|
# you'd need to re-enter it in Settings afterward.
|
||||||
|
MANAGER_SETTINGS_ENCRYPTION_KEY="replace-me"
|
||||||
|
# The URL this app is actually served at (used to decide whether the
|
||||||
|
# session cookie needs its Secure attribute) — e.g. "https://manager.example.com".
|
||||||
|
MANAGER_PUBLIC_URL="https://manager.example.com"
|
||||||
|
|
||||||
|
# ── Shared MySQL (see stacks/infra/compose.yaml) ────────────────────────
|
||||||
|
MYSQL_HOST="next-chair-mysql"
|
||||||
|
MYSQL_PORT="3306"
|
||||||
|
# Same value as stacks/infra/.env's MYSQL_ROOT_PASSWORD — needed here too
|
||||||
|
# to create each new business's database/user. Two copies of the same
|
||||||
|
# value, deliberately not derived from one another — see stacks/infra/
|
||||||
|
# compose.yaml's own note on why MYSQL_*/DATABASE_* aren't cross-wired.
|
||||||
|
MYSQL_ROOT_PASSWORD="replace-me"
|
||||||
|
|
||||||
|
# ── Docker / stacks ──────────────────────────────────────────────────────
|
||||||
|
# This repo's absolute path on the VPS, e.g. "/opt/nextchair/stacks" — NOT
|
||||||
|
# a relative path. This container shells `docker compose` out to the
|
||||||
|
# HOST's dockerd via the mounted Docker socket (see src/lib/docker.ts), so
|
||||||
|
# a compose-file path only resolves to the same file on both sides if the
|
||||||
|
# bind-mount doesn't change it — meaning this must be mounted at the
|
||||||
|
# identical path inside the container too (see stacks/manager/compose.yaml).
|
||||||
|
STACKS_DIR="/opt/nextchair/stacks"
|
||||||
|
# Every business's compose.yaml (see src/lib/compose-template.ts) uses
|
||||||
|
# this image tag — rebuild with `docker build -t nextchair-app:latest .`
|
||||||
|
# from the repo root after a code update, then use each business's
|
||||||
|
# "Update" button (or `docker compose up -d --force-recreate`) to pick it
|
||||||
|
# up.
|
||||||
|
NEXTCHAIR_IMAGE="nextchair-app:latest"
|
||||||
|
|
||||||
|
# ── Scheduler, backups, alerts (all optional) ───────────────────────────
|
||||||
|
# The built-in scheduler (notification cron, daily Google reviews sync,
|
||||||
|
# health checks, scheduled backups) starts with the Manager. Set to 1 to
|
||||||
|
# turn it off, e.g. to keep using the host-crontab fallback
|
||||||
|
# (stacks/infra/scripts/process-cron.sh) instead — never run both.
|
||||||
|
# MANAGER_SCHEDULER_DISABLED="1"
|
||||||
|
# Scheduled-backup times and the dashboard's dates use the container's
|
||||||
|
# timezone (UTC by default). Set e.g. TZ="Europe/London" to change it.
|
||||||
|
# TZ="Europe/London"
|
||||||
|
# Largest .7z accepted by the Restore page's upload (bytes; default 4 GiB).
|
||||||
|
# BACKUP_UPLOAD_MAX_BYTES="4294967296"
|
||||||
|
# An external dead-man's-switch (healthchecks.io, Better Stack…): the
|
||||||
|
# scheduler GETs this URL every 5 minutes, so you're told if the whole
|
||||||
|
# VPS or the Manager is down — something a self-hosted check can't report.
|
||||||
|
# MANAGER_HEARTBEAT_URL="https://hc-ping.com/your-uuid"
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<!-- BEGIN:nextjs-agent-rules -->
|
||||||
|
|
||||||
|
# This is NOT the Next.js you know
|
||||||
|
|
||||||
|
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
|
||||||
|
|
||||||
|
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
|
||||||
|
|
||||||
|
<!-- END:nextjs-agent-rules -->
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
@AGENTS.md
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# Standalone image for the management UI — separate from the main app's
|
||||||
|
# Dockerfile (see ../Dockerfile) since this needs the Docker CLI itself
|
||||||
|
# baked in (it shells out to `docker compose`/`docker exec` against the
|
||||||
|
# host's dockerd via the mounted socket — see src/lib/docker.ts), which
|
||||||
|
# the main business app never needs.
|
||||||
|
#
|
||||||
|
# Only one instance of this ever runs (unlike the business image, which
|
||||||
|
# is built once and reused across every business — see
|
||||||
|
# src/lib/compose-template.ts's own note) so a per-deploy `build:` in
|
||||||
|
# stacks/manager/compose.yaml, rather than a separately tagged image, is
|
||||||
|
# the simpler choice here.
|
||||||
|
|
||||||
|
FROM node:22-alpine AS deps
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
FROM node:22-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM node:22-alpine AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
# The Docker CLI + compose plugin — this container never runs its own
|
||||||
|
# Docker daemon, it only talks to the HOST's via the socket mounted in
|
||||||
|
# stacks/infra/compose.yaml's manager service (docker.sock).
|
||||||
|
RUN apk add --no-cache docker-cli docker-cli-compose
|
||||||
|
|
||||||
|
# 7-Zip for backups (`7zz a`, plain 7z — see src/lib/sevenzip.ts).
|
||||||
|
# Alpine's package is named `7zip` and installs /usr/bin/7zz (checked
|
||||||
|
# against node:22-alpine, Alpine 3.24: `apk add 7zip` → 7-Zip 26.01).
|
||||||
|
RUN apk add --no-cache 7zip
|
||||||
|
|
||||||
|
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 manager
|
||||||
|
|
||||||
|
COPY --from=builder --chown=manager:nodejs /app/node_modules ./node_modules
|
||||||
|
COPY --from=builder --chown=manager:nodejs /app/.next ./.next
|
||||||
|
COPY --from=builder --chown=manager:nodejs /app/public ./public
|
||||||
|
COPY --from=builder --chown=manager:nodejs /app/package.json ./package.json
|
||||||
|
COPY --from=builder --chown=manager:nodejs /app/next.config.ts ./next.config.ts
|
||||||
|
|
||||||
|
# Deliberately still root at runtime, not the `manager` user created
|
||||||
|
# above — this container's whole job is calling the Docker CLI against
|
||||||
|
# the host's socket, and on most VPS setups only root (or a user in the
|
||||||
|
# `docker` group, which would need to be created with a matching host
|
||||||
|
# GID) can use that socket. Worth being explicit about: root inside this
|
||||||
|
# container plus the mounted socket effectively means root on the host —
|
||||||
|
# this app must stay behind its own login (see src/lib/auth.ts) and never
|
||||||
|
# be exposed without that gate.
|
||||||
|
EXPOSE 3000
|
||||||
|
ENV PORT=3000
|
||||||
|
|
||||||
|
ENTRYPOINT ["node_modules/.bin/next", "start", "-p", "3000"]
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import nextCoreWebVitals from "eslint-config-next/core-web-vitals";
|
||||||
|
import nextTypescript from "eslint-config-next/typescript";
|
||||||
|
|
||||||
|
/** @type {import('eslint').Linter.Config[]} */
|
||||||
|
const eslintConfig = [
|
||||||
|
{ ignores: [".next/**", "node_modules/**"] },
|
||||||
|
...nextCoreWebVitals,
|
||||||
|
...nextTypescript,
|
||||||
|
{
|
||||||
|
rules: {
|
||||||
|
"@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default eslintConfig;
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
// Server Actions here run `docker`/`docker compose`/`mariadb` calls, not
|
||||||
|
// page rendering — nothing about this app benefits from static export or
|
||||||
|
// the standalone output trimming the main app also opts out of (see
|
||||||
|
// ../Dockerfile's own header for the same reasoning).
|
||||||
|
|
||||||
|
// This directory has its own package-lock.json alongside the main
|
||||||
|
// app's at the repo root — without this, Next infers the wrong
|
||||||
|
// workspace root from the nearest lockfile and warns about it on every
|
||||||
|
// build.
|
||||||
|
turbopack: {
|
||||||
|
root: path.join(__dirname),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
Generated
+8218
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"name": "nextchair-manager",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "Standalone platform-operator tool for provisioning and managing NextChair business deployments. Deliberately separate from the main app (own package.json, own build, own container) — an earlier in-app attempt at this repeatedly broke from Prisma/Auth.js/business-data imports leaking into a mode with no business database.",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test": "vitest run"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
|
"mariadb": "^3.5.4",
|
||||||
|
"next": "^16.4.2",
|
||||||
|
"nodemailer": "^8.0.11",
|
||||||
|
"react": "^19.3.1",
|
||||||
|
"react-dom": "^19.3.1",
|
||||||
|
"web-push": "^3.6.7"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4.3.3",
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
|
"@types/node": "^26.6.5",
|
||||||
|
"@types/nodemailer": "^8.0.2",
|
||||||
|
"@types/react": "^19.3.0",
|
||||||
|
"@types/react-dom": "^19.3.0",
|
||||||
|
"@types/web-push": "^3.6.4",
|
||||||
|
"eslint": "^9.39.5",
|
||||||
|
"eslint-config-next": "^16.4.2",
|
||||||
|
"tailwindcss": "^4.3.3",
|
||||||
|
"typescript": "^6.0.3",
|
||||||
|
"vitest": "^4.1.12"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
Whitespace-only changes.
@@ -0,0 +1,97 @@
|
|||||||
|
import { NextRequest } from "next/server";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { createSessionToken, SESSION_COOKIE_NAME } from "@/lib/session";
|
||||||
|
import proxy, { config } from "../proxy";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The installation-token panel (and its regenerate action) lives under
|
||||||
|
* /dashboard/[slug] like every other Manager page — it has no auth check
|
||||||
|
* of its own, relying entirely on this proxy gating every non-/login
|
||||||
|
* route (see proxy.ts's own header, and manager/README.md's "How auth
|
||||||
|
* works"). This test exists specifically because this feature is the
|
||||||
|
* first thing in this app sensitive enough that "only authenticated
|
||||||
|
* Manager users can view it" is a named requirement, not just incidental.
|
||||||
|
*/
|
||||||
|
describe("proxy — Manager auth gate", () => {
|
||||||
|
const originalSecret = process.env.MANAGER_SESSION_SECRET;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.MANAGER_SESSION_SECRET = "test-session-secret-value";
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (originalSecret === undefined) delete process.env.MANAGER_SESSION_SECRET;
|
||||||
|
else process.env.MANAGER_SESSION_SECRET = originalSecret;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redirects to /login when a business dashboard page is requested without a session cookie", () => {
|
||||||
|
const request = new NextRequest(new URL("http://localhost/dashboard/acme-barbers"));
|
||||||
|
|
||||||
|
const response = proxy(request);
|
||||||
|
|
||||||
|
expect(response.status).toBe(307);
|
||||||
|
expect(response.headers.get("location")).toBe("http://localhost/login");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redirects to /login for an invalid/tampered session cookie", () => {
|
||||||
|
const request = new NextRequest(new URL("http://localhost/dashboard/acme-barbers"), {
|
||||||
|
headers: { cookie: `${SESSION_COOKIE_NAME}=not-a-real-token` },
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = proxy(request);
|
||||||
|
|
||||||
|
expect(response.status).toBe(307);
|
||||||
|
expect(response.headers.get("location")).toBe("http://localhost/login");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes through to the business dashboard page (where the installation token lives) with a valid session cookie", () => {
|
||||||
|
const token = createSessionToken("operator@example.com");
|
||||||
|
const request = new NextRequest(new URL("http://localhost/dashboard/acme-barbers"), {
|
||||||
|
headers: { cookie: `${SESSION_COOKIE_NAME}=${token}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = proxy(request);
|
||||||
|
|
||||||
|
// NextResponse.next() carries no redirect / error status.
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never gates /login itself (or a signed-out operator could never reach the sign-in form)", () => {
|
||||||
|
const request = new NextRequest(new URL("http://localhost/login"));
|
||||||
|
|
||||||
|
const response = proxy(request);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("proxy — API routes", () => {
|
||||||
|
const originalSecret = process.env.MANAGER_SESSION_SECRET;
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.MANAGER_SESSION_SECRET = "test-session-secret-value";
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
if (originalSecret === undefined) delete process.env.MANAGER_SESSION_SECRET;
|
||||||
|
else process.env.MANAGER_SESSION_SECRET = originalSecret;
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["/api/jobs/abc123-deadbeef", "/api/backups/acme/acme__20261017T030000Z__regular.7z"])("gates %s behind the login", (pathname) => {
|
||||||
|
const response = proxy(new NextRequest(new URL(`http://localhost${pathname}`)));
|
||||||
|
expect(response.status).toBe(307);
|
||||||
|
expect(response.headers.get("location")).toBe("http://localhost/login");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets a signed-in request through to a job poll", () => {
|
||||||
|
const token = createSessionToken("op@example.com");
|
||||||
|
const response = proxy(new NextRequest(new URL("http://localhost/api/jobs/abc123-deadbeef"), { headers: { cookie: `${SESSION_COOKIE_NAME}=${token}` } }));
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("excludes ONLY the upload route from the proxy (it would truncate large bodies; the route checks the session itself)", () => {
|
||||||
|
const matcher = new RegExp(`^${config.matcher[0]!}$`);
|
||||||
|
expect(matcher.test("/api/backups/upload")).toBe(false);
|
||||||
|
expect(matcher.test("/api/backups/acme/file.7z")).toBe(true);
|
||||||
|
expect(matcher.test("/api/jobs/x")).toBe(true);
|
||||||
|
expect(matcher.test("/dashboard")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { NextRequest } from "next/server";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { createSessionToken, SESSION_COOKIE_NAME } from "@/lib/session";
|
||||||
|
import { GET as downloadBackup } from "../backups/[slug]/[file]/route";
|
||||||
|
import { POST as uploadBackup } from "../backups/upload/route";
|
||||||
|
import { GET as getJob } from "../jobs/[id]/route";
|
||||||
|
|
||||||
|
describe("manager API routes", () => {
|
||||||
|
let stacksDir: string;
|
||||||
|
const saved = { stacks: process.env.STACKS_DIR, secret: process.env.MANAGER_SESSION_SECRET, max: process.env.BACKUP_UPLOAD_MAX_BYTES };
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
stacksDir = await mkdtemp(path.join(tmpdir(), "nc-manager-routes-"));
|
||||||
|
process.env.STACKS_DIR = stacksDir;
|
||||||
|
process.env.MANAGER_SESSION_SECRET = "test-session-secret-value";
|
||||||
|
delete process.env.BACKUP_UPLOAD_MAX_BYTES;
|
||||||
|
});
|
||||||
|
afterEach(async () => {
|
||||||
|
for (const [key, value] of [["STACKS_DIR", saved.stacks], ["MANAGER_SESSION_SECRET", saved.secret], ["BACKUP_UPLOAD_MAX_BYTES", saved.max]] as const) {
|
||||||
|
if (value === undefined) delete process.env[key];
|
||||||
|
else process.env[key] = value;
|
||||||
|
}
|
||||||
|
await rm(stacksDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
const cookie = () => `${SESSION_COOKIE_NAME}=${createSessionToken("op@example.com")}`;
|
||||||
|
function uploadRequest(body: Uint8Array | null, headers: Record<string, string> = {}) {
|
||||||
|
return new NextRequest("http://localhost/api/backups/upload", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/octet-stream", ...headers },
|
||||||
|
body: body ? new ReadableStream({ start(c) { c.enqueue(body); c.close(); } }) : null,
|
||||||
|
// undici requires this for streaming request bodies
|
||||||
|
duplex: "half",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("POST /api/backups/upload", () => {
|
||||||
|
it("rejects an unauthenticated upload without reading it (the route is outside the proxy, so it must check itself)", async () => {
|
||||||
|
const response = await uploadBackup(uploadRequest(new Uint8Array([1, 2, 3])));
|
||||||
|
expect(response.status).toBe(401);
|
||||||
|
await expect(readdir(path.join(stacksDir, "backups", "_uploads"))).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("streams the body to backups/_uploads/<id>.7z and returns the id and size", async () => {
|
||||||
|
const payload = new Uint8Array(5000).fill(7);
|
||||||
|
const response = await uploadBackup(uploadRequest(payload, { cookie: cookie() }));
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
const body = (await response.json()) as { uploadId: string; sizeBytes: number };
|
||||||
|
expect(body.uploadId).toMatch(/^[a-f0-9]{16}$/);
|
||||||
|
expect(body.sizeBytes).toBe(5000);
|
||||||
|
expect((await readFile(path.join(stacksDir, "backups", "_uploads", `${body.uploadId}.7z`))).length).toBe(5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enforces the size cap from Content-Length before reading anything", async () => {
|
||||||
|
process.env.BACKUP_UPLOAD_MAX_BYTES = "1000";
|
||||||
|
const response = await uploadBackup(uploadRequest(new Uint8Array(10), { cookie: cookie(), "content-length": "5000" }));
|
||||||
|
expect(response.status).toBe(413);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enforces the cap while streaming too (a client can understate Content-Length) and deletes the partial file", async () => {
|
||||||
|
process.env.BACKUP_UPLOAD_MAX_BYTES = "1000";
|
||||||
|
const response = await uploadBackup(uploadRequest(new Uint8Array(5000), { cookie: cookie() }));
|
||||||
|
expect(response.status).toBe(413);
|
||||||
|
expect(await readdir(path.join(stacksDir, "backups", "_uploads"))).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an empty upload", async () => {
|
||||||
|
const response = await uploadBackup(uploadRequest(new Uint8Array(0), { cookie: cookie() }));
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GET /api/backups/[slug]/[file]", () => {
|
||||||
|
it("streams a stored archive as an attachment", async () => {
|
||||||
|
const name = "acme__20261017T030000Z__regular.7z";
|
||||||
|
await mkdir(path.join(stacksDir, "backups", "acme"), { recursive: true });
|
||||||
|
await writeFile(path.join(stacksDir, "backups", "acme", name), "ARCHIVE-BYTES");
|
||||||
|
|
||||||
|
const response = await downloadBackup(new Request("http://localhost/x"), { params: Promise.resolve({ slug: "acme", file: name }) });
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.headers.get("content-disposition")).toBe(`attachment; filename="${name}"`);
|
||||||
|
expect(response.headers.get("content-length")).toBe("13");
|
||||||
|
expect(await response.text()).toBe("ARCHIVE-BYTES");
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["acme", "../../../etc/passwd"],
|
||||||
|
["acme", "other__20261017T030000Z__regular.7z"],
|
||||||
|
["../acme", "acme__20261017T030000Z__regular.7z"],
|
||||||
|
["acme", "acme__20261017T030000Z__regular.7z"], // well-formed but absent
|
||||||
|
])("404s for %s / %s", async (slug, file) => {
|
||||||
|
const response = await downloadBackup(new Request("http://localhost/x"), { params: Promise.resolve({ slug, file }) });
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GET /api/jobs/[id]", () => {
|
||||||
|
it("returns the job record, and 404 for unknown or malformed ids", async () => {
|
||||||
|
const id = "abc123-deadbeef";
|
||||||
|
await mkdir(path.join(stacksDir, "manager", "jobs"), { recursive: true });
|
||||||
|
await writeFile(path.join(stacksDir, "manager", "jobs", `${id}.json`), JSON.stringify({ id, status: "running", steps: [], logTail: [] }));
|
||||||
|
|
||||||
|
const ok = await getJob(new Request("http://localhost/x"), { params: Promise.resolve({ id }) });
|
||||||
|
expect(ok.status).toBe(200);
|
||||||
|
expect(((await ok.json()) as { status: string }).status).toBe("running");
|
||||||
|
|
||||||
|
expect((await getJob(new Request("http://localhost/x"), { params: Promise.resolve({ id: "nope" }) })).status).toBe(404);
|
||||||
|
expect((await getJob(new Request("http://localhost/x"), { params: Promise.resolve({ id: "../../global-settings" }) })).status).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { createReadStream } from "node:fs";
|
||||||
|
import { stat } from "node:fs/promises";
|
||||||
|
import { Readable } from "node:stream";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { backupFilePath } from "@/lib/backup-store";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
/** Streams a stored archive as a download — never buffered in memory, since archives can be large. The .7z is NOT password-protected and holds the business's secrets, so this route sits behind the Manager login. */
|
||||||
|
export async function GET(_request: Request, { params }: { params: Promise<{ slug: string; file: string }> }) {
|
||||||
|
const { slug, file } = await params;
|
||||||
|
|
||||||
|
let filePath: string;
|
||||||
|
try {
|
||||||
|
filePath = backupFilePath(slug, file);
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Not found." }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let size: number;
|
||||||
|
try {
|
||||||
|
size = (await stat(filePath)).size;
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Not found." }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = Readable.toWeb(createReadStream(filePath)) as ReadableStream<Uint8Array>;
|
||||||
|
return new Response(body, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/x-7z-compressed",
|
||||||
|
"Content-Length": String(size),
|
||||||
|
"Content-Disposition": `attachment; filename="${file}"`,
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { createWriteStream } from "node:fs";
|
||||||
|
import { mkdir, rm } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { Readable, Transform } from "node:stream";
|
||||||
|
import { pipeline } from "node:stream/promises";
|
||||||
|
import { NextResponse, type NextRequest } from "next/server";
|
||||||
|
import { uploadsDir } from "@/lib/manager-paths";
|
||||||
|
import { randomHex } from "@/lib/random";
|
||||||
|
import { SESSION_COOKIE_NAME, verifySessionToken } from "@/lib/session";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
/** Hard cap on an uploaded archive. Override with BACKUP_UPLOAD_MAX_BYTES. */
|
||||||
|
const DEFAULT_MAX_BYTES = 4 * 1024 * 1024 * 1024;
|
||||||
|
|
||||||
|
function maxBytes(): number {
|
||||||
|
const configured = Number(process.env.BACKUP_UPLOAD_MAX_BYTES);
|
||||||
|
return Number.isFinite(configured) && configured > 0 ? configured : DEFAULT_MAX_BYTES;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Streamed upload of a .7z backup (raw request body, not multipart, so it
|
||||||
|
* is never buffered). This route is EXCLUDED from proxy.ts's matcher on
|
||||||
|
* purpose: with a proxy in front, Next.js buffers the request body in
|
||||||
|
* memory and silently truncates it at proxyClientMaxBodySize (10 MB by
|
||||||
|
* default), which would corrupt a large archive. So it checks the
|
||||||
|
* session cookie itself, first thing, before reading a single body byte.
|
||||||
|
*
|
||||||
|
* The size cap is enforced twice: up front from Content-Length, and while
|
||||||
|
* streaming (a client can lie about, or omit, Content-Length).
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const session = verifySessionToken(request.cookies.get(SESSION_COOKIE_NAME)?.value);
|
||||||
|
if (!session) return NextResponse.json({ error: "Not signed in." }, { status: 401 });
|
||||||
|
|
||||||
|
const limit = maxBytes();
|
||||||
|
const declared = Number(request.headers.get("content-length"));
|
||||||
|
if (Number.isFinite(declared) && declared > limit) {
|
||||||
|
return NextResponse.json({ error: `That file is larger than the ${Math.round(limit / 1024 / 1024)} MB upload limit.` }, { status: 413 });
|
||||||
|
}
|
||||||
|
if (!request.body) return NextResponse.json({ error: "No file received." }, { status: 400 });
|
||||||
|
|
||||||
|
const uploadId = randomHex(8);
|
||||||
|
const destination = path.join(uploadsDir(), `${uploadId}.7z`);
|
||||||
|
await mkdir(uploadsDir(), { recursive: true, mode: 0o700 });
|
||||||
|
|
||||||
|
let received = 0;
|
||||||
|
const counter = new Transform({
|
||||||
|
transform(chunk: Buffer, _encoding, callback) {
|
||||||
|
received += chunk.length;
|
||||||
|
if (received > limit) callback(new Error("LIMIT_EXCEEDED"));
|
||||||
|
else callback(null, chunk);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await pipeline(Readable.fromWeb(request.body as import("node:stream/web").ReadableStream), counter, createWriteStream(destination, { mode: 0o600 }));
|
||||||
|
} catch (error) {
|
||||||
|
await rm(destination, { force: true });
|
||||||
|
if (error instanceof Error && error.message === "LIMIT_EXCEEDED") {
|
||||||
|
return NextResponse.json({ error: `That file is larger than the ${Math.round(limit / 1024 / 1024)} MB upload limit.` }, { status: 413 });
|
||||||
|
}
|
||||||
|
return NextResponse.json({ error: "The upload was interrupted." }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (received === 0) {
|
||||||
|
await rm(destination, { force: true });
|
||||||
|
return NextResponse.json({ error: "The uploaded file was empty." }, { status: 400 });
|
||||||
|
}
|
||||||
|
return NextResponse.json({ uploadId, sizeBytes: received });
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { readJob } from "@/lib/jobs";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Polled by the JobProgress component. Behind the Manager login like
|
||||||
|
* every route (see proxy.ts). A job record holds step names, a log tail
|
||||||
|
* and a result summary — never secrets.
|
||||||
|
*/
|
||||||
|
export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await params;
|
||||||
|
const job = await readJob(id);
|
||||||
|
if (!job) return NextResponse.json({ error: "No such job." }, { status: 404 });
|
||||||
|
return NextResponse.json(job, { headers: { "Cache-Control": "no-store" } });
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const composeUpMock = vi.fn().mockResolvedValue(undefined);
|
||||||
|
vi.mock("@/lib/docker", () => ({
|
||||||
|
composeUp: (...args: unknown[]) => composeUpMock(...args),
|
||||||
|
composeDown: vi.fn(),
|
||||||
|
composeLogs: vi.fn(),
|
||||||
|
composeRestart: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const fetchInstallationStatusMock = vi.fn();
|
||||||
|
vi.mock("@/lib/installation-status", () => ({
|
||||||
|
fetchInstallationStatus: (...args: unknown[]) => fetchInstallationStatusMock(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||||
|
|
||||||
|
import { regenerateInstallationTokenAction } from "../actions";
|
||||||
|
|
||||||
|
const TOKEN_HEX_PATTERN = /^[a-f0-9]{64}$/;
|
||||||
|
|
||||||
|
describe("regenerateInstallationTokenAction", () => {
|
||||||
|
let stacksDir: string;
|
||||||
|
const originalStacksDir = process.env.STACKS_DIR;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
stacksDir = await mkdtemp(path.join(tmpdir(), "nc-manager-actions-test-"));
|
||||||
|
process.env.STACKS_DIR = stacksDir;
|
||||||
|
composeUpMock.mockClear();
|
||||||
|
fetchInstallationStatusMock.mockReset();
|
||||||
|
fetchInstallationStatusMock.mockResolvedValue({ status: "not_installed", installedAt: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (originalStacksDir === undefined) delete process.env.STACKS_DIR;
|
||||||
|
else process.env.STACKS_DIR = originalStacksDir;
|
||||||
|
await rm(stacksDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
async function createStack(slug: string, envContent: string) {
|
||||||
|
const dir = path.join(stacksDir, slug);
|
||||||
|
await mkdir(dir, { recursive: true });
|
||||||
|
await writeFile(path.join(dir, "compose.yaml"), "name: test\n", "utf8");
|
||||||
|
await writeFile(path.join(dir, ".env"), envContent, "utf8");
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formData(fields: Record<string, string>): FormData {
|
||||||
|
const fd = new FormData();
|
||||||
|
for (const [key, value] of Object.entries(fields)) fd.set(key, value);
|
||||||
|
return fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("requires confirmation on the first call — does not touch the .env or the container yet", async () => {
|
||||||
|
await createStack("acme", 'INSTALLATION_TOKEN="old-token"\n');
|
||||||
|
|
||||||
|
const result = await regenerateInstallationTokenAction("acme", { error: null, needsConfirmation: false, token: null }, formData({}));
|
||||||
|
|
||||||
|
expect(result).toEqual({ error: null, needsConfirmation: true, token: null });
|
||||||
|
expect(composeUpMock).not.toHaveBeenCalled();
|
||||||
|
const envAfter = await readFile(path.join(stacksDir, "acme", ".env"), "utf8");
|
||||||
|
expect(envAfter).toContain("old-token");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("on confirmation, replaces the token, recreates the container, and returns the new token", async () => {
|
||||||
|
await createStack("acme", 'BUSINESS_NAME="Acme"\nINSTALLATION_TOKEN="old-token"\nAUTH_SECRET="unrelated-secret"\n');
|
||||||
|
|
||||||
|
const result = await regenerateInstallationTokenAction(
|
||||||
|
"acme",
|
||||||
|
{ error: null, needsConfirmation: true, token: null },
|
||||||
|
formData({ confirmRegenerate: "true" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.error).toBeNull();
|
||||||
|
expect(result.token).toMatch(TOKEN_HEX_PATTERN);
|
||||||
|
expect(result.token).not.toBe("old-token");
|
||||||
|
|
||||||
|
const envAfter = await readFile(path.join(stacksDir, "acme", ".env"), "utf8");
|
||||||
|
expect(envAfter).toContain(`INSTALLATION_TOKEN="${result.token}"`);
|
||||||
|
expect(envAfter).not.toContain("old-token");
|
||||||
|
// Rewriting the token must not touch unrelated secrets already in the file.
|
||||||
|
expect(envAfter).toContain('AUTH_SECRET="unrelated-secret"');
|
||||||
|
|
||||||
|
expect(composeUpMock).toHaveBeenCalledWith(path.join(stacksDir, "acme"), { forceRecreate: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("invalidates the previous token — the old value no longer appears anywhere in the business's config", async () => {
|
||||||
|
await createStack("acme", 'INSTALLATION_TOKEN="old-token-to-invalidate"\n');
|
||||||
|
|
||||||
|
await regenerateInstallationTokenAction("acme", { error: null, needsConfirmation: true, token: null }, formData({ confirmRegenerate: "true" }));
|
||||||
|
|
||||||
|
const envAfter = await readFile(path.join(stacksDir, "acme", ".env"), "utf8");
|
||||||
|
expect(envAfter).not.toContain("old-token-to-invalidate");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses to regenerate once installation is complete, even if the form is (re)submitted", async () => {
|
||||||
|
await createStack("acme", 'INSTALLATION_TOKEN="old-token"\n');
|
||||||
|
fetchInstallationStatusMock.mockResolvedValue({ status: "installed", installedAt: "2026-01-01T00:00:00.000Z" });
|
||||||
|
|
||||||
|
const result = await regenerateInstallationTokenAction(
|
||||||
|
"acme",
|
||||||
|
{ error: null, needsConfirmation: true, token: null },
|
||||||
|
formData({ confirmRegenerate: "true" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.error).toMatch(/already completed installation/i);
|
||||||
|
expect(result.token).toBeNull();
|
||||||
|
expect(composeUpMock).not.toHaveBeenCalled();
|
||||||
|
const envAfter = await readFile(path.join(stacksDir, "acme", ".env"), "utf8");
|
||||||
|
expect(envAfter).toContain("old-token");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tenant isolation: regenerating one business's token never touches a sibling business's .env", async () => {
|
||||||
|
await createStack("tenant-a", 'INSTALLATION_TOKEN="token-a"\n');
|
||||||
|
await createStack("tenant-b", 'INSTALLATION_TOKEN="token-b"\n');
|
||||||
|
|
||||||
|
await regenerateInstallationTokenAction("tenant-a", { error: null, needsConfirmation: true, token: null }, formData({ confirmRegenerate: "true" }));
|
||||||
|
|
||||||
|
const envB = await readFile(path.join(stacksDir, "tenant-b", ".env"), "utf8");
|
||||||
|
expect(envB).toContain('INSTALLATION_TOKEN="token-b"');
|
||||||
|
expect(composeUpMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(composeUpMock).toHaveBeenCalledWith(path.join(stacksDir, "tenant-a"), { forceRecreate: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an unknown/nonexistent slug", async () => {
|
||||||
|
await expect(
|
||||||
|
regenerateInstallationTokenAction("does-not-exist", { error: null, needsConfirmation: true, token: null }, formData({ confirmRegenerate: "true" })),
|
||||||
|
).rejects.toThrow(/no such stack/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import path from "node:path";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { composeDown, composeLogs, composeRestart, composeUp } from "@/lib/docker";
|
||||||
|
import { upsertEnvValues } from "@/lib/env-file";
|
||||||
|
import { setIntentionallyStopped } from "@/lib/health-watcher";
|
||||||
|
import { fetchInstallationStatus } from "@/lib/installation-status";
|
||||||
|
import { NO_JOB, type JobStartState } from "@/lib/job-state";
|
||||||
|
import { assertNotLocked, BusinessLockedError } from "@/lib/locks";
|
||||||
|
import { startUpdateJob } from "@/lib/operations";
|
||||||
|
import { randomHex } from "@/lib/random";
|
||||||
|
import { isValidSlug, stackDir, stackExists } from "@/lib/stacks";
|
||||||
|
|
||||||
|
async function assertStack(slug: string): Promise<string> {
|
||||||
|
if (!isValidSlug(slug) || !(await stackExists(slug))) {
|
||||||
|
throw new Error(`No such stack: ${slug}`);
|
||||||
|
}
|
||||||
|
return stackDir(slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startStackAction(slug: string): Promise<void> {
|
||||||
|
const dir = await assertStack(slug);
|
||||||
|
await assertNotLocked(slug);
|
||||||
|
await composeUp(dir);
|
||||||
|
await setIntentionallyStopped(slug, false);
|
||||||
|
revalidatePath(`/dashboard/${slug}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function stopStackAction(slug: string): Promise<void> {
|
||||||
|
const dir = await assertStack(slug);
|
||||||
|
await assertNotLocked(slug);
|
||||||
|
// Mark it first so the health watcher doesn't alert on a stop the operator asked for.
|
||||||
|
await setIntentionallyStopped(slug, true);
|
||||||
|
await composeDown(dir);
|
||||||
|
revalidatePath(`/dashboard/${slug}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function restartStackAction(slug: string): Promise<void> {
|
||||||
|
const dir = await assertStack(slug);
|
||||||
|
await assertNotLocked(slug);
|
||||||
|
await composeRestart(dir);
|
||||||
|
revalidatePath(`/dashboard/${slug}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recreates the container against whatever the shared image tag currently
|
||||||
|
* points to (--force-recreate) — not a source rebuild. See
|
||||||
|
* manager/.env.example's NEXTCHAIR_IMAGE comment for the actual rebuild
|
||||||
|
* step. Runs as a background job (steps: [back up], recreate, wait for
|
||||||
|
* /api/health, done) the UI follows with <JobProgress>; the per-business
|
||||||
|
* lock stops it overlapping a backup, restore or delete.
|
||||||
|
*/
|
||||||
|
export async function updateStackAction(slug: string, backupFirst: boolean): Promise<JobStartState> {
|
||||||
|
await assertStack(slug);
|
||||||
|
try {
|
||||||
|
const { jobId } = await startUpdateJob(slug, backupFirst);
|
||||||
|
return { error: null, jobId };
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof BusinessLockedError) return { ...NO_JOB, error: error.message };
|
||||||
|
return { ...NO_JOB, error: error instanceof Error ? error.message : "Couldn't start the update." };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchLogsAction(slug: string): Promise<string> {
|
||||||
|
const dir = await assertStack(slug);
|
||||||
|
return composeLogs(dir, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegenerateTokenState {
|
||||||
|
error: string | null;
|
||||||
|
needsConfirmation: boolean;
|
||||||
|
/** The freshly-generated token, present only right after a successful regenerate — the panel re-reads it fresh on next load otherwise, same as every other value on this page. */
|
||||||
|
token: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-issues INSTALLATION_TOKEN for a business that hasn't completed
|
||||||
|
* installation yet — same two-step confirm shape as
|
||||||
|
* dashboard/settings/actions.ts's generateVapidKeysAction: the first
|
||||||
|
* submit (no `confirmRegenerate` field) comes back with
|
||||||
|
* `needsConfirmation: true` instead of acting, and only a second submit
|
||||||
|
* with `confirmRegenerate=true` actually rotates the token.
|
||||||
|
*
|
||||||
|
* Refuses once installation is complete (checked here too, not just
|
||||||
|
* hidden in the UI — defense in depth against a stale/re-submitted
|
||||||
|
* form). Uses the exact same `randomHex` generator provisioning.ts
|
||||||
|
* itself uses, so this is a rotation of the one existing token
|
||||||
|
* mechanism, not a second one; `upsertEnvValues` (also already used by
|
||||||
|
* updateBusiness) rewrites only the one line, leaving every other
|
||||||
|
* secret in this business's .env untouched. A container recreate is
|
||||||
|
* required afterward — env vars are only read at container start (see
|
||||||
|
* docker/entrypoint.sh), never hot-reloaded.
|
||||||
|
*/
|
||||||
|
export async function regenerateInstallationTokenAction(
|
||||||
|
slug: string,
|
||||||
|
_prevState: RegenerateTokenState,
|
||||||
|
formData: FormData,
|
||||||
|
): Promise<RegenerateTokenState> {
|
||||||
|
const dir = await assertStack(slug);
|
||||||
|
|
||||||
|
const { status } = await fetchInstallationStatus(slug);
|
||||||
|
if (status === "installed") {
|
||||||
|
return {
|
||||||
|
error: "This business has already completed installation — its installation token can no longer be regenerated.",
|
||||||
|
needsConfirmation: false,
|
||||||
|
token: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmRegenerate = formData.get("confirmRegenerate") === "true";
|
||||||
|
if (!confirmRegenerate) {
|
||||||
|
return { error: null, needsConfirmation: true, token: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = randomHex(32);
|
||||||
|
await upsertEnvValues(path.join(dir, ".env"), { INSTALLATION_TOKEN: token });
|
||||||
|
await composeUp(dir, { forceRecreate: true });
|
||||||
|
revalidatePath(`/dashboard/${slug}`);
|
||||||
|
|
||||||
|
return { error: null, needsConfirmation: false, token };
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { saveBackupSettings, validateBackupSettingsInput, type BackupFrequency } from "@/lib/backup-settings";
|
||||||
|
import { deleteBackup } from "@/lib/backup-store";
|
||||||
|
import { NO_JOB, type JobStartState } from "@/lib/job-state";
|
||||||
|
import { assertNotLocked, BusinessLockedError } from "@/lib/locks";
|
||||||
|
import { startBackupJob } from "@/lib/operations";
|
||||||
|
import { isValidSlug, stackExists } from "@/lib/stacks";
|
||||||
|
|
||||||
|
async function assertBusiness(slug: string): Promise<void> {
|
||||||
|
if (!isValidSlug(slug) || !(await stackExists(slug))) throw new Error(`No such business: ${slug}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SaveBackupSettingsState {
|
||||||
|
error: string | null;
|
||||||
|
saved: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveBackupSettingsAction(
|
||||||
|
slug: string,
|
||||||
|
_prev: SaveBackupSettingsState,
|
||||||
|
formData: FormData,
|
||||||
|
): Promise<SaveBackupSettingsState> {
|
||||||
|
await assertBusiness(slug);
|
||||||
|
|
||||||
|
const input = {
|
||||||
|
enabled: formData.get("enabled") === "on",
|
||||||
|
frequency: (formData.get("frequency") === "weekly" ? "weekly" : "daily") as BackupFrequency,
|
||||||
|
time: String(formData.get("time") ?? "").trim(),
|
||||||
|
weekday: Number(formData.get("weekday") ?? 0),
|
||||||
|
keep: Number(formData.get("keep") ?? 2),
|
||||||
|
};
|
||||||
|
const problem = validateBackupSettingsInput(input);
|
||||||
|
if (problem) return { error: problem, saved: false };
|
||||||
|
|
||||||
|
await saveBackupSettings(slug, input);
|
||||||
|
revalidatePath(`/dashboard/${slug}/backups`);
|
||||||
|
return { error: null, saved: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function backupNowAction(slug: string): Promise<JobStartState> {
|
||||||
|
await assertBusiness(slug);
|
||||||
|
try {
|
||||||
|
const { jobId } = await startBackupJob(slug, "regular");
|
||||||
|
return { error: null, jobId };
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof BusinessLockedError) return { ...NO_JOB, error: error.message };
|
||||||
|
return { ...NO_JOB, error: error instanceof Error ? error.message : "Couldn't start the backup." };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteBackupAction(slug: string, fileName: string): Promise<{ error: string | null }> {
|
||||||
|
try {
|
||||||
|
await assertNotLocked(slug); // a running backup/restore might be reading or pruning files
|
||||||
|
await deleteBackup(slug, fileName);
|
||||||
|
revalidatePath(`/dashboard/${slug}/backups`);
|
||||||
|
return { error: null };
|
||||||
|
} catch (error) {
|
||||||
|
return { error: error instanceof Error ? error.message : "Couldn't delete that backup." };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useActionState, useState, useTransition } from "react";
|
||||||
|
import type { BackupEntry } from "@/lib/backup-store";
|
||||||
|
import { JobProgress } from "../../job-progress";
|
||||||
|
import {
|
||||||
|
backupNowAction,
|
||||||
|
deleteBackupAction,
|
||||||
|
saveBackupSettingsAction,
|
||||||
|
type SaveBackupSettingsState,
|
||||||
|
} from "./actions";
|
||||||
|
|
||||||
|
interface SettingsView {
|
||||||
|
enabled: boolean;
|
||||||
|
frequency: "daily" | "weekly";
|
||||||
|
time: string;
|
||||||
|
weekday: number;
|
||||||
|
keep: number;
|
||||||
|
lastAttemptAt: string | null;
|
||||||
|
lastSuccessAt: string | null;
|
||||||
|
lastStatus: "ok" | "failed" | null;
|
||||||
|
lastMessage: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const WEEKDAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
||||||
|
const initialSaveState: SaveBackupSettingsState = { error: null, saved: false };
|
||||||
|
|
||||||
|
function formatBytes(bytes: number): string {
|
||||||
|
if (bytes < 1024 * 1024) return `${Math.max(1, Math.round(bytes / 1024))} KB`;
|
||||||
|
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||||
|
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatWhen(iso: string | null): string {
|
||||||
|
if (!iso) return "never";
|
||||||
|
return new Date(iso).toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
const KIND_LABEL: Record<BackupEntry["kind"], string> = {
|
||||||
|
regular: "Backup",
|
||||||
|
safety: "Safety (auto, before update/restore)",
|
||||||
|
final: "Final (before delete)",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function BackupsPanel({
|
||||||
|
slug,
|
||||||
|
settings,
|
||||||
|
backups,
|
||||||
|
activeJobId,
|
||||||
|
timeZone,
|
||||||
|
}: {
|
||||||
|
slug: string;
|
||||||
|
settings: SettingsView;
|
||||||
|
backups: BackupEntry[];
|
||||||
|
activeJobId: string | null;
|
||||||
|
timeZone: string;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [saveState, saveAction, isSaving] = useActionState(saveBackupSettingsAction.bind(null, slug), initialSaveState);
|
||||||
|
const [frequency, setFrequency] = useState(settings.frequency);
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [jobId, setJobId] = useState<string | null>(activeJobId);
|
||||||
|
const [jobRunning, setJobRunning] = useState(activeJobId !== null);
|
||||||
|
|
||||||
|
const backupNow = () => {
|
||||||
|
setError(null);
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await backupNowAction(slug);
|
||||||
|
if (result.error) {
|
||||||
|
setError(result.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setJobId(result.jobId);
|
||||||
|
setJobRunning(true);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = (fileName: string) => {
|
||||||
|
if (!window.confirm(`Delete ${fileName}? This cannot be undone.`)) return;
|
||||||
|
setError(null);
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await deleteBackupAction(slug, fileName);
|
||||||
|
if (result.error) setError(result.error);
|
||||||
|
else router.refresh();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<section className="space-y-3">
|
||||||
|
<h2 className="text-lg font-semibold">Schedule</h2>
|
||||||
|
<form action={saveAction} className="space-y-4 rounded-md border border-line bg-surface p-5">
|
||||||
|
<label className="flex items-center gap-2 text-sm font-medium">
|
||||||
|
<input type="checkbox" name="enabled" defaultChecked={settings.enabled} />
|
||||||
|
Back up this business automatically
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="frequency" className="block text-sm font-medium text-muted">
|
||||||
|
How often
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="frequency"
|
||||||
|
name="frequency"
|
||||||
|
value={frequency}
|
||||||
|
onChange={(e) => setFrequency(e.target.value as "daily" | "weekly")}
|
||||||
|
className="mt-1 w-full rounded-md border border-line px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<option value="daily">Daily</option>
|
||||||
|
<option value="weekly">Weekly</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{frequency === "weekly" && (
|
||||||
|
<div>
|
||||||
|
<label htmlFor="weekday" className="block text-sm font-medium text-muted">
|
||||||
|
Day
|
||||||
|
</label>
|
||||||
|
<select id="weekday" name="weekday" defaultValue={settings.weekday} className="mt-1 w-full rounded-md border border-line px-3 py-2 text-sm">
|
||||||
|
{WEEKDAYS.map((day, index) => (
|
||||||
|
<option key={day} value={index}>
|
||||||
|
{day}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="time" className="block text-sm font-medium text-muted">
|
||||||
|
Time ({timeZone})
|
||||||
|
</label>
|
||||||
|
<input id="time" name="time" type="time" required defaultValue={settings.time} className="mt-1 w-full rounded-md border border-line px-3 py-2 text-sm" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="keep" className="block text-sm font-medium text-muted">
|
||||||
|
Backups to keep
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="keep"
|
||||||
|
name="keep"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={14}
|
||||||
|
required
|
||||||
|
defaultValue={settings.keep}
|
||||||
|
className="mt-1 w-full rounded-md border border-line px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{saveState.error && (
|
||||||
|
<p role="alert" className="text-sm font-medium text-danger">
|
||||||
|
{saveState.error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{saveState.saved && !saveState.error && <p className="text-sm text-green-700">Saved.</p>}
|
||||||
|
<button type="submit" disabled={isSaving} className="rounded-md bg-ink px-4 py-2 text-sm font-semibold text-white disabled:opacity-60">
|
||||||
|
{isSaving ? "Saving…" : "Save schedule"}
|
||||||
|
</button>
|
||||||
|
<p className="text-xs text-muted">
|
||||||
|
Last backup run: {formatWhen(settings.lastAttemptAt)}
|
||||||
|
{settings.lastStatus === "ok" && " — succeeded"}
|
||||||
|
{settings.lastStatus === "failed" && <span className="text-danger"> — failed: {settings.lastMessage}</span>}
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-semibold">Backups</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={backupNow}
|
||||||
|
disabled={isPending || jobRunning}
|
||||||
|
className="rounded-md bg-ink px-4 py-2 text-sm font-semibold text-white disabled:opacity-60"
|
||||||
|
>
|
||||||
|
Back up now
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="text-sm font-medium text-danger">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{jobId && <JobProgress key={jobId} jobId={jobId} onFinished={() => setJobRunning(false)} />}
|
||||||
|
|
||||||
|
{backups.length === 0 ? (
|
||||||
|
<p className="rounded-md border border-line bg-surface p-4 text-sm text-muted">No backups yet.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-line rounded-md border border-line bg-surface">
|
||||||
|
{backups.map((backup) => (
|
||||||
|
<li key={backup.fileName} className="flex flex-wrap items-center gap-3 px-4 py-3 text-sm">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate font-mono text-xs">{backup.fileName}</p>
|
||||||
|
<p className="text-xs text-muted">
|
||||||
|
{new Date(backup.createdAt).toLocaleString()} · {formatBytes(backup.sizeBytes)} · {KIND_LABEL[backup.kind]}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href={`/api/backups/${slug}/${backup.fileName}`}
|
||||||
|
className="rounded-md border border-line px-3 py-1 text-xs font-medium"
|
||||||
|
download
|
||||||
|
>
|
||||||
|
Download
|
||||||
|
</a>
|
||||||
|
<Link
|
||||||
|
href={`/dashboard/restore?slug=${encodeURIComponent(slug)}&file=${encodeURIComponent(backup.fileName)}`}
|
||||||
|
className="rounded-md border border-line px-3 py-1 text-xs font-medium"
|
||||||
|
>
|
||||||
|
Restore…
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={isPending || jobRunning}
|
||||||
|
onClick={() => remove(backup.fileName)}
|
||||||
|
className="rounded-md border border-danger/30 px-3 py-1 text-xs font-medium text-danger disabled:opacity-60"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-2 text-sm text-muted">
|
||||||
|
<h2 className="text-lg font-semibold text-ink">Handle with care</h2>
|
||||||
|
<p>
|
||||||
|
Backups are not password-protected. Each one contains this business's secrets (its <span className="font-mono">.env</span>: database
|
||||||
|
password, sign-in secret, email password, push keys) as well as its data. They are stored owner-only on this server; if you download one,
|
||||||
|
keep it somewhere as private as the server itself.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { readBackupSettings } from "@/lib/backup-settings";
|
||||||
|
import { listBackups } from "@/lib/backup-store";
|
||||||
|
import { getActiveLock } from "@/lib/locks";
|
||||||
|
import { listBusinessStacks } from "@/lib/stacks";
|
||||||
|
import { BackupsPanel } from "./backups-panel";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Backups" };
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function BackupsPage({ params }: { params: Promise<{ slug: string }> }) {
|
||||||
|
const { slug } = await params;
|
||||||
|
const stacks = await listBusinessStacks();
|
||||||
|
const stack = stacks.find((s) => s.slug === slug);
|
||||||
|
if (!stack) notFound();
|
||||||
|
|
||||||
|
const [settings, backups, lock] = await Promise.all([
|
||||||
|
readBackupSettings(slug),
|
||||||
|
listBackups(slug),
|
||||||
|
getActiveLock(slug),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<Link href={`/dashboard/${slug}`} className="text-sm text-muted hover:text-ink">
|
||||||
|
← {stack.name ?? slug}
|
||||||
|
</Link>
|
||||||
|
<h1 className="mt-2 text-xl font-bold">Backups</h1>
|
||||||
|
<p className="text-sm text-muted">
|
||||||
|
Encrypted 7z archives of the database, media files and configuration, stored on this server under{" "}
|
||||||
|
<span className="font-mono">backups/{slug}/</span>. To restore one — onto this business or a fresh server — use{" "}
|
||||||
|
<Link href="/dashboard/restore" className="font-medium text-ink underline">
|
||||||
|
Restore
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<BackupsPanel
|
||||||
|
slug={slug}
|
||||||
|
settings={{
|
||||||
|
enabled: settings.enabled,
|
||||||
|
frequency: settings.frequency,
|
||||||
|
time: settings.time,
|
||||||
|
weekday: settings.weekday,
|
||||||
|
keep: settings.keep,
|
||||||
|
lastAttemptAt: settings.lastAttemptAt,
|
||||||
|
lastSuccessAt: settings.lastSuccessAt,
|
||||||
|
lastStatus: settings.lastStatus,
|
||||||
|
lastMessage: settings.lastMessage,
|
||||||
|
}}
|
||||||
|
backups={backups}
|
||||||
|
activeJobId={lock?.jobId ?? null}
|
||||||
|
timeZone={Intl.DateTimeFormat().resolvedOptions().timeZone}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import { JobProgress } from "../job-progress";
|
||||||
|
import {
|
||||||
|
fetchLogsAction,
|
||||||
|
restartStackAction,
|
||||||
|
startStackAction,
|
||||||
|
stopStackAction,
|
||||||
|
updateStackAction,
|
||||||
|
} from "./actions";
|
||||||
|
|
||||||
|
export function BusinessActions({ slug, activeJobId }: { slug: string; activeJobId: string | null }) {
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [logs, setLogs] = useState<string | null>(null);
|
||||||
|
const [updateOpen, setUpdateOpen] = useState(false);
|
||||||
|
const [backupFirst, setBackupFirst] = useState(true);
|
||||||
|
const [jobId, setJobId] = useState<string | null>(activeJobId);
|
||||||
|
const [jobRunning, setJobRunning] = useState(activeJobId !== null);
|
||||||
|
|
||||||
|
// Quick actions are refused server-side while a job holds the business; the buttons just mirror that.
|
||||||
|
const busy = isPending || jobRunning;
|
||||||
|
|
||||||
|
const run = (action: (slug: string) => Promise<void>) => {
|
||||||
|
setError(null);
|
||||||
|
startTransition(async () => {
|
||||||
|
try {
|
||||||
|
await action(slug);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Action failed.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const runUpdate = () => {
|
||||||
|
setError(null);
|
||||||
|
startTransition(async () => {
|
||||||
|
try {
|
||||||
|
const result = await updateStackAction(slug, backupFirst);
|
||||||
|
if (result.error) {
|
||||||
|
setError(result.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setJobId(result.jobId);
|
||||||
|
setJobRunning(true);
|
||||||
|
setUpdateOpen(false);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Couldn't start the update.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadLogs = () => {
|
||||||
|
setError(null);
|
||||||
|
startTransition(async () => {
|
||||||
|
try {
|
||||||
|
setLogs(await fetchLogsAction(slug));
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Failed to load logs.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => run(startStackAction)}
|
||||||
|
className="rounded-md border border-line px-3 py-1.5 text-sm font-medium disabled:opacity-60"
|
||||||
|
>
|
||||||
|
Start
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => run(stopStackAction)}
|
||||||
|
className="rounded-md border border-line px-3 py-1.5 text-sm font-medium disabled:opacity-60"
|
||||||
|
>
|
||||||
|
Stop
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => run(restartStackAction)}
|
||||||
|
className="rounded-md border border-line px-3 py-1.5 text-sm font-medium disabled:opacity-60"
|
||||||
|
>
|
||||||
|
Restart
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => setUpdateOpen((open) => !open)}
|
||||||
|
aria-expanded={updateOpen}
|
||||||
|
className="rounded-md border border-line px-3 py-1.5 text-sm font-medium disabled:opacity-60"
|
||||||
|
title="Recreates the container against the current shared image tag — not a source rebuild."
|
||||||
|
>
|
||||||
|
Update
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={isPending}
|
||||||
|
onClick={loadLogs}
|
||||||
|
className="rounded-md border border-line px-3 py-1.5 text-sm font-medium disabled:opacity-60"
|
||||||
|
>
|
||||||
|
View logs
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{updateOpen && !jobRunning && (
|
||||||
|
<div className="space-y-3 rounded-md border border-line bg-surface p-4">
|
||||||
|
<p className="text-sm text-muted">
|
||||||
|
Recreates the container against the current shared image tag, then waits for it to answer{" "}
|
||||||
|
<span className="font-mono">/api/health</span>. This does <strong>not</strong> rebuild the image — build{" "}
|
||||||
|
<span className="font-mono">nextchair-app</span> first if the code changed.
|
||||||
|
</p>
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<input type="checkbox" checked={backupFirst} onChange={(e) => setBackupFirst(e.target.checked)} />
|
||||||
|
Back up first (recommended)
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={isPending}
|
||||||
|
onClick={runUpdate}
|
||||||
|
className="rounded-md bg-ink px-4 py-2 text-sm font-semibold text-white disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{isPending ? "Starting…" : "Run update"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="text-sm font-medium text-danger">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{jobId && <JobProgress key={jobId} jobId={jobId} onFinished={() => setJobRunning(false)} />}
|
||||||
|
{logs !== null && (
|
||||||
|
<pre className="max-h-96 overflow-auto rounded-md bg-ink p-4 text-xs text-white">{logs}</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { BusinessLockedError } from "@/lib/locks";
|
||||||
|
import { startDeleteJob } from "@/lib/operations";
|
||||||
|
|
||||||
|
export interface DeleteBusinessState {
|
||||||
|
error: string | null;
|
||||||
|
/** Set once the delete job has started — the form then follows it with <JobProgress>. */
|
||||||
|
jobId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `slug` is bound in by the page, the same way updateBusinessAction takes
|
||||||
|
* it — the operator only ever types a *confirmation* string, never the
|
||||||
|
* slug being acted on as a submittable field.
|
||||||
|
*
|
||||||
|
* Deleting is a background job holding the business lock (so it can't
|
||||||
|
* overlap an update, backup or restore). With "final backup" ticked the
|
||||||
|
* job takes one first and aborts WITHOUT deleting if that backup fails.
|
||||||
|
*/
|
||||||
|
export async function deleteBusinessAction(
|
||||||
|
slug: string,
|
||||||
|
_prevState: DeleteBusinessState,
|
||||||
|
formData: FormData,
|
||||||
|
): Promise<DeleteBusinessState> {
|
||||||
|
const confirmation = String(formData.get("confirmation") ?? "");
|
||||||
|
if (confirmation !== slug) {
|
||||||
|
return { error: `Type "${slug}" exactly to confirm deletion.`, jobId: null };
|
||||||
|
}
|
||||||
|
const finalBackup = formData.get("finalBackup") === "on";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { jobId } = await startDeleteJob(slug, finalBackup);
|
||||||
|
return { error: null, jobId };
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof BusinessLockedError) return { error: error.message, jobId: null };
|
||||||
|
return { error: error instanceof Error ? error.message : "Failed to delete this business.", jobId: null };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useActionState, useState } from "react";
|
||||||
|
import { JobProgress } from "../../job-progress";
|
||||||
|
import { deleteBusinessAction, type DeleteBusinessState } from "./actions";
|
||||||
|
|
||||||
|
const initialState: DeleteBusinessState = { error: null, jobId: null };
|
||||||
|
|
||||||
|
export function DeleteBusinessForm({ slug }: { slug: string }) {
|
||||||
|
const boundAction = deleteBusinessAction.bind(null, slug);
|
||||||
|
const [state, formAction, isPending] = useActionState(boundAction, initialState);
|
||||||
|
const [confirmation, setConfirmation] = useState("");
|
||||||
|
const canDelete = confirmation === slug;
|
||||||
|
|
||||||
|
if (state.jobId) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<JobProgress jobId={state.jobId} refreshOnFinish={false}>
|
||||||
|
<Link href="/dashboard" className="inline-block rounded-md bg-ink px-4 py-2 text-sm font-semibold text-white">
|
||||||
|
Back to businesses
|
||||||
|
</Link>
|
||||||
|
</JobProgress>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={formAction} className="space-y-4">
|
||||||
|
<label className="flex items-start gap-2 text-sm">
|
||||||
|
<input type="checkbox" name="finalBackup" defaultChecked className="mt-0.5" />
|
||||||
|
<span>
|
||||||
|
Take a final backup first <span className="text-muted">(recommended — kept on this server after the business is gone; deleting stops if the backup fails)</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="confirmation" className="block text-sm font-medium text-muted">
|
||||||
|
Type <span className="font-mono font-semibold text-ink">{slug}</span> to confirm
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="confirmation"
|
||||||
|
name="confirmation"
|
||||||
|
type="text"
|
||||||
|
autoComplete="off"
|
||||||
|
autoCorrect="off"
|
||||||
|
spellCheck={false}
|
||||||
|
value={confirmation}
|
||||||
|
onChange={(e) => setConfirmation(e.target.value)}
|
||||||
|
className="mt-1 w-full rounded-md border border-line px-3 py-2 font-mono text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{state.error && (
|
||||||
|
<p role="alert" className="text-sm font-medium text-danger">
|
||||||
|
{state.error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!canDelete || isPending}
|
||||||
|
className="w-full rounded-md bg-danger py-2 text-sm font-semibold text-white disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{isPending ? "Starting…" : "Delete Business"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { listBusinessStacks } from "@/lib/stacks";
|
||||||
|
import { DeleteBusinessForm } from "./delete-business-form";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function DeleteBusinessPage({ params }: { params: Promise<{ slug: string }> }) {
|
||||||
|
const { slug } = await params;
|
||||||
|
|
||||||
|
const stacks = await listBusinessStacks();
|
||||||
|
const stack = stacks.find((s) => s.slug === slug);
|
||||||
|
if (!stack) notFound();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-lg space-y-6">
|
||||||
|
<div>
|
||||||
|
<Link href={`/dashboard/${slug}`} className="text-sm text-muted hover:text-ink">
|
||||||
|
← Back
|
||||||
|
</Link>
|
||||||
|
<h1 className="mt-2 text-xl font-bold text-danger">Delete {stack.name ?? slug}</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 rounded-md border border-danger/30 bg-danger/5 p-4 text-sm">
|
||||||
|
<p className="font-semibold text-danger">This permanently deletes:</p>
|
||||||
|
<ul className="list-inside list-disc space-y-1 text-muted">
|
||||||
|
<li>The running container and its media uploads volume</li>
|
||||||
|
<li>
|
||||||
|
The <span className="font-mono">{stack.databaseName ?? "business"}</span> database — every customer,
|
||||||
|
appointment, and setting in it
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Its Caddy site — {stack.domain ?? "its domain"} will stop resolving to this business immediately
|
||||||
|
</li>
|
||||||
|
<li>The stack directory and its configuration on disk</li>
|
||||||
|
</ul>
|
||||||
|
<p className="font-semibold">This cannot be undone.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DeleteBusinessForm slug={slug} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { updateBusiness } from "@/lib/provisioning";
|
||||||
|
|
||||||
|
export interface EditBusinessState {
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `slug` is bound in by the page (see edit-business-form.tsx's
|
||||||
|
* `.bind(null, slug)`) rather than coming from form data, the same way
|
||||||
|
* BusinessActions' start/stop/restart actions take slug as a plain
|
||||||
|
* argument — it's route context, not a field the operator can edit.
|
||||||
|
*/
|
||||||
|
export async function updateBusinessAction(
|
||||||
|
slug: string,
|
||||||
|
_prevState: EditBusinessState,
|
||||||
|
formData: FormData,
|
||||||
|
): Promise<EditBusinessState> {
|
||||||
|
const name = String(formData.get("name") ?? "");
|
||||||
|
const domain = String(formData.get("domain") ?? "");
|
||||||
|
|
||||||
|
let result: { slug: string; restarted: boolean };
|
||||||
|
try {
|
||||||
|
result = await updateBusiness({ slug, name, domain });
|
||||||
|
} catch (error) {
|
||||||
|
return { error: error instanceof Error ? error.message : "Failed to save changes." };
|
||||||
|
}
|
||||||
|
|
||||||
|
redirect(`/dashboard/${result.slug}?justUpdated=1&restarted=${result.restarted ? "1" : "0"}`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useActionState } from "react";
|
||||||
|
import { updateBusinessAction, type EditBusinessState } from "./actions";
|
||||||
|
|
||||||
|
const initialState: EditBusinessState = { error: null };
|
||||||
|
|
||||||
|
export function EditBusinessForm({
|
||||||
|
slug,
|
||||||
|
initialName,
|
||||||
|
initialDomain,
|
||||||
|
}: {
|
||||||
|
slug: string;
|
||||||
|
initialName: string;
|
||||||
|
initialDomain: string;
|
||||||
|
}) {
|
||||||
|
const boundAction = updateBusinessAction.bind(null, slug);
|
||||||
|
const [state, formAction, isPending] = useActionState(boundAction, initialState);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={formAction} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="name" className="block text-sm font-medium text-muted">
|
||||||
|
Business name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="name"
|
||||||
|
name="name"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
defaultValue={initialName}
|
||||||
|
className="mt-1 w-full rounded-md border border-line px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="slug" className="block text-sm font-medium text-muted">
|
||||||
|
Slug
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="slug"
|
||||||
|
type="text"
|
||||||
|
value={slug}
|
||||||
|
disabled
|
||||||
|
readOnly
|
||||||
|
className="mt-1 w-full rounded-md border border-line bg-stone px-3 py-2 font-mono text-sm text-muted"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-muted">
|
||||||
|
Fixed at creation — it’s baked into the container name, database name, and stack directory, so it
|
||||||
|
can’t be changed here.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="domain" className="block text-sm font-medium text-muted">
|
||||||
|
Domain
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="domain"
|
||||||
|
name="domain"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
defaultValue={initialDomain}
|
||||||
|
className="mt-1 w-full rounded-md border border-line px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-muted">
|
||||||
|
Changing this updates the Caddy site and restarts the container to pick up the new domain — leaving it
|
||||||
|
unchanged saves the name only, with no restart.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{state.error && (
|
||||||
|
<p role="alert" className="text-sm font-medium text-danger">
|
||||||
|
{state.error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isPending}
|
||||||
|
className="w-full rounded-md bg-ink py-2 text-sm font-semibold text-white disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{isPending ? "Saving…" : "Save Changes"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { listBusinessStacks, stackDir } from "@/lib/stacks";
|
||||||
|
import { EditBusinessForm } from "./edit-business-form";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function EditBusinessPage({ params }: { params: Promise<{ slug: string }> }) {
|
||||||
|
const { slug } = await params;
|
||||||
|
|
||||||
|
const stacks = await listBusinessStacks();
|
||||||
|
const stack = stacks.find((s) => s.slug === slug);
|
||||||
|
if (!stack) notFound();
|
||||||
|
|
||||||
|
// Same image every business's compose.yaml resolves NEXTCHAIR_IMAGE
|
||||||
|
// to at `docker compose up` time — see compose-template.ts's
|
||||||
|
// generateComposeYaml.
|
||||||
|
const image = process.env.NEXTCHAIR_IMAGE ?? "nextchair-app:latest";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-lg space-y-6">
|
||||||
|
<div>
|
||||||
|
<Link href={`/dashboard/${slug}`} className="text-sm text-muted hover:text-ink">
|
||||||
|
← Back
|
||||||
|
</Link>
|
||||||
|
<h1 className="mt-2 text-xl font-bold">Edit {stack.name ?? slug}</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EditBusinessForm slug={slug} initialName={stack.name ?? ""} initialDomain={stack.domain ?? ""} />
|
||||||
|
|
||||||
|
<div className="space-y-3 rounded-md border border-line bg-surface p-4 text-sm">
|
||||||
|
<h2 className="text-xs font-bold tracking-wide text-muted uppercase">Read-only info</h2>
|
||||||
|
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5">
|
||||||
|
<dt className="text-muted">Container status</dt>
|
||||||
|
<dd className="font-medium">{stack.status}</dd>
|
||||||
|
<dt className="text-muted">Database name</dt>
|
||||||
|
<dd className="font-mono">{stack.databaseName ?? "unknown"}</dd>
|
||||||
|
<dt className="text-muted">Stack directory</dt>
|
||||||
|
<dd className="font-mono break-all">{stackDir(slug)}</dd>
|
||||||
|
<dt className="text-muted">Image</dt>
|
||||||
|
<dd className="font-mono">{image}</dd>
|
||||||
|
<dt className="text-muted">Live site</dt>
|
||||||
|
<dd>
|
||||||
|
{stack.domain ? (
|
||||||
|
<a
|
||||||
|
href={`https://${stack.domain}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="font-medium text-accent underline"
|
||||||
|
>
|
||||||
|
https://{stack.domain}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
"not set"
|
||||||
|
)}
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useActionState, useState } from "react";
|
||||||
|
import type { InstallationView } from "@/lib/installation-status";
|
||||||
|
import { regenerateInstallationTokenAction, type RegenerateTokenState } from "./actions";
|
||||||
|
|
||||||
|
const regenerateInitialState: RegenerateTokenState = { error: null, needsConfirmation: false, token: null };
|
||||||
|
|
||||||
|
function formatInstalledAt(installedAt: string | null): string | null {
|
||||||
|
if (!installedAt) return null;
|
||||||
|
const date = new Date(installedAt);
|
||||||
|
if (Number.isNaN(date.getTime())) return null;
|
||||||
|
return date.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function CopyButton({ value }: { value: string }) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(value);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
} catch {
|
||||||
|
// Clipboard access can be denied by the browser — the token is
|
||||||
|
// still selectable/copyable by hand from the input next to
|
||||||
|
// this button, so this is a convenience failure, not a dead end.
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="rounded-md border border-line px-3 py-1.5 text-sm font-medium hover:bg-stone"
|
||||||
|
>
|
||||||
|
{copied ? "Copied" : "Copy"}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the per-business "Installation" section on the dashboard —
|
||||||
|
* see installation-status.ts's resolveInstallationView for how `view` is
|
||||||
|
* computed. Everything here is keyed off `view`/`slug`, both scoped to
|
||||||
|
* this one business — there is no code path here that could render one
|
||||||
|
* tenant's token under another tenant's page.
|
||||||
|
*/
|
||||||
|
export function InstallationTokenPanel({ slug, view }: { slug: string; view: InstallationView }) {
|
||||||
|
const [regenerateState, regenerateAction, isRegenerating] = useActionState(
|
||||||
|
regenerateInstallationTokenAction.bind(null, slug),
|
||||||
|
regenerateInitialState,
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentToken = regenerateState.token ?? (view.kind !== "installed" ? view.token : null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 rounded-md border border-line bg-surface p-5">
|
||||||
|
<h2 className="text-lg font-semibold">Installation</h2>
|
||||||
|
|
||||||
|
{view.kind === "installed" && (
|
||||||
|
<dl className="space-y-1 text-sm">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<dt className="font-medium text-muted">Status:</dt>
|
||||||
|
<dd className="font-semibold text-accent">Installed</dd>
|
||||||
|
</div>
|
||||||
|
{formatInstalledAt(view.installedAt) && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<dt className="font-medium text-muted">Installed at:</dt>
|
||||||
|
<dd>{formatInstalledAt(view.installedAt)}</dd>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</dl>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{view.kind !== "installed" && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<dl className="space-y-1 text-sm">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<dt className="font-medium text-muted">Status:</dt>
|
||||||
|
<dd className="font-semibold">Not installed</dd>
|
||||||
|
</div>
|
||||||
|
{view.kind === "unknown" && (
|
||||||
|
<p className="text-xs text-muted">
|
||||||
|
Can't confirm this business's status right now — its container may be stopped or still
|
||||||
|
starting.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
{view.installUrl ? (
|
||||||
|
<div className="text-sm">
|
||||||
|
<p className="font-medium text-muted">Installation URL:</p>
|
||||||
|
<a href={view.installUrl} target="_blank" rel="noreferrer" className="break-all font-mono text-xs text-accent underline">
|
||||||
|
{view.installUrl}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted">No domain set yet — set one in Edit before installing.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-muted">Installation token:</p>
|
||||||
|
{currentToken ? (
|
||||||
|
<div className="mt-1 flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
readOnly
|
||||||
|
value={currentToken}
|
||||||
|
onFocus={(e) => e.currentTarget.select()}
|
||||||
|
className="w-full rounded-md border border-line bg-stone px-3 py-2 font-mono text-xs"
|
||||||
|
/>
|
||||||
|
<CopyButton value={currentToken} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="mt-1 text-sm text-muted">
|
||||||
|
No installation token found in this business's .env — generate one below.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{regenerateState.needsConfirmation && (
|
||||||
|
<div className="space-y-1 rounded-md border border-amber-300 bg-amber-50 p-4">
|
||||||
|
<p className="text-sm font-semibold text-amber-900">Regenerate installation token?</p>
|
||||||
|
<p className="text-sm text-amber-900">The existing installation token will stop working.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-3 pt-1">
|
||||||
|
{view.installUrl && (
|
||||||
|
<a
|
||||||
|
href={view.installUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="rounded-md bg-ink px-4 py-2 text-sm font-semibold text-white"
|
||||||
|
>
|
||||||
|
Open installer
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form action={regenerateAction}>
|
||||||
|
<input type="hidden" name="confirmRegenerate" value={regenerateState.needsConfirmation ? "true" : "false"} />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isRegenerating}
|
||||||
|
className="rounded-md border border-line px-4 py-2 text-sm font-semibold disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{isRegenerating
|
||||||
|
? "Working…"
|
||||||
|
: regenerateState.needsConfirmation
|
||||||
|
? "Yes, Regenerate Token"
|
||||||
|
: currentToken
|
||||||
|
? "Regenerate token"
|
||||||
|
: "Generate token"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{regenerateState.error && (
|
||||||
|
<p role="alert" className="text-sm font-medium text-danger">
|
||||||
|
{regenerateState.error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{regenerateState.token && !regenerateState.error && (
|
||||||
|
<p className="text-sm text-green-700">New token generated and the container was recreated to pick it up.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { resolveInstallationView } from "@/lib/installation-status";
|
||||||
|
import { getActiveLock } from "@/lib/locks";
|
||||||
|
import { listBusinessStacks } from "@/lib/stacks";
|
||||||
|
import { BusinessActions } from "./business-actions";
|
||||||
|
import { InstallationTokenPanel } from "./installation-token-panel";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function BusinessDetailPage({
|
||||||
|
params,
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ slug: string }>;
|
||||||
|
searchParams: Promise<{ justCreated?: string; justUpdated?: string; restarted?: string }>;
|
||||||
|
}) {
|
||||||
|
const { slug } = await params;
|
||||||
|
const { justCreated, justUpdated, restarted } = await searchParams;
|
||||||
|
|
||||||
|
const stacks = await listBusinessStacks();
|
||||||
|
const stack = stacks.find((s) => s.slug === slug);
|
||||||
|
if (!stack) notFound();
|
||||||
|
|
||||||
|
const installationView = await resolveInstallationView(slug, stack.domain);
|
||||||
|
// A job already running for this business (started in another tab, or before a page reload) — followed live below.
|
||||||
|
const activeLock = await getActiveLock(slug);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{justCreated && (
|
||||||
|
<div className="rounded-md border border-accent/30 bg-accent/10 px-4 py-3 text-sm text-accent">
|
||||||
|
Business created and started. Visit{" "}
|
||||||
|
<a href={`https://${stack.domain}/install`} target="_blank" rel="noreferrer" className="font-semibold underline">
|
||||||
|
https://{stack.domain}/install
|
||||||
|
</a>{" "}
|
||||||
|
to finish setting it up.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{justUpdated && (
|
||||||
|
<div className="rounded-md border border-accent/30 bg-accent/10 px-4 py-3 text-sm text-accent">
|
||||||
|
Changes saved{restarted === "1" ? " — the container was restarted to pick up the new domain." : "."}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold">{stack.name ?? stack.slug}</h1>
|
||||||
|
<p className="text-sm text-muted">{stack.domain ?? "no domain set"}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Link
|
||||||
|
href={`/dashboard/${stack.slug}/backups`}
|
||||||
|
className="rounded-md border border-line px-3 py-1.5 text-sm font-medium"
|
||||||
|
>
|
||||||
|
Backups
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href={`/dashboard/${stack.slug}/edit`}
|
||||||
|
className="rounded-md border border-line px-3 py-1.5 text-sm font-medium"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href={`/dashboard/${stack.slug}/delete`}
|
||||||
|
className="rounded-md border border-danger/30 px-3 py-1.5 text-sm font-medium text-danger"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<BusinessActions slug={stack.slug} activeJobId={activeLock?.jobId ?? null} />
|
||||||
|
|
||||||
|
<InstallationTokenPanel slug={stack.slug} view={installationView} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import type { JobRecord, StepStatus } from "@/lib/jobs";
|
||||||
|
|
||||||
|
const POLL_MS = 1500;
|
||||||
|
|
||||||
|
const STEP_GLYPH: Record<StepStatus, string> = {
|
||||||
|
pending: "○",
|
||||||
|
running: "◐",
|
||||||
|
done: "✓",
|
||||||
|
failed: "✕",
|
||||||
|
skipped: "–",
|
||||||
|
};
|
||||||
|
|
||||||
|
const STEP_COLOR: Record<StepStatus, string> = {
|
||||||
|
pending: "text-muted",
|
||||||
|
running: "text-accent",
|
||||||
|
done: "text-accent",
|
||||||
|
failed: "text-danger",
|
||||||
|
skipped: "text-muted",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live progress for a background job: polls GET /api/jobs/<id> every
|
||||||
|
* 1.5s until the job finishes, showing each step's state and the log
|
||||||
|
* tail. Used by Update, Backup, Restore and Delete.
|
||||||
|
*/
|
||||||
|
export function JobProgress({
|
||||||
|
jobId,
|
||||||
|
onFinished,
|
||||||
|
refreshOnFinish = true,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
jobId: string;
|
||||||
|
/** Re-render the surrounding server page when the job ends. Off for Delete, whose page 404s once the business is gone. */
|
||||||
|
refreshOnFinish?: boolean;
|
||||||
|
/** Called once, when the job reaches succeeded/failed. */
|
||||||
|
onFinished?: (job: JobRecord) => void;
|
||||||
|
/** Rendered under the steps once the job has finished successfully (e.g. a link onward). */
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [job, setJob] = useState<JobRecord | null>(null);
|
||||||
|
const [pollError, setPollError] = useState<string | null>(null);
|
||||||
|
const finishedRef = useRef(false);
|
||||||
|
const onFinishedRef = useRef(onFinished);
|
||||||
|
useEffect(() => {
|
||||||
|
onFinishedRef.current = onFinished;
|
||||||
|
}, [onFinished]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
finishedRef.current = false;
|
||||||
|
let cancelled = false;
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
|
async function poll() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/jobs/${encodeURIComponent(jobId)}`, { cache: "no-store" });
|
||||||
|
if (response.status === 404) {
|
||||||
|
if (!cancelled) setPollError("This job's record is gone (it may have been cleaned up).");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
const next = (await response.json()) as JobRecord;
|
||||||
|
if (cancelled) return;
|
||||||
|
setJob(next);
|
||||||
|
setPollError(null);
|
||||||
|
if (next.status !== "running") {
|
||||||
|
if (!finishedRef.current) {
|
||||||
|
finishedRef.current = true;
|
||||||
|
onFinishedRef.current?.(next);
|
||||||
|
if (refreshOnFinish) router.refresh();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// A restart of the Manager itself drops polls for a moment — keep trying.
|
||||||
|
if (!cancelled) setPollError(error instanceof Error ? error.message : "Couldn't reach the Manager.");
|
||||||
|
}
|
||||||
|
if (!cancelled) timer = setTimeout(poll, POLL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
void poll();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, [jobId, router, refreshOnFinish]);
|
||||||
|
|
||||||
|
if (!job) {
|
||||||
|
return <p className="text-sm text-muted">{pollError ?? "Starting…"}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3 rounded-md border border-line bg-surface p-4" aria-live="polite">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-sm font-semibold">{job.title}</h3>
|
||||||
|
<span
|
||||||
|
className={`rounded-full px-3 py-1 text-xs font-semibold ${
|
||||||
|
job.status === "running"
|
||||||
|
? "bg-stone text-muted"
|
||||||
|
: job.status === "succeeded"
|
||||||
|
? "bg-green-100 text-green-800"
|
||||||
|
: "bg-danger/10 text-danger"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{job.status === "running" ? "Running…" : job.status === "succeeded" ? "Done" : "Failed"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ol className="space-y-1.5">
|
||||||
|
{job.steps.map((step) => (
|
||||||
|
<li key={step.id} className="flex items-start gap-2 text-sm">
|
||||||
|
<span className={`w-4 shrink-0 text-center font-mono ${STEP_COLOR[step.status]}`} aria-hidden="true">
|
||||||
|
{STEP_GLYPH[step.status]}
|
||||||
|
</span>
|
||||||
|
<span className={step.status === "pending" || step.status === "skipped" ? "text-muted" : undefined}>
|
||||||
|
{step.label}
|
||||||
|
<span className="sr-only"> — {step.status}</span>
|
||||||
|
{step.detail && <span className="ml-2 text-xs text-muted">{step.detail}</span>}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
{job.error && (
|
||||||
|
<p role="alert" className="text-sm font-medium text-danger">
|
||||||
|
{job.error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{job.status === "succeeded" && job.result && (
|
||||||
|
<p className="text-xs text-muted">
|
||||||
|
{Object.entries(job.result)
|
||||||
|
.filter(([, value]) => value !== null && value !== "")
|
||||||
|
.map(([key, value]) => `${key}: ${String(value)}`)
|
||||||
|
.join(" · ")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{pollError && job.status === "running" && <p className="text-xs text-muted">Reconnecting… ({pollError})</p>}
|
||||||
|
|
||||||
|
{job.logTail.length > 0 && (
|
||||||
|
<details>
|
||||||
|
<summary className="cursor-pointer text-xs font-medium text-muted">Log</summary>
|
||||||
|
<pre className="mt-2 max-h-56 overflow-auto rounded-md bg-ink p-3 text-xs text-white">{job.logTail.join("\n")}</pre>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{job.status === "succeeded" && children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { logoutAction } from "./logout-action";
|
||||||
|
|
||||||
|
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen">
|
||||||
|
<header className="flex items-center justify-between border-b border-line bg-surface px-6 py-4">
|
||||||
|
<Link href="/dashboard" className="text-sm font-bold">
|
||||||
|
NextChair Manager
|
||||||
|
</Link>
|
||||||
|
<div className="flex items-center gap-4 text-sm">
|
||||||
|
<Link href="/dashboard/restore" className="text-muted hover:text-ink">
|
||||||
|
Restore
|
||||||
|
</Link>
|
||||||
|
<Link href="/dashboard/settings" className="text-muted hover:text-ink">
|
||||||
|
Settings
|
||||||
|
</Link>
|
||||||
|
<form action={logoutAction}>
|
||||||
|
<button type="submit" className="text-muted hover:text-ink">
|
||||||
|
Sign out
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main className="mx-auto max-w-4xl px-6 py-8">{children}</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { SESSION_COOKIE_NAME } from "@/lib/session";
|
||||||
|
|
||||||
|
export async function logoutAction(): Promise<void> {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
cookieStore.delete(SESSION_COOKIE_NAME);
|
||||||
|
redirect("/login");
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { provisionBusiness } from "@/lib/provisioning";
|
||||||
|
|
||||||
|
export interface AddBusinessState {
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addBusinessAction(_prevState: AddBusinessState, formData: FormData): Promise<AddBusinessState> {
|
||||||
|
const name = String(formData.get("name") ?? "");
|
||||||
|
const slug = String(formData.get("slug") ?? "");
|
||||||
|
const domain = String(formData.get("domain") ?? "");
|
||||||
|
|
||||||
|
let result: { slug: string };
|
||||||
|
try {
|
||||||
|
result = await provisionBusiness({ name, slug, domain });
|
||||||
|
} catch (error) {
|
||||||
|
return { error: error instanceof Error ? error.message : "Failed to provision this business." };
|
||||||
|
}
|
||||||
|
|
||||||
|
redirect(`/dashboard/${result.slug}?justCreated=1`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useActionState, useState } from "react";
|
||||||
|
import { suggestSlug } from "@/lib/slug";
|
||||||
|
import { addBusinessAction, type AddBusinessState } from "./actions";
|
||||||
|
|
||||||
|
const initialState: AddBusinessState = { error: null };
|
||||||
|
|
||||||
|
export function AddBusinessForm() {
|
||||||
|
const [state, formAction, isPending] = useActionState(addBusinessAction, initialState);
|
||||||
|
const [slug, setSlug] = useState("");
|
||||||
|
const [slugTouched, setSlugTouched] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={formAction} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="name" className="block text-sm font-medium text-muted">
|
||||||
|
Business name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="name"
|
||||||
|
name="name"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
onChange={(e) => {
|
||||||
|
if (!slugTouched) setSlug(suggestSlug(e.target.value));
|
||||||
|
}}
|
||||||
|
className="mt-1 w-full rounded-md border border-line px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="slug" className="block text-sm font-medium text-muted">
|
||||||
|
Slug
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="slug"
|
||||||
|
name="slug"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={slug}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSlugTouched(true);
|
||||||
|
setSlug(e.target.value);
|
||||||
|
}}
|
||||||
|
className="mt-1 w-full rounded-md border border-line px-3 py-2 font-mono text-sm"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-muted">
|
||||||
|
Used for the container name, database name, and stack directory — lowercase letters, numbers, and hyphens
|
||||||
|
only.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="domain" className="block text-sm font-medium text-muted">
|
||||||
|
Domain
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="domain"
|
||||||
|
name="domain"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
placeholder="business.example.com"
|
||||||
|
className="mt-1 w-full rounded-md border border-line px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-muted">
|
||||||
|
Must already point at this server’s IP before Caddy can issue a certificate for it.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{state.error && (
|
||||||
|
<p role="alert" className="text-sm font-medium text-danger">
|
||||||
|
{state.error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isPending}
|
||||||
|
className="w-full rounded-md bg-ink py-2 text-sm font-semibold text-white disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{isPending ? "Provisioning…" : "Create Business"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { AddBusinessForm } from "./add-business-form";
|
||||||
|
|
||||||
|
export default function NewBusinessPage() {
|
||||||
|
return (
|
||||||
|
<div className="max-w-lg space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold">Add Business</h1>
|
||||||
|
<p className="text-sm text-muted">
|
||||||
|
Creates the database, generates secrets, writes the compose stack and Caddy site, and starts the
|
||||||
|
container. Takes a few seconds — the button stays disabled until it’s done.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<AddBusinessForm />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { fetchInstallationStatus } from "@/lib/installation-status";
|
||||||
|
import { listBusinessStacks } from "@/lib/stacks";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
function StatusPill({ status }: { status: "running" | "stopped" | "unknown" }) {
|
||||||
|
const styles = {
|
||||||
|
running: "bg-accent/10 text-accent",
|
||||||
|
stopped: "bg-danger/10 text-danger",
|
||||||
|
unknown: "bg-line text-muted",
|
||||||
|
} as const;
|
||||||
|
return <span className={`rounded-full px-2 py-0.5 text-xs font-semibold ${styles[status]}`}>{status}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Only ever the two labels from the task's own example — the nuance
|
||||||
|
* between "confirmed not installed" and "container unreachable, can't
|
||||||
|
* confirm" (see installation-status.ts's three-state model) only matters
|
||||||
|
* on the business detail page where the token itself lives. Never shows
|
||||||
|
* the token — see resolveInstallationView, not used here.
|
||||||
|
*/
|
||||||
|
function InstallPill({ installed }: { installed: boolean }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`rounded-full px-2 py-0.5 text-xs font-semibold ${installed ? "bg-accent/10 text-accent" : "bg-amber-100 text-amber-800"}`}
|
||||||
|
>
|
||||||
|
{installed ? "Installed" : "Setup required"}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function DashboardPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ justDeleted?: string; warnings?: string }>;
|
||||||
|
}) {
|
||||||
|
const { justDeleted, warnings } = await searchParams;
|
||||||
|
const stacks = await listBusinessStacks();
|
||||||
|
|
||||||
|
// Each check is independently timeout-bounded (see
|
||||||
|
// installation-status.ts) so one stopped/dead business's container
|
||||||
|
// can't stall this whole page — resolved in parallel rather than one
|
||||||
|
// at a time.
|
||||||
|
const installStatuses = await Promise.all(stacks.map((stack) => fetchInstallationStatus(stack.slug)));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{justDeleted && (
|
||||||
|
<div className="rounded-md border border-line bg-surface px-4 py-3 text-sm">
|
||||||
|
<p className="font-medium">
|
||||||
|
<span className="font-mono">{justDeleted}</span> was deleted.
|
||||||
|
</p>
|
||||||
|
{warnings && <p className="mt-1 text-xs text-danger">Some cleanup steps need a manual look: {warnings}</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-xl font-bold">Businesses</h1>
|
||||||
|
<Link
|
||||||
|
href="/dashboard/new"
|
||||||
|
className="rounded-md bg-ink px-4 py-2 text-sm font-semibold text-white"
|
||||||
|
>
|
||||||
|
Add Business
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{stacks.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted">No businesses provisioned yet.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-line rounded-lg border border-line bg-surface">
|
||||||
|
{stacks.map((stack, index) => (
|
||||||
|
<li key={stack.slug}>
|
||||||
|
<Link
|
||||||
|
href={`/dashboard/${stack.slug}`}
|
||||||
|
className="flex items-center justify-between px-4 py-3 hover:bg-stone"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold">{stack.name ?? stack.slug}</p>
|
||||||
|
<p className="text-xs text-muted">{stack.domain ?? "no domain set"}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<InstallPill installed={installStatuses[index]?.status === "installed"} />
|
||||||
|
<StatusPill status={stack.status} />
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { BusinessLockedError } from "@/lib/locks";
|
||||||
|
import { startRestoreJob } from "@/lib/operations";
|
||||||
|
import { planRestore, readArchiveManifest, resolveArchivePath, type RestoreSource } from "@/lib/restore";
|
||||||
|
|
||||||
|
/** "backup:<slug>:<fileName>" for a stored archive, "upload:<id>" for one just uploaded. Parsed strictly; resolveArchivePath re-validates both forms. */
|
||||||
|
function parseSourceKey(key: string): RestoreSource {
|
||||||
|
if (key.startsWith("upload:")) return { kind: "upload", uploadId: key.slice("upload:".length) };
|
||||||
|
const match = /^backup:([^:]+):([^:]+)$/.exec(key);
|
||||||
|
if (!match) throw new Error("Choose a backup to restore.");
|
||||||
|
return { kind: "backup", slug: match[1]!, fileName: match[2]! };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RestorePreview {
|
||||||
|
slug: string;
|
||||||
|
name: string | null;
|
||||||
|
domain: string;
|
||||||
|
createdAt: string;
|
||||||
|
kind: string;
|
||||||
|
lastMigration: string | null;
|
||||||
|
slugExists: boolean;
|
||||||
|
domainConflict: string | null;
|
||||||
|
schemaProblem: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InspectRestoreState {
|
||||||
|
error: string | null;
|
||||||
|
preview: RestorePreview | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reads only the manifest (fast) and reports what restoring would do — nothing is changed. */
|
||||||
|
export async function inspectRestoreAction(sourceKey: string): Promise<InspectRestoreState> {
|
||||||
|
try {
|
||||||
|
const source = parseSourceKey(sourceKey);
|
||||||
|
const manifest = await readArchiveManifest(resolveArchivePath(source));
|
||||||
|
const plan = await planRestore(manifest);
|
||||||
|
return {
|
||||||
|
error: null,
|
||||||
|
preview: {
|
||||||
|
slug: manifest.slug,
|
||||||
|
name: manifest.name,
|
||||||
|
domain: manifest.domain,
|
||||||
|
createdAt: manifest.createdAt,
|
||||||
|
kind: manifest.kind,
|
||||||
|
lastMigration: manifest.lastMigration,
|
||||||
|
slugExists: plan.slugExists,
|
||||||
|
domainConflict: plan.domainConflict,
|
||||||
|
schemaProblem: plan.schema.ok ? null : plan.schema.reason,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
// 7zz fails with a generic message on a damaged or foreign file; say so plainly instead of passing its output through.
|
||||||
|
const message = error instanceof Error ? error.message : "";
|
||||||
|
const looksUnreadable = /wrong password|can not open|data error|headers error|exited with code 2/i.test(message);
|
||||||
|
return {
|
||||||
|
error: looksUnreadable
|
||||||
|
? "Couldn't open that file — it isn't a valid NextChair backup (backups made by an older version with a password can't be opened here)."
|
||||||
|
: message || "Couldn't read that backup.",
|
||||||
|
preview: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StartRestoreState {
|
||||||
|
error: string | null;
|
||||||
|
jobId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startRestoreAction(
|
||||||
|
sourceKey: string,
|
||||||
|
replace: boolean,
|
||||||
|
confirmSlug: string,
|
||||||
|
): Promise<StartRestoreState> {
|
||||||
|
const fail = (error: string): StartRestoreState => ({ error, jobId: null });
|
||||||
|
try {
|
||||||
|
const source = parseSourceKey(sourceKey);
|
||||||
|
const manifest = await readArchiveManifest(resolveArchivePath(source));
|
||||||
|
|
||||||
|
// Re-checked here, not trusted from the preview the browser holds.
|
||||||
|
const plan = await planRestore(manifest);
|
||||||
|
if (plan.domainConflict) return fail(plan.domainConflict);
|
||||||
|
if (!plan.schema.ok) return fail(plan.schema.reason);
|
||||||
|
if (plan.slugExists && (!replace || confirmSlug !== manifest.slug)) {
|
||||||
|
return fail(`"${manifest.slug}" already exists. Tick "replace" and type its slug to confirm overwriting it.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { jobId } = await startRestoreJob({ source, replace });
|
||||||
|
return { error: null, jobId };
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof BusinessLockedError) return fail(error.message);
|
||||||
|
return fail(error instanceof Error ? error.message : "Couldn't start the restore.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { listAllBackups } from "@/lib/backup-store";
|
||||||
|
import { RestoreWizard } from "./restore-wizard";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Restore" };
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function RestorePage({ searchParams }: { searchParams: Promise<{ slug?: string; file?: string }> }) {
|
||||||
|
const { slug, file } = await searchParams;
|
||||||
|
const backups = await listAllBackups();
|
||||||
|
const preselected = slug && file ? `backup:${slug}:${file}` : null;
|
||||||
|
const configuredMax = Number(process.env.BACKUP_UPLOAD_MAX_BYTES);
|
||||||
|
const maxUploadBytes = configuredMax > 0 ? configuredMax : 4 * 1024 * 1024 * 1024;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl space-y-6">
|
||||||
|
<div>
|
||||||
|
<Link href="/dashboard" className="text-sm text-muted hover:text-ink">
|
||||||
|
← Businesses
|
||||||
|
</Link>
|
||||||
|
<h1 className="mt-2 text-xl font-bold">Restore a business</h1>
|
||||||
|
<p className="text-sm text-muted">
|
||||||
|
Restores a backup onto the business it came from, or onto a fresh server where it never existed. Pick a stored
|
||||||
|
backup or upload a <span className="font-mono">.7z</span>, check what it contains, then confirm.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<RestoreWizard
|
||||||
|
backups={backups.map((b) => ({
|
||||||
|
key: `backup:${b.slug}:${b.fileName}`,
|
||||||
|
slug: b.slug,
|
||||||
|
fileName: b.fileName,
|
||||||
|
createdAt: b.createdAt,
|
||||||
|
kind: b.kind,
|
||||||
|
sizeBytes: b.sizeBytes,
|
||||||
|
}))}
|
||||||
|
preselected={preselected}
|
||||||
|
maxUploadBytes={maxUploadBytes}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRef, useState, useTransition } from "react";
|
||||||
|
import { JobProgress } from "../job-progress";
|
||||||
|
import { inspectRestoreAction, startRestoreAction, type RestorePreview } from "./actions";
|
||||||
|
|
||||||
|
interface BackupOption {
|
||||||
|
key: string;
|
||||||
|
slug: string;
|
||||||
|
fileName: string;
|
||||||
|
createdAt: string;
|
||||||
|
kind: string;
|
||||||
|
sizeBytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes: number): string {
|
||||||
|
if (bytes < 1024 * 1024) return `${Math.max(1, Math.round(bytes / 1024))} KB`;
|
||||||
|
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||||
|
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RestoreWizard({
|
||||||
|
backups,
|
||||||
|
preselected,
|
||||||
|
maxUploadBytes,
|
||||||
|
}: {
|
||||||
|
backups: BackupOption[];
|
||||||
|
preselected: string | null;
|
||||||
|
maxUploadBytes: number;
|
||||||
|
}) {
|
||||||
|
const [mode, setMode] = useState<"stored" | "upload">(backups.length === 0 ? "upload" : "stored");
|
||||||
|
const [selected, setSelected] = useState(preselected ?? backups[0]?.key ?? "");
|
||||||
|
const [uploadKey, setUploadKey] = useState<string | null>(null);
|
||||||
|
const [uploadProgress, setUploadProgress] = useState<number | null>(null);
|
||||||
|
const [uploadName, setUploadName] = useState<string | null>(null);
|
||||||
|
const [preview, setPreview] = useState<RestorePreview | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [replace, setReplace] = useState(false);
|
||||||
|
const [confirmSlug, setConfirmSlug] = useState("");
|
||||||
|
const [jobId, setJobId] = useState<string | null>(null);
|
||||||
|
const [restoredSlug, setRestoredSlug] = useState<string | null>(null);
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
const xhrRef = useRef<XMLHttpRequest | null>(null);
|
||||||
|
|
||||||
|
const sourceKey = mode === "upload" ? uploadKey : selected;
|
||||||
|
const resetPreview = () => {
|
||||||
|
setPreview(null);
|
||||||
|
setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
function upload(file: File) {
|
||||||
|
resetPreview();
|
||||||
|
setUploadKey(null);
|
||||||
|
if (file.size > maxUploadBytes) {
|
||||||
|
setError(`That file is ${formatBytes(file.size)}, over the ${formatBytes(maxUploadBytes)} upload limit. Copy it into the server's backups folder instead.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setUploadName(file.name);
|
||||||
|
setUploadProgress(0);
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
xhrRef.current = xhr;
|
||||||
|
xhr.open("POST", "/api/backups/upload");
|
||||||
|
xhr.setRequestHeader("Content-Type", "application/octet-stream");
|
||||||
|
xhr.upload.onprogress = (event) => {
|
||||||
|
if (event.lengthComputable) setUploadProgress(Math.round((event.loaded / event.total) * 100));
|
||||||
|
};
|
||||||
|
xhr.onload = () => {
|
||||||
|
setUploadProgress(null);
|
||||||
|
try {
|
||||||
|
const body = JSON.parse(xhr.responseText) as { uploadId?: string; error?: string };
|
||||||
|
if (xhr.status === 200 && body.uploadId) setUploadKey(`upload:${body.uploadId}`);
|
||||||
|
else setError(body.error ?? "Upload failed.");
|
||||||
|
} catch {
|
||||||
|
setError("Upload failed.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
xhr.onerror = () => {
|
||||||
|
setUploadProgress(null);
|
||||||
|
setError("Upload failed — the connection dropped.");
|
||||||
|
};
|
||||||
|
xhr.send(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
function inspect() {
|
||||||
|
if (!sourceKey) return;
|
||||||
|
resetPreview();
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await inspectRestoreAction(sourceKey);
|
||||||
|
if (result.error) setError(result.error);
|
||||||
|
setPreview(result.preview);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function start() {
|
||||||
|
if (!sourceKey || !preview) return;
|
||||||
|
setError(null);
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await startRestoreAction(sourceKey, replace, confirmSlug);
|
||||||
|
if (result.error) {
|
||||||
|
setError(result.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setRestoredSlug(preview.slug);
|
||||||
|
setJobId(result.jobId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const blocked = preview ? Boolean(preview.domainConflict || preview.schemaProblem) : false;
|
||||||
|
const replaceConfirmed = !preview?.slugExists || (replace && confirmSlug === preview.slug);
|
||||||
|
|
||||||
|
if (jobId) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<JobProgress jobId={jobId}>
|
||||||
|
{restoredSlug && (
|
||||||
|
<Link href={`/dashboard/${restoredSlug}`} className="inline-block rounded-md bg-ink px-4 py-2 text-sm font-semibold text-white">
|
||||||
|
Open {restoredSlug}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</JobProgress>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<fieldset className="space-y-3">
|
||||||
|
<legend className="text-sm font-semibold">1. Choose the backup</legend>
|
||||||
|
<div className="flex gap-4 text-sm">
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
checked={mode === "stored"}
|
||||||
|
onChange={() => {
|
||||||
|
setMode("stored");
|
||||||
|
resetPreview();
|
||||||
|
}}
|
||||||
|
disabled={backups.length === 0}
|
||||||
|
/>
|
||||||
|
Stored on this server
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
checked={mode === "upload"}
|
||||||
|
onChange={() => {
|
||||||
|
setMode("upload");
|
||||||
|
resetPreview();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
Upload a .7z
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mode === "stored" ? (
|
||||||
|
<select
|
||||||
|
value={selected}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSelected(e.target.value);
|
||||||
|
resetPreview();
|
||||||
|
}}
|
||||||
|
className="w-full rounded-md border border-line px-3 py-2 font-mono text-xs"
|
||||||
|
aria-label="Stored backup"
|
||||||
|
>
|
||||||
|
{backups.map((b) => (
|
||||||
|
<option key={b.key} value={b.key}>
|
||||||
|
{b.slug} — {new Date(b.createdAt).toLocaleString()} — {formatBytes(b.sizeBytes)} — {b.kind}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".7z"
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) upload(file);
|
||||||
|
}}
|
||||||
|
className="block w-full text-sm"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted">Up to {formatBytes(maxUploadBytes)}. Streamed to the server, not held in memory.</p>
|
||||||
|
{uploadProgress !== null && (
|
||||||
|
<div className="h-2 w-full overflow-hidden rounded bg-stone" role="progressbar" aria-valuenow={uploadProgress} aria-valuemin={0} aria-valuemax={100}>
|
||||||
|
<div className="h-full bg-accent" style={{ width: `${uploadProgress}%` }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{uploadKey && <p className="text-xs text-green-700">Uploaded {uploadName}.</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={inspect}
|
||||||
|
disabled={isPending || !sourceKey}
|
||||||
|
className="rounded-md bg-ink px-4 py-2 text-sm font-semibold text-white disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{isPending && !preview ? "Checking…" : "2. Check this backup"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="text-sm font-medium text-danger">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{preview && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h2 className="text-sm font-semibold">3. Review and confirm</h2>
|
||||||
|
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 rounded-md border border-line bg-surface p-4 text-sm">
|
||||||
|
<dt className="text-muted">Business</dt>
|
||||||
|
<dd>{preview.name ?? preview.slug}</dd>
|
||||||
|
<dt className="text-muted">Slug</dt>
|
||||||
|
<dd className="font-mono">{preview.slug}</dd>
|
||||||
|
<dt className="text-muted">Domain</dt>
|
||||||
|
<dd className="font-mono">{preview.domain}</dd>
|
||||||
|
<dt className="text-muted">Backup taken</dt>
|
||||||
|
<dd>{new Date(preview.createdAt).toLocaleString()}</dd>
|
||||||
|
<dt className="text-muted">Schema</dt>
|
||||||
|
<dd className="font-mono text-xs">{preview.lastMigration ?? "unknown"}</dd>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
{preview.domainConflict && (
|
||||||
|
<p role="alert" className="text-sm font-medium text-danger">
|
||||||
|
{preview.domainConflict}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{preview.schemaProblem && (
|
||||||
|
<p role="alert" className="text-sm font-medium text-danger">
|
||||||
|
{preview.schemaProblem}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{preview.slugExists ? (
|
||||||
|
<div className="space-y-2 rounded-md border border-danger/30 bg-danger/5 p-4 text-sm">
|
||||||
|
<p className="font-semibold text-danger">
|
||||||
|
“{preview.slug}” already exists on this server. Restoring REPLACES its database, media and configuration.
|
||||||
|
</p>
|
||||||
|
<p className="text-muted">A safety backup of the current state is taken first.</p>
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input type="checkbox" checked={replace} onChange={(e) => setReplace(e.target.checked)} />
|
||||||
|
Replace the existing business
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={confirmSlug}
|
||||||
|
onChange={(e) => setConfirmSlug(e.target.value)}
|
||||||
|
placeholder={`Type ${preview.slug} to confirm`}
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
className="w-full rounded-md border border-line px-3 py-2 font-mono text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted">No business named “{preview.slug}” exists here — it will be created from the backup.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={start}
|
||||||
|
disabled={isPending || blocked || !replaceConfirmed}
|
||||||
|
className="rounded-md bg-danger px-4 py-2 text-sm font-semibold text-white disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{isPending ? "Starting…" : preview.slugExists ? "Replace and restore" : "Restore"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,425 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { applyEmailSettingsToAllBusinesses, type EmailRolloutResult } from "@/lib/email-rollout";
|
||||||
|
import { applyGoogleCalendarSettingsToAllBusinesses, type GoogleCalendarRolloutResult } from "@/lib/google-calendar-rollout";
|
||||||
|
import { applyOperatorAlertEmailToAllBusinesses, type OperatorRolloutResult } from "@/lib/operator-rollout";
|
||||||
|
import { applyPushSettingsToAllBusinesses, type PushRolloutResult } from "@/lib/push-rollout";
|
||||||
|
import {
|
||||||
|
generateVapidKeys,
|
||||||
|
isEmailConfigComplete,
|
||||||
|
isGoogleCalendarConfigComplete,
|
||||||
|
isPushConfigComplete,
|
||||||
|
isSuperAdminConfigComplete,
|
||||||
|
readSuperAdminSettings,
|
||||||
|
recordLastTestResult,
|
||||||
|
resolveEmailSettings,
|
||||||
|
resolveGoogleCalendarSettings,
|
||||||
|
resolvePushSettings,
|
||||||
|
saveEmailSettings,
|
||||||
|
saveGoogleCalendarSettings,
|
||||||
|
readOperatorSettings,
|
||||||
|
saveOperatorSettings,
|
||||||
|
savePushSubject,
|
||||||
|
saveSuperAdminSettings,
|
||||||
|
VapidKeysAlreadyExistError,
|
||||||
|
type EmailEncryptionMode,
|
||||||
|
type EmailSettingsInput,
|
||||||
|
type GoogleCalendarSettingsInput,
|
||||||
|
} from "@/lib/global-settings";
|
||||||
|
import { sendOperatorEmail } from "@/lib/operator-email";
|
||||||
|
import { applySuperAdminToAllBusinesses, type SuperAdminRolloutResult } from "@/lib/super-admin-rollout";
|
||||||
|
import { sendTestSmtpEmail } from "@/lib/test-email";
|
||||||
|
|
||||||
|
function parseEncryption(value: FormDataEntryValue | null): EmailEncryptionMode {
|
||||||
|
return value === "none" || value === "tls" ? value : "starttls";
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseEmailSettingsInput(formData: FormData): EmailSettingsInput {
|
||||||
|
return {
|
||||||
|
host: String(formData.get("host") ?? "").trim(),
|
||||||
|
port: Number(formData.get("port") ?? 587) || 587,
|
||||||
|
username: String(formData.get("username") ?? "").trim(),
|
||||||
|
password: String(formData.get("password") ?? ""),
|
||||||
|
encryption: parseEncryption(formData.get("encryption")),
|
||||||
|
fromAddress: String(formData.get("fromAddress") ?? "").trim(),
|
||||||
|
fromName: String(formData.get("fromName") ?? "").trim(),
|
||||||
|
replyTo: String(formData.get("replyTo") ?? "").trim(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SaveEmailSettingsState {
|
||||||
|
error: string | null;
|
||||||
|
saved: boolean;
|
||||||
|
/** null = settings were saved but not rolled out (incomplete config — see isEmailConfigComplete); an array (possibly empty, if there are no businesses yet) means a rollout was attempted. */
|
||||||
|
rollout: EmailRolloutResult[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveEmailSettingsAction(
|
||||||
|
_prevState: SaveEmailSettingsState,
|
||||||
|
formData: FormData,
|
||||||
|
): Promise<SaveEmailSettingsState> {
|
||||||
|
const input = parseEmailSettingsInput(formData);
|
||||||
|
if (!input.host || !input.username || !input.fromAddress) {
|
||||||
|
return { error: "SMTP host, username, and from address are required.", saved: false, rollout: null };
|
||||||
|
}
|
||||||
|
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(input.fromAddress)) {
|
||||||
|
return { error: "Enter a valid from address.", saved: false, rollout: null };
|
||||||
|
}
|
||||||
|
if (input.replyTo && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(input.replyTo)) {
|
||||||
|
return { error: "Enter a valid reply-to address, or leave it blank.", saved: false, rollout: null };
|
||||||
|
}
|
||||||
|
if (!Number.isInteger(input.port) || input.port < 1 || input.port > 65535) {
|
||||||
|
return { error: "Enter a valid SMTP port.", saved: false, rollout: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
let resolved;
|
||||||
|
try {
|
||||||
|
resolved = await saveEmailSettings(input);
|
||||||
|
} catch (error) {
|
||||||
|
return { error: error instanceof Error ? error.message : "Couldn't save settings.", saved: false, rollout: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isEmailConfigComplete(resolved)) {
|
||||||
|
// Saved as a draft — no password configured yet (or none was ever
|
||||||
|
// set), so nothing is rolled out. A business left on "console" mode
|
||||||
|
// is safe; a business flipped to "brevo" with a blank password would
|
||||||
|
// hard-fail its very first send.
|
||||||
|
return { error: null, saved: true, rollout: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const rollout = await applyEmailSettingsToAllBusinesses(resolved);
|
||||||
|
return { error: null, saved: true, rollout };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReapplyEmailSettingsState {
|
||||||
|
error: string | null;
|
||||||
|
rollout: EmailRolloutResult[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Retries the rollout without requiring a field edit first — e.g. after a business's container was down during the last save. */
|
||||||
|
export async function reapplyEmailSettingsAction(
|
||||||
|
_prevState: ReapplyEmailSettingsState,
|
||||||
|
_formData: FormData,
|
||||||
|
): Promise<ReapplyEmailSettingsState> {
|
||||||
|
const resolved = await resolveEmailSettings();
|
||||||
|
if (!resolved || !isEmailConfigComplete(resolved)) {
|
||||||
|
return { error: "Email settings aren't fully configured yet — nothing to reapply.", rollout: null };
|
||||||
|
}
|
||||||
|
const rollout = await applyEmailSettingsToAllBusinesses(resolved);
|
||||||
|
return { error: null, rollout };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SendTestEmailState {
|
||||||
|
error: string | null;
|
||||||
|
result: { ok: boolean; message: string } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests with whatever is currently in the form — including an unsaved
|
||||||
|
* password change — falling back to the already-stored password only
|
||||||
|
* when the password field was left blank, the same "keep existing
|
||||||
|
* password" rule saveEmailSettingsAction follows. This lets an operator
|
||||||
|
* validate connectivity before committing to Save.
|
||||||
|
*/
|
||||||
|
export async function sendTestEmailAction(
|
||||||
|
_prevState: SendTestEmailState,
|
||||||
|
formData: FormData,
|
||||||
|
): Promise<SendTestEmailState> {
|
||||||
|
const recipient = String(formData.get("recipient") ?? "").trim();
|
||||||
|
if (!recipient || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(recipient)) {
|
||||||
|
return { error: "Enter a valid test recipient address.", result: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const input = parseEmailSettingsInput(formData);
|
||||||
|
if (!input.host || !input.username || !input.fromAddress) {
|
||||||
|
return { error: "SMTP host, username, and from address are required before sending a test.", result: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
let password = (input.password ?? "").trim();
|
||||||
|
if (!password) {
|
||||||
|
const stored = await resolveEmailSettings();
|
||||||
|
password = stored?.password ?? "";
|
||||||
|
}
|
||||||
|
if (!password) {
|
||||||
|
return { error: "Enter the SMTP password (or save it first) before sending a test.", result: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await sendTestSmtpEmail({
|
||||||
|
host: input.host,
|
||||||
|
port: input.port,
|
||||||
|
username: input.username,
|
||||||
|
password,
|
||||||
|
encryption: input.encryption,
|
||||||
|
fromAddress: input.fromAddress,
|
||||||
|
fromName: input.fromName,
|
||||||
|
replyTo: input.replyTo,
|
||||||
|
recipient,
|
||||||
|
});
|
||||||
|
|
||||||
|
await recordLastTestResult({ ok: result.ok, message: result.message, testedAt: new Date().toISOString() });
|
||||||
|
|
||||||
|
return { error: null, result };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Push / VAPID ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface GenerateVapidKeysState {
|
||||||
|
error: string | null;
|
||||||
|
needsConfirmation: boolean;
|
||||||
|
generated: boolean;
|
||||||
|
rollout: PushRolloutResult[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a brand-new VAPID key pair. If one already exists, the first
|
||||||
|
* call (no `confirmOverwrite` field in the form) is rejected with
|
||||||
|
* `needsConfirmation: true` instead of an error — the form re-renders a
|
||||||
|
* warning + a second, explicit "Yes, generate new keys" button (which
|
||||||
|
* submits again with `confirmOverwrite=true`) rather than ever
|
||||||
|
* regenerating silently. See global-settings.ts's generateVapidKeys for
|
||||||
|
* why keys are generate-only (no hand-pasted key fields exist).
|
||||||
|
*/
|
||||||
|
export async function generateVapidKeysAction(
|
||||||
|
_prevState: GenerateVapidKeysState,
|
||||||
|
formData: FormData,
|
||||||
|
): Promise<GenerateVapidKeysState> {
|
||||||
|
const subject = String(formData.get("subject") ?? "").trim();
|
||||||
|
const confirmOverwrite = formData.get("confirmOverwrite") === "true";
|
||||||
|
|
||||||
|
if (subject && !/^mailto:[^\s@]+@[^\s@]+\.[^\s@]+$/.test(subject)) {
|
||||||
|
return { error: "Enter a valid contact address, e.g. mailto:support@example.com.", needsConfirmation: false, generated: false, rollout: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
let resolved;
|
||||||
|
try {
|
||||||
|
resolved = await generateVapidKeys({ subject, confirmOverwrite });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof VapidKeysAlreadyExistError) {
|
||||||
|
return { error: null, needsConfirmation: true, generated: false, rollout: null };
|
||||||
|
}
|
||||||
|
return { error: error instanceof Error ? error.message : "Couldn't generate VAPID keys.", needsConfirmation: false, generated: false, rollout: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isPushConfigComplete(resolved)) {
|
||||||
|
// No subject yet — saved as a draft, nothing rolled out (mirrors
|
||||||
|
// saveEmailSettingsAction's incomplete-config behavior).
|
||||||
|
return { error: null, needsConfirmation: false, generated: true, rollout: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const rollout = await applyPushSettingsToAllBusinesses(resolved);
|
||||||
|
return { error: null, needsConfirmation: false, generated: true, rollout };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SavePushSubjectState {
|
||||||
|
error: string | null;
|
||||||
|
saved: boolean;
|
||||||
|
rollout: PushRolloutResult[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function savePushSubjectAction(
|
||||||
|
_prevState: SavePushSubjectState,
|
||||||
|
formData: FormData,
|
||||||
|
): Promise<SavePushSubjectState> {
|
||||||
|
const subject = String(formData.get("subject") ?? "").trim();
|
||||||
|
if (!/^mailto:[^\s@]+@[^\s@]+\.[^\s@]+$/.test(subject)) {
|
||||||
|
return { error: "Enter a valid contact address, e.g. mailto:support@example.com.", saved: false, rollout: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolved = await savePushSubject(subject);
|
||||||
|
if (!resolved || !isPushConfigComplete(resolved)) {
|
||||||
|
// No key pair generated yet — the subject is saved for next time, but there's nothing to roll out.
|
||||||
|
return { error: null, saved: true, rollout: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const rollout = await applyPushSettingsToAllBusinesses(resolved);
|
||||||
|
return { error: null, saved: true, rollout };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReapplyPushSettingsState {
|
||||||
|
error: string | null;
|
||||||
|
rollout: PushRolloutResult[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reapplyPushSettingsAction(
|
||||||
|
_prevState: ReapplyPushSettingsState,
|
||||||
|
_formData: FormData,
|
||||||
|
): Promise<ReapplyPushSettingsState> {
|
||||||
|
const resolved = await resolvePushSettings();
|
||||||
|
if (!resolved || !isPushConfigComplete(resolved)) {
|
||||||
|
return { error: "Push settings aren't fully configured yet — nothing to reapply.", rollout: null };
|
||||||
|
}
|
||||||
|
const rollout = await applyPushSettingsToAllBusinesses(resolved);
|
||||||
|
return { error: null, rollout };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Super Administrator ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface SaveSuperAdminState {
|
||||||
|
error: string | null;
|
||||||
|
saved: boolean;
|
||||||
|
rollout: SuperAdminRolloutResult[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves the global default Super Administrator and immediately rolls it
|
||||||
|
* out to every existing business — unlike email/push, there's no
|
||||||
|
* "incomplete config, save as a draft" case worth distinguishing (an
|
||||||
|
* email is either present or it isn't, no secondary field like a
|
||||||
|
* password gates completeness), so this always rolls out once the email
|
||||||
|
* itself validates. New businesses pick this up automatically at
|
||||||
|
* provisioning time (see provisioning.ts) — this action is what applies
|
||||||
|
* a change to businesses that already exist.
|
||||||
|
*/
|
||||||
|
export async function saveSuperAdminSettingsAction(
|
||||||
|
_prevState: SaveSuperAdminState,
|
||||||
|
formData: FormData,
|
||||||
|
): Promise<SaveSuperAdminState> {
|
||||||
|
const email = String(formData.get("email") ?? "").trim();
|
||||||
|
const firstName = String(formData.get("firstName") ?? "").trim();
|
||||||
|
const lastName = String(formData.get("lastName") ?? "").trim();
|
||||||
|
|
||||||
|
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||||
|
return { error: "Enter a valid email address.", saved: false, rollout: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const saved = await saveSuperAdminSettings({ email, firstName, lastName });
|
||||||
|
if (!isSuperAdminConfigComplete(saved)) {
|
||||||
|
return { error: null, saved: true, rollout: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const rollout = await applySuperAdminToAllBusinesses(saved);
|
||||||
|
return { error: null, saved: true, rollout };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReapplySuperAdminState {
|
||||||
|
error: string | null;
|
||||||
|
rollout: SuperAdminRolloutResult[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reapplySuperAdminAction(
|
||||||
|
_prevState: ReapplySuperAdminState,
|
||||||
|
_formData: FormData,
|
||||||
|
): Promise<ReapplySuperAdminState> {
|
||||||
|
const saved = await readSuperAdminSettings();
|
||||||
|
if (!isSuperAdminConfigComplete(saved)) {
|
||||||
|
return { error: "No Super Administrator email is saved yet — nothing to reapply.", rollout: null };
|
||||||
|
}
|
||||||
|
const rollout = await applySuperAdminToAllBusinesses(saved);
|
||||||
|
return { error: null, rollout };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Google Calendar ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function parseGoogleCalendarSettingsInput(formData: FormData): GoogleCalendarSettingsInput {
|
||||||
|
return {
|
||||||
|
clientId: String(formData.get("clientId") ?? "").trim(),
|
||||||
|
clientSecret: String(formData.get("clientSecret") ?? ""),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SaveGoogleCalendarSettingsState {
|
||||||
|
error: string | null;
|
||||||
|
saved: boolean;
|
||||||
|
/** null = settings were saved but not rolled out (incomplete config — see isGoogleCalendarConfigComplete); an array (possibly empty) means a rollout was attempted. */
|
||||||
|
rollout: GoogleCalendarRolloutResult[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveGoogleCalendarSettingsAction(
|
||||||
|
_prevState: SaveGoogleCalendarSettingsState,
|
||||||
|
formData: FormData,
|
||||||
|
): Promise<SaveGoogleCalendarSettingsState> {
|
||||||
|
const input = parseGoogleCalendarSettingsInput(formData);
|
||||||
|
if (!input.clientId) {
|
||||||
|
return { error: "Client ID is required.", saved: false, rollout: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolved = await saveGoogleCalendarSettings(input);
|
||||||
|
if (!isGoogleCalendarConfigComplete(resolved)) {
|
||||||
|
// Saved as a draft — no client secret configured yet, so nothing is
|
||||||
|
// rolled out (mirrors saveEmailSettingsAction's incomplete-config
|
||||||
|
// behavior: a business left with a blank secret would hard-fail the
|
||||||
|
// moment a staff member tries to connect their calendar).
|
||||||
|
return { error: null, saved: true, rollout: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const rollout = await applyGoogleCalendarSettingsToAllBusinesses(resolved);
|
||||||
|
return { error: null, saved: true, rollout };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReapplyGoogleCalendarSettingsState {
|
||||||
|
error: string | null;
|
||||||
|
rollout: GoogleCalendarRolloutResult[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Retries the rollout without requiring a field edit first — e.g. after a business's container was down during the last save, or to backfill a newly-added business's CALENDAR_TOKEN_ENCRYPTION_KEY. */
|
||||||
|
export async function reapplyGoogleCalendarSettingsAction(
|
||||||
|
_prevState: ReapplyGoogleCalendarSettingsState,
|
||||||
|
_formData: FormData,
|
||||||
|
): Promise<ReapplyGoogleCalendarSettingsState> {
|
||||||
|
const resolved = await resolveGoogleCalendarSettings();
|
||||||
|
if (!resolved || !isGoogleCalendarConfigComplete(resolved)) {
|
||||||
|
return { error: "Google Calendar settings aren't fully configured yet — nothing to reapply.", rollout: null };
|
||||||
|
}
|
||||||
|
const rollout = await applyGoogleCalendarSettingsToAllBusinesses(resolved);
|
||||||
|
return { error: null, rollout };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Monitoring & scheduler ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface SaveOperatorSettingsState {
|
||||||
|
error: string | null;
|
||||||
|
saved: boolean;
|
||||||
|
/** Set when the alert address changed and was pushed to the businesses (each restarts to pick it up); null when nothing needed rolling out. */
|
||||||
|
rollout?: OperatorRolloutResult[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveOperatorSettingsAction(
|
||||||
|
_prevState: SaveOperatorSettingsState,
|
||||||
|
formData: FormData,
|
||||||
|
): Promise<SaveOperatorSettingsState> {
|
||||||
|
const alertEmail = String(formData.get("alertEmail") ?? "").trim();
|
||||||
|
if (alertEmail && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(alertEmail)) {
|
||||||
|
return { error: "Enter a valid alert email address, or leave it blank to turn alerts off.", saved: false };
|
||||||
|
}
|
||||||
|
const notificationsIntervalSeconds = Number(formData.get("notificationsIntervalSeconds"));
|
||||||
|
const healthIntervalMinutes = Number(formData.get("healthIntervalMinutes"));
|
||||||
|
if (!Number.isInteger(notificationsIntervalSeconds) || notificationsIntervalSeconds < 10 || notificationsIntervalSeconds > 3600) {
|
||||||
|
return { error: "Notification interval must be between 10 and 3600 seconds.", saved: false };
|
||||||
|
}
|
||||||
|
if (!Number.isInteger(healthIntervalMinutes) || healthIntervalMinutes < 1 || healthIntervalMinutes > 60) {
|
||||||
|
return { error: "Health check interval must be between 1 and 60 minutes.", saved: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
let previousAlertEmail = "";
|
||||||
|
try {
|
||||||
|
previousAlertEmail = (await readOperatorSettings()).alertEmail;
|
||||||
|
await saveOperatorSettings({
|
||||||
|
alertEmail,
|
||||||
|
alertOnHealth: formData.get("alertOnHealth") === "on",
|
||||||
|
alertOnRecovery: formData.get("alertOnRecovery") === "on",
|
||||||
|
dailyReminder: formData.get("dailyReminder") === "on",
|
||||||
|
alertOnBackupFailure: formData.get("alertOnBackupFailure") === "on",
|
||||||
|
notificationsIntervalSeconds,
|
||||||
|
healthIntervalMinutes,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return { error: error instanceof Error ? error.message : "Couldn't save settings.", saved: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Businesses read the alert address from their own .env, so a changed address has to be pushed out
|
||||||
|
// (and each restarted once). An unchanged one isn't touched at all.
|
||||||
|
let rollout: OperatorRolloutResult[] | null = null;
|
||||||
|
if (alertEmail !== previousAlertEmail) rollout = await applyOperatorAlertEmailToAllBusinesses(alertEmail);
|
||||||
|
|
||||||
|
return { error: null, saved: true, rollout };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SendTestAlertState {
|
||||||
|
result: { ok: boolean; message: string } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sends a test alert to the SAVED alert address — so it also proves the Email settings work for alerts. */
|
||||||
|
export async function sendTestAlertAction(_prevState: SendTestAlertState, _formData: FormData): Promise<SendTestAlertState> {
|
||||||
|
const result = await sendOperatorEmail("Test alert", "This is a test alert from your NextChair Manager. If you received it, health and backup alerts will reach you.");
|
||||||
|
return { result };
|
||||||
|
}
|
||||||
@@ -0,0 +1,320 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useActionState, useEffect, useState } from "react";
|
||||||
|
import type { EmailEncryptionMode, EmailSettingsView } from "@/lib/global-settings";
|
||||||
|
import {
|
||||||
|
reapplyEmailSettingsAction,
|
||||||
|
saveEmailSettingsAction,
|
||||||
|
sendTestEmailAction,
|
||||||
|
type ReapplyEmailSettingsState,
|
||||||
|
type SaveEmailSettingsState,
|
||||||
|
type SendTestEmailState,
|
||||||
|
} from "./actions";
|
||||||
|
|
||||||
|
const saveInitialState: SaveEmailSettingsState = { error: null, saved: false, rollout: null };
|
||||||
|
const testInitialState: SendTestEmailState = { error: null, result: null };
|
||||||
|
const reapplyInitialState: ReapplyEmailSettingsState = { error: null, rollout: null };
|
||||||
|
|
||||||
|
function StatusBadge({ initial, saveState, testState }: { initial: EmailSettingsView; saveState: SaveEmailSettingsState; testState: SendTestEmailState }) {
|
||||||
|
// Most-recent-action-wins: a test result from this session takes
|
||||||
|
// priority over the persisted status, which itself takes priority over
|
||||||
|
// "not configured".
|
||||||
|
if (testState.result) {
|
||||||
|
return testState.result.ok ? (
|
||||||
|
<span className="rounded-full bg-green-100 px-3 py-1 text-xs font-semibold text-green-800">Test successful</span>
|
||||||
|
) : (
|
||||||
|
<span className="rounded-full bg-red-100 px-3 py-1 text-xs font-semibold text-red-800">Test failed</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (saveState.saved && saveState.rollout) {
|
||||||
|
const allOk = saveState.rollout.every((r) => r.ok);
|
||||||
|
return allOk ? (
|
||||||
|
<span className="rounded-full bg-green-100 px-3 py-1 text-xs font-semibold text-green-800">Configured</span>
|
||||||
|
) : (
|
||||||
|
<span className="rounded-full bg-amber-100 px-3 py-1 text-xs font-semibold text-amber-800">Configured (some businesses failed)</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (initial.lastTest) {
|
||||||
|
return initial.lastTest.ok ? (
|
||||||
|
<span className="rounded-full bg-green-100 px-3 py-1 text-xs font-semibold text-green-800">Test successful</span>
|
||||||
|
) : (
|
||||||
|
<span className="rounded-full bg-red-100 px-3 py-1 text-xs font-semibold text-red-800">Test failed</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (initial.hasPassword && initial.host && initial.fromAddress) {
|
||||||
|
return <span className="rounded-full bg-green-100 px-3 py-1 text-xs font-semibold text-green-800">Configured</span>;
|
||||||
|
}
|
||||||
|
return <span className="rounded-full bg-stone px-3 py-1 text-xs font-semibold text-muted">Not configured</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every field is a controlled input backed by useState here, deliberately
|
||||||
|
* NOT `defaultValue`-based uncontrolled inputs (the pattern
|
||||||
|
* edit-business-form.tsx uses for its simpler 2-field/1-action form).
|
||||||
|
* This form has three separate actions (Save / Test / Reapply) sharing
|
||||||
|
* one set of fields — after any one of them resolves, Next.js re-renders
|
||||||
|
* this component with the same `initial` server prop it always had,
|
||||||
|
* which would silently reset every uncontrolled field back to its
|
||||||
|
* original value (losing whatever the operator was mid-typing, e.g. a
|
||||||
|
* new password, right after clicking "Send Test Email" but before
|
||||||
|
* clicking "Save"). Controlled state survives that re-render untouched.
|
||||||
|
*/
|
||||||
|
export function EmailSettingsForm({ initial }: { initial: EmailSettingsView }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [saveState, saveAction, isSaving] = useActionState(saveEmailSettingsAction, saveInitialState);
|
||||||
|
const [testState, testAction, isTesting] = useActionState(sendTestEmailAction, testInitialState);
|
||||||
|
const [reapplyState, reapplyAction, isReapplying] = useActionState(reapplyEmailSettingsAction, reapplyInitialState);
|
||||||
|
|
||||||
|
// `initial` is a server-fetched snapshot from page load — after a
|
||||||
|
// successful save, refresh it so fields that reflect *stored* state
|
||||||
|
// (hasPassword's placeholder text, the Reapply button's disabled state)
|
||||||
|
// don't keep showing the pre-save snapshot even though the save
|
||||||
|
// genuinely succeeded (confirmed live: without this, "Reapply to all
|
||||||
|
// businesses" stayed disabled and the password hint still said "No
|
||||||
|
// password saved yet." right after a successful first save).
|
||||||
|
useEffect(() => {
|
||||||
|
if (saveState.saved) router.refresh();
|
||||||
|
}, [saveState, router]);
|
||||||
|
|
||||||
|
const [host, setHost] = useState(initial.host);
|
||||||
|
const [port, setPort] = useState(initial.port);
|
||||||
|
const [username, setUsername] = useState(initial.username);
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [encryption, setEncryption] = useState<EmailEncryptionMode>(initial.encryption);
|
||||||
|
const [fromAddress, setFromAddress] = useState(initial.fromAddress);
|
||||||
|
const [fromName, setFromName] = useState(initial.fromName);
|
||||||
|
const [replyTo, setReplyTo] = useState(initial.replyTo);
|
||||||
|
const [recipient, setRecipient] = useState("");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-semibold">Email / SMTP</h2>
|
||||||
|
<StatusBadge initial={initial} saveState={saveState} testState={testState} />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted">
|
||||||
|
Shared by every business on this installation — used to send OTP verification codes and other transactional
|
||||||
|
email. Configuring it here means no business needs its own SMTP setup.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form className="space-y-4 rounded-md border border-line bg-surface p-5">
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="host" className="block text-sm font-medium text-muted">
|
||||||
|
SMTP host
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="host"
|
||||||
|
name="host"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={host}
|
||||||
|
onChange={(e) => setHost(e.target.value)}
|
||||||
|
placeholder="smtp-relay.brevo.com"
|
||||||
|
className="mt-1 w-full rounded-md border border-line px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="port" className="block text-sm font-medium text-muted">
|
||||||
|
SMTP port
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="port"
|
||||||
|
name="port"
|
||||||
|
type="number"
|
||||||
|
required
|
||||||
|
min={1}
|
||||||
|
max={65535}
|
||||||
|
value={port}
|
||||||
|
onChange={(e) => setPort(Number(e.target.value))}
|
||||||
|
className="mt-1 w-full rounded-md border border-line px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="username" className="block text-sm font-medium text-muted">
|
||||||
|
SMTP username
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="username"
|
||||||
|
name="username"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
className="mt-1 w-full rounded-md border border-line px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="password" className="block text-sm font-medium text-muted">
|
||||||
|
SMTP password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder={initial.hasPassword ? "•••••••••••• (leave blank to keep)" : "Not set"}
|
||||||
|
className="mt-1 w-full rounded-md border border-line px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-muted">
|
||||||
|
{initial.hasPassword ? "A password is already saved — leave this blank to keep it." : "No password saved yet."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="encryption" className="block text-sm font-medium text-muted">
|
||||||
|
Encryption
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="encryption"
|
||||||
|
name="encryption"
|
||||||
|
value={encryption}
|
||||||
|
onChange={(e) => setEncryption(e.target.value as EmailEncryptionMode)}
|
||||||
|
className="mt-1 w-full rounded-md border border-line bg-white px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<option value="none">None</option>
|
||||||
|
<option value="starttls">STARTTLS</option>
|
||||||
|
<option value="tls">TLS/SSL</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="fromAddress" className="block text-sm font-medium text-muted">
|
||||||
|
From email address
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="fromAddress"
|
||||||
|
name="fromAddress"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
value={fromAddress}
|
||||||
|
onChange={(e) => setFromAddress(e.target.value)}
|
||||||
|
className="mt-1 w-full rounded-md border border-line px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="fromName" className="block text-sm font-medium text-muted">
|
||||||
|
From name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="fromName"
|
||||||
|
name="fromName"
|
||||||
|
type="text"
|
||||||
|
value={fromName}
|
||||||
|
onChange={(e) => setFromName(e.target.value)}
|
||||||
|
placeholder="NextChair"
|
||||||
|
className="mt-1 w-full rounded-md border border-line px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-muted">Only used when a business's own name can't be resolved (e.g. before install).</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="replyTo" className="block text-sm font-medium text-muted">
|
||||||
|
Default reply-to address (optional)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="replyTo"
|
||||||
|
name="replyTo"
|
||||||
|
type="email"
|
||||||
|
value={replyTo}
|
||||||
|
onChange={(e) => setReplyTo(e.target.value)}
|
||||||
|
className="mt-1 w-full rounded-md border border-line px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-muted">
|
||||||
|
Unlike the settings above, this isn't forced onto every business — it's only used as a
|
||||||
|
fallback for a business that hasn't set its own reply-to address in its own .env.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{saveState.error && (
|
||||||
|
<p role="alert" className="text-sm font-medium text-danger">
|
||||||
|
{saveState.error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{saveState.saved && !saveState.error && (
|
||||||
|
<div className="text-sm text-green-700">
|
||||||
|
{saveState.rollout === null ? (
|
||||||
|
<p>Settings saved as a draft. Add a password to enable delivery and roll it out to businesses.</p>
|
||||||
|
) : (
|
||||||
|
<p>
|
||||||
|
Settings saved and applied to {saveState.rollout.filter((r) => r.ok).length} of {saveState.rollout.length}{" "}
|
||||||
|
business{saveState.rollout.length === 1 ? "" : "es"}.
|
||||||
|
{saveState.rollout.some((r) => !r.ok) && (
|
||||||
|
<>
|
||||||
|
{" "}
|
||||||
|
Failed: {saveState.rollout.filter((r) => !r.ok).map((r) => `${r.slug} (${r.error})`).join(", ")}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="border-t border-line pt-4">
|
||||||
|
<label htmlFor="recipient" className="block text-sm font-medium text-muted">
|
||||||
|
Test recipient
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="recipient"
|
||||||
|
name="recipient"
|
||||||
|
type="email"
|
||||||
|
value={recipient}
|
||||||
|
onChange={(e) => setRecipient(e.target.value)}
|
||||||
|
placeholder="you@example.com"
|
||||||
|
className="mt-1 w-full max-w-sm rounded-md border border-line px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
{testState.error && (
|
||||||
|
<p role="alert" className="mt-2 text-sm font-medium text-danger">
|
||||||
|
{testState.error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{testState.result && (
|
||||||
|
<p className={`mt-2 text-sm font-medium ${testState.result.ok ? "text-green-700" : "text-danger"}`}>
|
||||||
|
{testState.result.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-3 pt-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
formAction={saveAction}
|
||||||
|
disabled={isSaving}
|
||||||
|
className="rounded-md bg-ink px-4 py-2 text-sm font-semibold text-white disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{isSaving ? "Saving…" : "Save Changes"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
formAction={testAction}
|
||||||
|
disabled={isTesting}
|
||||||
|
className="rounded-md border border-line px-4 py-2 text-sm font-semibold disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{isTesting ? "Sending…" : "Send Test Email"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
formAction={reapplyAction}
|
||||||
|
disabled={isReapplying || !initial.hasPassword}
|
||||||
|
title={!initial.hasPassword ? "Save complete settings first" : undefined}
|
||||||
|
className="ml-auto text-sm font-medium text-muted hover:text-ink disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{isReapplying ? "Reapplying…" : "Reapply to all businesses"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{reapplyState.error && (
|
||||||
|
<p role="alert" className="text-sm font-medium text-danger">
|
||||||
|
{reapplyState.error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{reapplyState.rollout && (
|
||||||
|
<p className="text-sm text-green-700">
|
||||||
|
Reapplied to {reapplyState.rollout.filter((r) => r.ok).length} of {reapplyState.rollout.length} business
|
||||||
|
{reapplyState.rollout.length === 1 ? "" : "es"}.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useActionState, useEffect, useState } from "react";
|
||||||
|
import type { GoogleCalendarSettingsView } from "@/lib/global-settings";
|
||||||
|
import {
|
||||||
|
reapplyGoogleCalendarSettingsAction,
|
||||||
|
saveGoogleCalendarSettingsAction,
|
||||||
|
type ReapplyGoogleCalendarSettingsState,
|
||||||
|
type SaveGoogleCalendarSettingsState,
|
||||||
|
} from "./actions";
|
||||||
|
|
||||||
|
const saveInitialState: SaveGoogleCalendarSettingsState = { error: null, saved: false, rollout: null };
|
||||||
|
const reapplyInitialState: ReapplyGoogleCalendarSettingsState = { error: null, rollout: null };
|
||||||
|
|
||||||
|
function StatusBadge({ initial, saveState }: { initial: GoogleCalendarSettingsView; saveState: SaveGoogleCalendarSettingsState }) {
|
||||||
|
if (saveState.saved && saveState.rollout) {
|
||||||
|
const allOk = saveState.rollout.every((r) => r.ok);
|
||||||
|
return allOk ? (
|
||||||
|
<span className="rounded-full bg-green-100 px-3 py-1 text-xs font-semibold text-green-800">Configured</span>
|
||||||
|
) : (
|
||||||
|
<span className="rounded-full bg-amber-100 px-3 py-1 text-xs font-semibold text-amber-800">Configured (some businesses failed)</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (initial.hasClientSecret && initial.clientId) {
|
||||||
|
return <span className="rounded-full bg-green-100 px-3 py-1 text-xs font-semibold text-green-800">Configured</span>;
|
||||||
|
}
|
||||||
|
return <span className="rounded-full bg-stone px-3 py-1 text-xs font-semibold text-muted">Not configured</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One OAuth client shared by every business — see email-settings-form.tsx
|
||||||
|
* for why every field here is controlled state rather than
|
||||||
|
* `defaultValue`: this form also has two actions (Save / Reapply) sharing
|
||||||
|
* one set of fields, and re-render after either would otherwise reset an
|
||||||
|
* uncommitted edit.
|
||||||
|
*/
|
||||||
|
export function GoogleCalendarSettingsForm({ initial }: { initial: GoogleCalendarSettingsView }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [saveState, saveAction, isSaving] = useActionState(saveGoogleCalendarSettingsAction, saveInitialState);
|
||||||
|
const [reapplyState, reapplyAction, isReapplying] = useActionState(reapplyGoogleCalendarSettingsAction, reapplyInitialState);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (saveState.saved) router.refresh();
|
||||||
|
}, [saveState, router]);
|
||||||
|
|
||||||
|
const [clientId, setClientId] = useState(initial.clientId);
|
||||||
|
const [clientSecret, setClientSecret] = useState("");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-semibold">Google Calendar</h2>
|
||||||
|
<StatusBadge initial={initial} saveState={saveState} />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted">
|
||||||
|
One OAuth client shared by every business on this installation, so staff can sync their confirmed
|
||||||
|
appointments to their own Google Calendar without each business needing its own Google Cloud project. In
|
||||||
|
Google Cloud Console, add every business's own callback URL —{" "}
|
||||||
|
<code className="rounded bg-stone px-1 py-0.5 text-xs">
|
||||||
|
https://<business-domain>/api/staff/calendar/google/callback
|
||||||
|
</code>{" "}
|
||||||
|
— as an authorized redirect URI on this client.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form className="space-y-4 rounded-md border border-line bg-surface p-5">
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="clientId" className="block text-sm font-medium text-muted">
|
||||||
|
Client ID
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="clientId"
|
||||||
|
name="clientId"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={clientId}
|
||||||
|
onChange={(e) => setClientId(e.target.value)}
|
||||||
|
placeholder="xxxxxxxxxxxx.apps.googleusercontent.com"
|
||||||
|
className="mt-1 w-full rounded-md border border-line px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="clientSecret" className="block text-sm font-medium text-muted">
|
||||||
|
Client secret
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="clientSecret"
|
||||||
|
name="clientSecret"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={clientSecret}
|
||||||
|
onChange={(e) => setClientSecret(e.target.value)}
|
||||||
|
placeholder={initial.hasClientSecret ? "•••••••••••• (leave blank to keep)" : "Not set"}
|
||||||
|
className="mt-1 w-full rounded-md border border-line px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-muted">
|
||||||
|
{initial.hasClientSecret ? "A client secret is already saved — leave this blank to keep it." : "No client secret saved yet."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{saveState.error && (
|
||||||
|
<p role="alert" className="text-sm font-medium text-danger">
|
||||||
|
{saveState.error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{saveState.saved && !saveState.error && (
|
||||||
|
<div className="text-sm text-green-700">
|
||||||
|
{saveState.rollout === null ? (
|
||||||
|
<p>Settings saved as a draft. Add a client secret to enable it and roll it out to businesses.</p>
|
||||||
|
) : (
|
||||||
|
<p>
|
||||||
|
Settings saved and applied to {saveState.rollout.filter((r) => r.ok).length} of {saveState.rollout.length}{" "}
|
||||||
|
business{saveState.rollout.length === 1 ? "" : "es"}.
|
||||||
|
{saveState.rollout.some((r) => !r.ok) && (
|
||||||
|
<>
|
||||||
|
{" "}
|
||||||
|
Failed: {saveState.rollout.filter((r) => !r.ok).map((r) => `${r.slug} (${r.error})`).join(", ")}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-3 pt-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
formAction={saveAction}
|
||||||
|
disabled={isSaving}
|
||||||
|
className="rounded-md bg-ink px-4 py-2 text-sm font-semibold text-white disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{isSaving ? "Saving…" : "Save Changes"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
formAction={reapplyAction}
|
||||||
|
disabled={isReapplying || !initial.hasClientSecret}
|
||||||
|
title={!initial.hasClientSecret ? "Save complete settings first" : undefined}
|
||||||
|
className="ml-auto text-sm font-medium text-muted hover:text-ink disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{isReapplying ? "Reapplying…" : "Reapply to all businesses"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{reapplyState.error && (
|
||||||
|
<p role="alert" className="text-sm font-medium text-danger">
|
||||||
|
{reapplyState.error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{reapplyState.rollout && (
|
||||||
|
<p className="text-sm text-green-700">
|
||||||
|
Reapplied to {reapplyState.rollout.filter((r) => r.ok).length} of {reapplyState.rollout.length} business
|
||||||
|
{reapplyState.rollout.length === 1 ? "" : "es"}.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useActionState, useEffect } from "react";
|
||||||
|
import type { OperatorSettings } from "@/lib/global-settings";
|
||||||
|
import type { SchedulerStatus } from "@/lib/scheduler-status";
|
||||||
|
import {
|
||||||
|
saveOperatorSettingsAction,
|
||||||
|
sendTestAlertAction,
|
||||||
|
type SaveOperatorSettingsState,
|
||||||
|
type SendTestAlertState,
|
||||||
|
} from "./actions";
|
||||||
|
|
||||||
|
const saveInitialState: SaveOperatorSettingsState = { error: null, saved: false };
|
||||||
|
const testInitialState: SendTestAlertState = { result: null };
|
||||||
|
|
||||||
|
function Toggle({ name, label, hint, defaultChecked }: { name: string; label: string; hint?: string; defaultChecked: boolean }) {
|
||||||
|
return (
|
||||||
|
<label className="flex items-start gap-2 text-sm">
|
||||||
|
<input type="checkbox" name={name} defaultChecked={defaultChecked} className="mt-0.5" />
|
||||||
|
<span>
|
||||||
|
{label}
|
||||||
|
{hint && <span className="block text-xs text-muted">{hint}</span>}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OperatorSettingsForm({ initial, status }: { initial: OperatorSettings; status: SchedulerStatus }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [saveState, saveAction, isSaving] = useActionState(saveOperatorSettingsAction, saveInitialState);
|
||||||
|
const [testState, testAction, isTesting] = useActionState(sendTestAlertAction, testInitialState);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (saveState.saved) router.refresh();
|
||||||
|
}, [saveState, router]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-semibold">Monitoring & scheduler</h2>
|
||||||
|
{initial.alertEmail ? (
|
||||||
|
<span className="rounded-full bg-green-100 px-3 py-1 text-xs font-semibold text-green-800">Alerts on</span>
|
||||||
|
) : (
|
||||||
|
<span className="rounded-full bg-stone px-3 py-1 text-xs font-semibold text-muted">Alerts off</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted">
|
||||||
|
The Manager calls every business's notification cron, checks its <span className="font-mono">/api/health</span> and runs scheduled
|
||||||
|
backups itself. Alerts are emailed with the SMTP settings above; a business must fail two checks in a row before you hear about it.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form className="space-y-4 rounded-md border border-line bg-surface p-5">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="alertEmail" className="block text-sm font-medium text-muted">
|
||||||
|
Operator alert email
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="alertEmail"
|
||||||
|
name="alertEmail"
|
||||||
|
type="email"
|
||||||
|
defaultValue={initial.alertEmail}
|
||||||
|
placeholder="you@example.com — blank turns alerts off"
|
||||||
|
className="mt-1 w-full rounded-md border border-line px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Toggle name="alertOnHealth" label="Email when a business goes degraded or down" defaultChecked={initial.alertOnHealth} />
|
||||||
|
<Toggle name="alertOnRecovery" label="Email when it recovers" defaultChecked={initial.alertOnRecovery} />
|
||||||
|
<Toggle name="dailyReminder" label="Remind me daily while it is still bad" defaultChecked={initial.dailyReminder} />
|
||||||
|
<Toggle name="alertOnBackupFailure" label="Email when a scheduled backup fails" defaultChecked={initial.alertOnBackupFailure} />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="notificationsIntervalSeconds" className="block text-sm font-medium text-muted">
|
||||||
|
Notification cron every (seconds)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="notificationsIntervalSeconds"
|
||||||
|
name="notificationsIntervalSeconds"
|
||||||
|
type="number"
|
||||||
|
min={10}
|
||||||
|
max={3600}
|
||||||
|
required
|
||||||
|
defaultValue={initial.notificationsIntervalSeconds}
|
||||||
|
className="mt-1 w-full rounded-md border border-line px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="healthIntervalMinutes" className="block text-sm font-medium text-muted">
|
||||||
|
Health check every (minutes)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="healthIntervalMinutes"
|
||||||
|
name="healthIntervalMinutes"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={60}
|
||||||
|
required
|
||||||
|
defaultValue={initial.healthIntervalMinutes}
|
||||||
|
className="mt-1 w-full rounded-md border border-line px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{saveState.error && (
|
||||||
|
<p role="alert" className="text-sm font-medium text-danger">
|
||||||
|
{saveState.error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{saveState.saved && !saveState.error && <p className="text-sm text-green-700">Saved.</p>}
|
||||||
|
{saveState.rollout && saveState.rollout.length > 0 && (
|
||||||
|
<div className="text-sm">
|
||||||
|
<p className="text-muted">The alert address was sent to your businesses so they can email you about server errors:</p>
|
||||||
|
<ul className="mt-1 space-y-0.5">
|
||||||
|
{saveState.rollout.map((r) => (
|
||||||
|
<li key={r.slug} className={r.ok ? "text-green-700" : "text-danger"}>
|
||||||
|
{r.slug}: {r.ok ? (r.unchanged ? "already set" : "updated and restarted") : `failed — ${r.error}`}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-3 pt-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
formAction={saveAction}
|
||||||
|
disabled={isSaving}
|
||||||
|
className="rounded-md bg-ink px-4 py-2 text-sm font-semibold text-white disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{isSaving ? "Saving…" : "Save Changes"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
formAction={testAction}
|
||||||
|
disabled={isTesting || !initial.alertEmail}
|
||||||
|
title={!initial.alertEmail ? "Save an alert email first" : "Sends to the saved address"}
|
||||||
|
className="ml-auto text-sm font-medium text-muted hover:text-ink disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{isTesting ? "Sending…" : "Send test alert"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{testState.result && (
|
||||||
|
<p role="status" className={`text-sm ${testState.result.ok ? "text-green-700" : "font-medium text-danger"}`}>
|
||||||
|
{testState.result.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="rounded-md border border-line bg-surface p-5">
|
||||||
|
<h3 className="text-sm font-semibold">Scheduler status</h3>
|
||||||
|
<p className="text-xs text-muted">
|
||||||
|
Last tick: {status.heartbeatAt ? new Date(status.heartbeatAt).toLocaleString() : "never (scheduler not running?)"}
|
||||||
|
</p>
|
||||||
|
{status.rows.length > 0 && (
|
||||||
|
<table className="mt-3 w-full text-left text-xs">
|
||||||
|
<thead className="text-muted">
|
||||||
|
<tr>
|
||||||
|
<th className="py-1 pr-3 font-medium">Task</th>
|
||||||
|
<th className="py-1 pr-3 font-medium">Last run</th>
|
||||||
|
<th className="py-1 font-medium">Result</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{status.rows.map(({ key, state }) => (
|
||||||
|
<tr key={key} className="border-t border-line">
|
||||||
|
<td className="py-1 pr-3 font-mono">{key}</td>
|
||||||
|
<td className="py-1 pr-3">{state.lastFinishedAt ? new Date(state.lastFinishedAt).toLocaleString() : "running / never"}</td>
|
||||||
|
<td className={`py-1 ${state.lastOk === false ? "text-danger" : ""}`}>{state.lastMessage ?? "—"}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import {
|
||||||
|
readEmailSettingsView,
|
||||||
|
readGoogleCalendarSettingsView,
|
||||||
|
readOperatorSettings,
|
||||||
|
readPushSettingsView,
|
||||||
|
readSuperAdminSettings,
|
||||||
|
} from "@/lib/global-settings";
|
||||||
|
import { readSchedulerStatus } from "@/lib/scheduler-status";
|
||||||
|
import { EmailSettingsForm } from "./email-settings-form";
|
||||||
|
import { GoogleCalendarSettingsForm } from "./google-calendar-settings-form";
|
||||||
|
import { OperatorSettingsForm } from "./operator-settings-form";
|
||||||
|
import { PushSettingsForm } from "./push-settings-form";
|
||||||
|
import { SuperAdminSettingsForm } from "./super-admin-settings-form";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Settings" };
|
||||||
|
// Every other page under /dashboard already has this (see
|
||||||
|
// dashboard/page.tsx, [slug]/page.tsx, [slug]/edit, [slug]/delete) —
|
||||||
|
// this one was missing it. Without it, this page has no request-time API
|
||||||
|
// in its render path (no cookies()/headers(), just plain fs reads), so
|
||||||
|
// Next.js statically prerenders it once at `next build` time and serves
|
||||||
|
// that frozen snapshot on every request afterward. In production that
|
||||||
|
// means whatever Settings looked like at build time (typically nothing,
|
||||||
|
// since STACKS_DIR/the settings file aren't available inside the Docker
|
||||||
|
// build stage — env_file only applies at container run time) is what
|
||||||
|
// gets served forever, regardless of what's actually since been saved —
|
||||||
|
// confirmed for real: after a `docker compose up -d --build` rebuild,
|
||||||
|
// already-saved settings appeared blank in the UI even though the
|
||||||
|
// underlying global-settings.json on disk still had them.
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Platform-wide settings shared by every business on this installation —
|
||||||
|
* see src/lib/global-settings.ts's own header for the storage design.
|
||||||
|
* "Email", "Push Notifications", "Super Administrator", and "Google
|
||||||
|
* Calendar" exist today; a later section (SMS, storage, backups,
|
||||||
|
* branding, locale, monitoring) is a sibling here, not a rewrite.
|
||||||
|
*/
|
||||||
|
export default async function SettingsPage() {
|
||||||
|
const [email, push, superAdmin, googleCalendar, operator, schedulerStatus] = await Promise.all([
|
||||||
|
readEmailSettingsView(),
|
||||||
|
readPushSettingsView(),
|
||||||
|
readSuperAdminSettings(),
|
||||||
|
readGoogleCalendarSettingsView(),
|
||||||
|
readOperatorSettings(),
|
||||||
|
readSchedulerStatus(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold">Settings</h1>
|
||||||
|
<p className="text-sm text-muted">Platform-wide configuration shared by every business on this installation.</p>
|
||||||
|
</div>
|
||||||
|
<EmailSettingsForm initial={email} />
|
||||||
|
<PushSettingsForm initial={push} />
|
||||||
|
<SuperAdminSettingsForm initial={superAdmin} />
|
||||||
|
<GoogleCalendarSettingsForm initial={googleCalendar} />
|
||||||
|
<OperatorSettingsForm initial={operator} status={schedulerStatus} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useActionState, useEffect, useState } from "react";
|
||||||
|
import type { PushSettingsView } from "@/lib/global-settings";
|
||||||
|
import {
|
||||||
|
generateVapidKeysAction,
|
||||||
|
reapplyPushSettingsAction,
|
||||||
|
savePushSubjectAction,
|
||||||
|
type GenerateVapidKeysState,
|
||||||
|
type ReapplyPushSettingsState,
|
||||||
|
type SavePushSubjectState,
|
||||||
|
} from "./actions";
|
||||||
|
|
||||||
|
const generateInitialState: GenerateVapidKeysState = { error: null, needsConfirmation: false, generated: false, rollout: null };
|
||||||
|
const saveInitialState: SavePushSubjectState = { error: null, saved: false, rollout: null };
|
||||||
|
const reapplyInitialState: ReapplyPushSettingsState = { error: null, rollout: null };
|
||||||
|
|
||||||
|
function StatusBadge({ initial, generateState, saveState }: { initial: PushSettingsView; generateState: GenerateVapidKeysState; saveState: SavePushSubjectState }) {
|
||||||
|
const isConfigured =
|
||||||
|
(generateState.generated && generateState.rollout !== null) ||
|
||||||
|
(saveState.saved && saveState.rollout !== null) ||
|
||||||
|
(initial.hasPrivateKey && Boolean(initial.subject));
|
||||||
|
return isConfigured ? (
|
||||||
|
<span className="rounded-full bg-green-100 px-3 py-1 text-xs font-semibold text-green-800">Configured</span>
|
||||||
|
) : (
|
||||||
|
<span className="rounded-full bg-stone px-3 py-1 text-xs font-semibold text-muted">Not configured</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* No hand-pasted key fields — see global-settings.ts's generateVapidKeys
|
||||||
|
* for why (a mismatched pair would let browsers subscribe successfully
|
||||||
|
* while every server-side send silently fails). All inputs are controlled
|
||||||
|
* (useState), same reasoning as email-settings-form.tsx: this form has
|
||||||
|
* three separate actions sharing one Subject field, and an uncontrolled
|
||||||
|
* `defaultValue` input would reset after any one of them resolves.
|
||||||
|
*
|
||||||
|
* No `<Modal>` here — Manager has no shared dialog primitive (no UI kit
|
||||||
|
* at all, by design; see edit-business-form.tsx's plain-HTML convention).
|
||||||
|
* The overwrite-protection ask is met with an inline warning panel
|
||||||
|
* instead: "Generate" is rejected server-side the first time
|
||||||
|
* (`needsConfirmation: true`) rather than acting immediately, and only a
|
||||||
|
* second, explicit click on "Yes, Generate New Keys" resubmits with
|
||||||
|
* `confirmOverwrite=true`.
|
||||||
|
*/
|
||||||
|
export function PushSettingsForm({ initial }: { initial: PushSettingsView }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [generateState, generateAction, isGenerating] = useActionState(generateVapidKeysAction, generateInitialState);
|
||||||
|
const [saveState, saveAction, isSaving] = useActionState(savePushSubjectAction, saveInitialState);
|
||||||
|
const [reapplyState, reapplyAction, isReapplying] = useActionState(reapplyPushSettingsAction, reapplyInitialState);
|
||||||
|
|
||||||
|
// See email-settings-form.tsx's identical effect for why: `initial` is
|
||||||
|
// a server snapshot from page load, and fields like the Public
|
||||||
|
// Key/Private Key display and the Reapply button's disabled state
|
||||||
|
// depend on it directly — without refreshing, they'd keep showing
|
||||||
|
// "Not generated yet" even immediately after a successful Generate.
|
||||||
|
useEffect(() => {
|
||||||
|
if (generateState.generated || saveState.saved) router.refresh();
|
||||||
|
}, [generateState, saveState, router]);
|
||||||
|
|
||||||
|
const [subject, setSubject] = useState(initial.subject || "mailto:");
|
||||||
|
const [confirmOverwrite, setConfirmOverwrite] = useState(false);
|
||||||
|
|
||||||
|
const hasExistingKeys = initial.hasPrivateKey || generateState.generated;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-semibold">Push Notifications</h2>
|
||||||
|
<StatusBadge initial={initial} generateState={generateState} saveState={saveState} />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted">
|
||||||
|
Shared by every business on this installation — lets any business send browser push notifications without
|
||||||
|
generating its own key pair. Individual businesses and users still separately control whether they use push
|
||||||
|
at all; this only controls whether the platform is technically able to.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form className="space-y-4 rounded-md border border-line bg-surface p-5">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="publicKey" className="block text-sm font-medium text-muted">
|
||||||
|
VAPID Public Key
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="publicKey"
|
||||||
|
type="text"
|
||||||
|
readOnly
|
||||||
|
value={initial.publicKey || "Not generated yet"}
|
||||||
|
className="mt-1 w-full rounded-md border border-line bg-stone px-3 py-2 font-mono text-xs text-muted"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-muted">Not a secret — browsers need this to create a push subscription.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="privateKeyDisplay" className="block text-sm font-medium text-muted">
|
||||||
|
VAPID Private Key
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="privateKeyDisplay"
|
||||||
|
type="text"
|
||||||
|
readOnly
|
||||||
|
value={initial.hasPrivateKey ? "•••••••••••••••••••••••••••••••••••••" : "Not generated yet"}
|
||||||
|
className="mt-1 w-full rounded-md border border-line bg-stone px-3 py-2 font-mono text-xs text-muted"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-muted">Never shown, logged, or returned to the browser once generated.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="subject" className="block text-sm font-medium text-muted">
|
||||||
|
Contact / Subject Email
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="subject"
|
||||||
|
name="subject"
|
||||||
|
type="text"
|
||||||
|
value={subject}
|
||||||
|
onChange={(e) => setSubject(e.target.value)}
|
||||||
|
placeholder="mailto:support@example.com"
|
||||||
|
className="mt-1 w-full rounded-md border border-line px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-muted">
|
||||||
|
A contact address push services may use if there's an abuse concern — must start with{" "}
|
||||||
|
<code>mailto:</code>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{generateState.error && (
|
||||||
|
<p role="alert" className="text-sm font-medium text-danger">
|
||||||
|
{generateState.error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{saveState.error && (
|
||||||
|
<p role="alert" className="text-sm font-medium text-danger">
|
||||||
|
{saveState.error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{(generateState.generated || saveState.saved) && !generateState.error && !saveState.error && (
|
||||||
|
<div className="text-sm text-green-700">
|
||||||
|
{(() => {
|
||||||
|
const rollout = generateState.rollout ?? saveState.rollout;
|
||||||
|
if (rollout === null) return <p>Saved as a draft — add a contact address and keys to enable delivery.</p>;
|
||||||
|
return (
|
||||||
|
<p>
|
||||||
|
Applied to {rollout.filter((r) => r.ok).length} of {rollout.length} business
|
||||||
|
{rollout.length === 1 ? "" : "es"}.
|
||||||
|
{rollout.some((r) => !r.ok) && (
|
||||||
|
<> Failed: {rollout.filter((r) => !r.ok).map((r) => `${r.slug} (${r.error})`).join(", ")}</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{generateState.needsConfirmation && (
|
||||||
|
<div className="space-y-3 rounded-md border border-amber-300 bg-amber-50 p-4">
|
||||||
|
<p className="text-sm font-semibold text-amber-900">A VAPID key pair already exists.</p>
|
||||||
|
<p className="text-sm text-amber-900">
|
||||||
|
Every browser push subscription across every business was created using the current public key.
|
||||||
|
Generating a new pair will make those subscriptions stop receiving pushes — each device will need to
|
||||||
|
re-enable push notifications afterward (the app already detects and cleans up the broken subscriptions
|
||||||
|
automatically the next time a push is attempted; nothing needs to be deleted by hand).
|
||||||
|
</p>
|
||||||
|
<label className="flex items-center gap-2 text-sm font-medium text-amber-900">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="confirmOverwrite"
|
||||||
|
value="true"
|
||||||
|
checked={confirmOverwrite}
|
||||||
|
onChange={(e) => setConfirmOverwrite(e.target.checked)}
|
||||||
|
/>
|
||||||
|
I understand this will require every existing subscription to be re-created.
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-3 pt-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
formAction={generateAction}
|
||||||
|
disabled={isGenerating || (generateState.needsConfirmation && !confirmOverwrite)}
|
||||||
|
className="rounded-md bg-ink px-4 py-2 text-sm font-semibold text-white disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{isGenerating
|
||||||
|
? "Generating…"
|
||||||
|
: generateState.needsConfirmation
|
||||||
|
? "Yes, Generate New Keys"
|
||||||
|
: hasExistingKeys
|
||||||
|
? "Regenerate VAPID Keys"
|
||||||
|
: "Generate VAPID Keys"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
formAction={saveAction}
|
||||||
|
disabled={isSaving}
|
||||||
|
className="rounded-md border border-line px-4 py-2 text-sm font-semibold disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{isSaving ? "Saving…" : "Save Changes"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
formAction={reapplyAction}
|
||||||
|
disabled={isReapplying || !initial.hasPrivateKey}
|
||||||
|
title={!initial.hasPrivateKey ? "Generate keys first" : undefined}
|
||||||
|
className="ml-auto text-sm font-medium text-muted hover:text-ink disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{isReapplying ? "Reapplying…" : "Reapply to all businesses"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{reapplyState.error && (
|
||||||
|
<p role="alert" className="text-sm font-medium text-danger">
|
||||||
|
{reapplyState.error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{reapplyState.rollout && (
|
||||||
|
<p className="text-sm text-green-700">
|
||||||
|
Reapplied to {reapplyState.rollout.filter((r) => r.ok).length} of {reapplyState.rollout.length} business
|
||||||
|
{reapplyState.rollout.length === 1 ? "" : "es"}.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useActionState, useEffect, useState } from "react";
|
||||||
|
import type { SuperAdminSettings } from "@/lib/global-settings";
|
||||||
|
import {
|
||||||
|
reapplySuperAdminAction,
|
||||||
|
saveSuperAdminSettingsAction,
|
||||||
|
type ReapplySuperAdminState,
|
||||||
|
type SaveSuperAdminState,
|
||||||
|
} from "./actions";
|
||||||
|
|
||||||
|
const saveInitialState: SaveSuperAdminState = { error: null, saved: false, rollout: null };
|
||||||
|
const reapplyInitialState: ReapplySuperAdminState = { error: null, rollout: null };
|
||||||
|
|
||||||
|
function StatusBadge({ initial, saveState }: { initial: SuperAdminSettings | null; saveState: SaveSuperAdminState }) {
|
||||||
|
const configured = (saveState.saved && !!saveState.rollout) || !!initial?.email;
|
||||||
|
return configured ? (
|
||||||
|
<span className="rounded-full bg-green-100 px-3 py-1 text-xs font-semibold text-green-800">Configured</span>
|
||||||
|
) : (
|
||||||
|
<span className="rounded-full bg-stone px-3 py-1 text-xs font-semibold text-muted">Not configured</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Controlled inputs — same reasoning as email-settings-form.tsx: two actions (Save / Reapply) share one field set, and an uncontrolled defaultValue input would reset after either resolves. */
|
||||||
|
export function SuperAdminSettingsForm({ initial }: { initial: SuperAdminSettings | null }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [saveState, saveAction, isSaving] = useActionState(saveSuperAdminSettingsAction, saveInitialState);
|
||||||
|
const [reapplyState, reapplyAction, isReapplying] = useActionState(reapplySuperAdminAction, reapplyInitialState);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (saveState.saved) router.refresh();
|
||||||
|
}, [saveState, router]);
|
||||||
|
|
||||||
|
const [email, setEmail] = useState(initial?.email ?? "");
|
||||||
|
const [firstName, setFirstName] = useState(initial?.firstName ?? "");
|
||||||
|
const [lastName, setLastName] = useState(initial?.lastName ?? "");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-semibold">Super Administrator</h2>
|
||||||
|
<StatusBadge initial={initial} saveState={saveState} />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted">
|
||||||
|
A platform-level account with every Owner permission, provisioned automatically on every business — no
|
||||||
|
password, it signs in the same one-time-code way as everyone else. Never appears in any business's Team
|
||||||
|
list, the customer-facing barber picker, or staff totals, and can never receive an appointment.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form className="space-y-4 rounded-md border border-line bg-surface p-5">
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="email" className="block text-sm font-medium text-muted">
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
placeholder="you@example.com"
|
||||||
|
className="mt-1 w-full rounded-md border border-line px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div />
|
||||||
|
<div>
|
||||||
|
<label htmlFor="firstName" className="block text-sm font-medium text-muted">
|
||||||
|
First name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="firstName"
|
||||||
|
name="firstName"
|
||||||
|
type="text"
|
||||||
|
value={firstName}
|
||||||
|
onChange={(e) => setFirstName(e.target.value)}
|
||||||
|
className="mt-1 w-full rounded-md border border-line px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="lastName" className="block text-sm font-medium text-muted">
|
||||||
|
Last name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="lastName"
|
||||||
|
name="lastName"
|
||||||
|
type="text"
|
||||||
|
value={lastName}
|
||||||
|
onChange={(e) => setLastName(e.target.value)}
|
||||||
|
className="mt-1 w-full rounded-md border border-line px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{saveState.error && (
|
||||||
|
<p role="alert" className="text-sm font-medium text-danger">
|
||||||
|
{saveState.error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{saveState.saved && !saveState.error && (
|
||||||
|
<div className="text-sm text-green-700">
|
||||||
|
{saveState.rollout === null ? (
|
||||||
|
<p>Saved as a draft — enter an email to apply it to every business.</p>
|
||||||
|
) : (
|
||||||
|
<p>
|
||||||
|
Saved and applied to {saveState.rollout.filter((r) => r.ok).length} of {saveState.rollout.length}{" "}
|
||||||
|
business{saveState.rollout.length === 1 ? "" : "es"}.
|
||||||
|
{saveState.rollout.some((r) => !r.ok) && (
|
||||||
|
<> Failed: {saveState.rollout.filter((r) => !r.ok).map((r) => `${r.slug} (${r.error})`).join(", ")}</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-3 pt-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
formAction={saveAction}
|
||||||
|
disabled={isSaving}
|
||||||
|
className="rounded-md bg-ink px-4 py-2 text-sm font-semibold text-white disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{isSaving ? "Saving…" : "Save Changes"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
formAction={reapplyAction}
|
||||||
|
disabled={isReapplying || !initial?.email}
|
||||||
|
title={!initial?.email ? "Save an email first" : undefined}
|
||||||
|
className="ml-auto text-sm font-medium text-muted hover:text-ink disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{isReapplying ? "Reapplying…" : "Reapply to all businesses"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{reapplyState.error && (
|
||||||
|
<p role="alert" className="text-sm font-medium text-danger">
|
||||||
|
{reapplyState.error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{reapplyState.rollout && (
|
||||||
|
<p className="text-sm text-green-700">
|
||||||
|
Reapplied to {reapplyState.rollout.filter((r) => r.ok).length} of {reapplyState.rollout.length} business
|
||||||
|
{reapplyState.rollout.length === 1 ? "" : "es"}.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--color-ink: #111b15;
|
||||||
|
--color-stone: #f4f6f4;
|
||||||
|
--color-surface: #ffffff;
|
||||||
|
--color-muted: #5b665f;
|
||||||
|
--color-line: #dfe3df;
|
||||||
|
--color-accent: #1f7a4d;
|
||||||
|
--color-danger: #b3261e;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: var(--color-stone);
|
||||||
|
color: var(--color-ink);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import "./globals.css";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "NextChair Manager",
|
||||||
|
description: "Platform-operator tool for provisioning NextChair business deployments.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<html lang="en">
|
||||||
|
<body>{children}</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { verifyManagerCredentials } from "@/lib/auth";
|
||||||
|
import { createSessionToken, isSecureDeployment, SESSION_COOKIE_NAME } from "@/lib/session";
|
||||||
|
|
||||||
|
export interface LoginState {
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loginAction(_prevState: LoginState, formData: FormData): Promise<LoginState> {
|
||||||
|
const email = String(formData.get("email") ?? "");
|
||||||
|
const password = String(formData.get("password") ?? "");
|
||||||
|
|
||||||
|
if (!email || !password) {
|
||||||
|
return { error: "Email and password are required." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const valid = await verifyManagerCredentials(email, password);
|
||||||
|
if (!valid) {
|
||||||
|
return { error: "Incorrect email or password." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
cookieStore.set(SESSION_COOKIE_NAME, createSessionToken(email), {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: isSecureDeployment(),
|
||||||
|
sameSite: "lax",
|
||||||
|
path: "/",
|
||||||
|
maxAge: 7 * 24 * 60 * 60,
|
||||||
|
});
|
||||||
|
|
||||||
|
redirect("/dashboard");
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useActionState } from "react";
|
||||||
|
import { loginAction, type LoginState } from "./actions";
|
||||||
|
|
||||||
|
const initialState: LoginState = { error: null };
|
||||||
|
|
||||||
|
export function LoginForm() {
|
||||||
|
const [state, formAction, isPending] = useActionState(loginAction, initialState);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={formAction} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="email" className="block text-sm font-medium text-muted">
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
autoComplete="username"
|
||||||
|
className="mt-1 w-full rounded-md border border-line px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="password" className="block text-sm font-medium text-muted">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
autoComplete="current-password"
|
||||||
|
className="mt-1 w-full rounded-md border border-line px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{state.error && (
|
||||||
|
<p role="alert" className="text-sm font-medium text-danger">
|
||||||
|
{state.error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isPending}
|
||||||
|
className="w-full rounded-md bg-ink py-2 text-sm font-semibold text-white disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{isPending ? "Signing in…" : "Sign in"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { LoginForm } from "./login-form";
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
return (
|
||||||
|
<main className="flex min-h-screen items-center justify-center px-4">
|
||||||
|
<div className="w-full max-w-sm space-y-6 rounded-lg border border-line bg-surface p-8 shadow-sm">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-lg font-bold">NextChair Manager</h1>
|
||||||
|
<p className="text-sm text-muted">Platform-operator sign in.</p>
|
||||||
|
</div>
|
||||||
|
<LoginForm />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export default function RootPage() {
|
||||||
|
redirect("/dashboard");
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
/**
|
||||||
|
* Runs once when the Manager's server starts (Next.js instrumentation).
|
||||||
|
* Starts the built-in scheduler — notification cron, daily reviews sync,
|
||||||
|
* health checks, scheduled backups — as ONE interval guarded by a
|
||||||
|
* globalThis singleton (see lib/scheduler-start.ts).
|
||||||
|
*
|
||||||
|
* Node.js runtime only (the scheduler uses fs/child_process), and skipped
|
||||||
|
* when STACKS_DIR isn't set or MANAGER_SCHEDULER_DISABLED=1 (e.g. a
|
||||||
|
* throwaway `next dev` with no stacks, or to hand cron back to the host's
|
||||||
|
* stacks/infra/scripts/process-cron.sh).
|
||||||
|
*/
|
||||||
|
export async function register() {
|
||||||
|
if (process.env.NEXT_RUNTIME !== "nodejs") return;
|
||||||
|
if (!process.env.STACKS_DIR) return;
|
||||||
|
if (process.env.MANAGER_SCHEDULER_DISABLED === "1") {
|
||||||
|
console.log("[scheduler] disabled by MANAGER_SCHEDULER_DISABLED=1.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { startScheduler } = await import("./lib/scheduler-start");
|
||||||
|
await startScheduler();
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { buildManifest, checkMigrationCompatibility, ManifestError, parseManifest } from "../backup-manifest";
|
||||||
|
|
||||||
|
const valid = () =>
|
||||||
|
buildManifest({
|
||||||
|
slug: "acme",
|
||||||
|
name: "Acme Barbers",
|
||||||
|
domain: "acme.example.com",
|
||||||
|
databaseName: "acme",
|
||||||
|
databaseUser: "acme",
|
||||||
|
createdAt: "2026-10-17T03:00:00.000Z",
|
||||||
|
kind: "regular",
|
||||||
|
lastMigration: "20261017180000_add_business_holidays",
|
||||||
|
appImage: "nextchair-app:latest",
|
||||||
|
hasCaddySite: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseManifest", () => {
|
||||||
|
it("round-trips a manifest it built", () => {
|
||||||
|
const parsed = parseManifest(JSON.stringify(valid()));
|
||||||
|
expect(parsed).toEqual(valid());
|
||||||
|
expect(parsed.formatVersion).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a manifest with no recorded migration", () => {
|
||||||
|
expect(parseManifest(JSON.stringify({ ...valid(), lastMigration: null })).lastMigration).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects non-JSON and non-objects", () => {
|
||||||
|
expect(() => parseManifest("not json")).toThrow(ManifestError);
|
||||||
|
expect(() => parseManifest("[]")).toThrow(ManifestError);
|
||||||
|
expect(() => parseManifest("null")).toThrow(ManifestError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a format newer than this Manager understands", () => {
|
||||||
|
expect(() => parseManifest(JSON.stringify({ ...valid(), formatVersion: 99 }))).toThrow(/newer than this Manager/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["slug", { slug: "Not A Slug" }],
|
||||||
|
["slug", { slug: "infra" }],
|
||||||
|
["domain", { domain: 'evil".com' }],
|
||||||
|
["database name", { databaseName: "acme`; DROP DATABASE x; --" }],
|
||||||
|
["database user", { databaseUser: "root'@'%" }],
|
||||||
|
["createdAt", { createdAt: "yesterday" }],
|
||||||
|
["kind", { kind: "nightly" }],
|
||||||
|
["migration", { lastMigration: "../../etc" }],
|
||||||
|
["business name", { name: 'Acme "quoted"' }],
|
||||||
|
])("rejects an unsafe or invalid %s", (_label, override) => {
|
||||||
|
expect(() => parseManifest(JSON.stringify({ ...valid(), ...override }))).toThrow(ManifestError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a manifest missing required fields", () => {
|
||||||
|
const { domain: _domain, ...rest } = valid();
|
||||||
|
expect(() => parseManifest(JSON.stringify(rest))).toThrow(/domain/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("checkMigrationCompatibility (version guard)", () => {
|
||||||
|
const image = ["20260912165903_add_staff_selected_by_customer", "20261017180000_add_business_holidays"];
|
||||||
|
|
||||||
|
it("allows a backup on the image's newest migration", () => {
|
||||||
|
expect(checkMigrationCompatibility("20261017180000_add_business_holidays", image)).toEqual({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows an older backup (the entrypoint migrates it forward)", () => {
|
||||||
|
expect(checkMigrationCompatibility("20260912165903_add_staff_selected_by_customer", image)).toEqual({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a backup whose last migration is newer than the image's", () => {
|
||||||
|
const result = checkMigrationCompatibility("20271101000000_future_thing", image);
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(!result.ok && result.reason).toMatch(/newer than the running app image/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows a backup with no recorded migration", () => {
|
||||||
|
expect(checkMigrationCompatibility(null, image)).toEqual({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses when the image's migration list can't be read", () => {
|
||||||
|
expect(checkMigrationCompatibility("20261017180000_add_business_holidays", []).ok).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { mkdtemp, rm } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
DEFAULT_BACKUP_SETTINGS,
|
||||||
|
isBackupDue,
|
||||||
|
lastScheduledOccurrence,
|
||||||
|
readBackupSettings,
|
||||||
|
recordBackupAttempt,
|
||||||
|
recordBackupOutcome,
|
||||||
|
saveBackupSettings,
|
||||||
|
validateBackupSettingsInput,
|
||||||
|
type BackupSettings,
|
||||||
|
} from "../backup-settings";
|
||||||
|
|
||||||
|
// All dates are built from LOCAL components, like the scheduler's own maths, so these hold in any TZ.
|
||||||
|
const at = (y: number, m: number, d: number, h = 0, min = 0) => new Date(y, m - 1, d, h, min, 0, 0);
|
||||||
|
const base = (over: Partial<BackupSettings> = {}): BackupSettings => ({ ...DEFAULT_BACKUP_SETTINGS, enabled: true, ...over });
|
||||||
|
|
||||||
|
describe("lastScheduledOccurrence", () => {
|
||||||
|
it("daily: today's time if it has passed, otherwise yesterday's", () => {
|
||||||
|
const s = { frequency: "daily" as const, time: "03:00", weekday: 0 };
|
||||||
|
expect(lastScheduledOccurrence(s, at(2026, 10, 17, 14, 30))).toEqual(at(2026, 10, 17, 3, 0));
|
||||||
|
expect(lastScheduledOccurrence(s, at(2026, 10, 17, 2, 59))).toEqual(at(2026, 10, 16, 3, 0));
|
||||||
|
expect(lastScheduledOccurrence(s, at(2026, 10, 17, 3, 0))).toEqual(at(2026, 10, 17, 3, 0));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("weekly: the most recent matching weekday at that time", () => {
|
||||||
|
// 2026-10-17 is a Saturday (6). Weekly on Monday (1) 04:00 → Monday 12th.
|
||||||
|
const s = { frequency: "weekly" as const, time: "04:00", weekday: 1 };
|
||||||
|
expect(lastScheduledOccurrence(s, at(2026, 10, 17, 12, 0))).toEqual(at(2026, 10, 12, 4, 0));
|
||||||
|
// On Monday before 04:00 it is still the previous Monday.
|
||||||
|
expect(lastScheduledOccurrence(s, at(2026, 10, 19, 3, 0))).toEqual(at(2026, 10, 12, 4, 0));
|
||||||
|
expect(lastScheduledOccurrence(s, at(2026, 10, 19, 4, 1))).toEqual(at(2026, 10, 19, 4, 0));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isBackupDue", () => {
|
||||||
|
it("is never due when disabled", () => {
|
||||||
|
expect(isBackupDue(base({ enabled: false }), at(2026, 10, 17, 12))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not fire the moment it is enabled — it waits for the next scheduled time", () => {
|
||||||
|
const enabledAt = at(2026, 10, 17, 14, 0).toISOString();
|
||||||
|
const s = base({ time: "03:00", enabledAt });
|
||||||
|
expect(isBackupDue(s, at(2026, 10, 17, 14, 1))).toBe(false);
|
||||||
|
expect(isBackupDue(s, at(2026, 10, 18, 3, 0))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is due once a scheduled time has passed since the last attempt, and not again until the next", () => {
|
||||||
|
const s = base({ time: "03:00", lastAttemptAt: at(2026, 10, 16, 3, 0).toISOString(), enabledAt: at(2026, 10, 1).toISOString() });
|
||||||
|
expect(isBackupDue(s, at(2026, 10, 17, 2, 59))).toBe(false);
|
||||||
|
expect(isBackupDue(s, at(2026, 10, 17, 3, 0))).toBe(true);
|
||||||
|
const after = { ...s, lastAttemptAt: at(2026, 10, 17, 3, 0, ).toISOString() };
|
||||||
|
expect(isBackupDue(after, at(2026, 10, 17, 23, 0))).toBe(false);
|
||||||
|
expect(isBackupDue(after, at(2026, 10, 18, 3, 0))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("catches up a run missed while the Manager was down", () => {
|
||||||
|
const s = base({ time: "03:00", lastAttemptAt: at(2026, 10, 10, 3, 0).toISOString(), enabledAt: at(2026, 10, 1).toISOString() });
|
||||||
|
expect(isBackupDue(s, at(2026, 10, 17, 9, 0))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("weekly: due only after that weekday's time", () => {
|
||||||
|
const s = base({ frequency: "weekly", weekday: 1, time: "04:00", lastAttemptAt: at(2026, 10, 12, 4, 0).toISOString(), enabledAt: at(2026, 9, 1).toISOString() });
|
||||||
|
expect(isBackupDue(s, at(2026, 10, 18, 12))).toBe(false); // Sunday
|
||||||
|
expect(isBackupDue(s, at(2026, 10, 19, 4, 0))).toBe(true); // next Monday 04:00
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("settings store", () => {
|
||||||
|
let stacksDir: string;
|
||||||
|
const originalStacksDir = process.env.STACKS_DIR;
|
||||||
|
beforeEach(async () => {
|
||||||
|
stacksDir = await mkdtemp(path.join(tmpdir(), "nc-manager-backup-settings-"));
|
||||||
|
process.env.STACKS_DIR = stacksDir;
|
||||||
|
});
|
||||||
|
afterEach(async () => {
|
||||||
|
if (originalStacksDir === undefined) delete process.env.STACKS_DIR;
|
||||||
|
else process.env.STACKS_DIR = originalStacksDir;
|
||||||
|
await rm(stacksDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults to disabled, daily, 03:00, keep 2", async () => {
|
||||||
|
expect(await readBackupSettings("acme")).toMatchObject({ enabled: false, frequency: "daily", time: "03:00", keep: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is keyed by slug and records enabledAt only on the off→on transition", async () => {
|
||||||
|
const t1 = new Date("2026-10-17T10:00:00Z");
|
||||||
|
await saveBackupSettings("acme", { enabled: true, frequency: "weekly", time: "05:30", weekday: 3, keep: 4 }, t1);
|
||||||
|
await saveBackupSettings("zeta", { enabled: false, frequency: "daily", time: "01:00", weekday: 0, keep: 2 });
|
||||||
|
const acme = await readBackupSettings("acme");
|
||||||
|
expect(acme).toMatchObject({ enabled: true, frequency: "weekly", time: "05:30", weekday: 3, keep: 4, enabledAt: t1.toISOString() });
|
||||||
|
expect((await readBackupSettings("zeta")).enabled).toBe(false);
|
||||||
|
|
||||||
|
await saveBackupSettings("acme", { enabled: true, frequency: "daily", time: "05:30", weekday: 0, keep: 4 }, new Date("2026-10-18T10:00:00Z"));
|
||||||
|
expect((await readBackupSettings("acme")).enabledAt).toBe(t1.toISOString());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("records attempts and outcomes without touching the schedule", async () => {
|
||||||
|
await saveBackupSettings("acme", { enabled: true, frequency: "daily", time: "02:00", weekday: 0, keep: 2 });
|
||||||
|
await recordBackupAttempt("acme", new Date("2026-10-17T02:00:00Z"));
|
||||||
|
await recordBackupOutcome("acme", { ok: false, message: "disk full", at: new Date("2026-10-17T02:05:00Z") });
|
||||||
|
expect(await readBackupSettings("acme")).toMatchObject({ time: "02:00", lastStatus: "failed", lastMessage: "disk full", lastSuccessAt: null });
|
||||||
|
await recordBackupOutcome("acme", { ok: true, message: "ok", at: new Date("2026-10-18T02:05:00Z") });
|
||||||
|
expect((await readBackupSettings("acme")).lastSuccessAt).toBe("2026-10-18T02:05:00.000Z");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("validates input", () => {
|
||||||
|
const ok = { enabled: true, frequency: "daily" as const, time: "03:00", weekday: 0, keep: 2 };
|
||||||
|
expect(validateBackupSettingsInput(ok)).toBeNull();
|
||||||
|
expect(validateBackupSettingsInput({ ...ok, time: "25:00" })).toMatch(/HH:MM/);
|
||||||
|
expect(validateBackupSettingsInput({ ...ok, keep: 0 })).toMatch(/Keep between/);
|
||||||
|
expect(validateBackupSettingsInput({ ...ok, keep: 99 })).toMatch(/Keep between/);
|
||||||
|
expect(validateBackupSettingsInput({ ...ok, weekday: 9 })).toMatch(/day/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { mkdtemp, mkdir, readdir, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { backupFileName, backupFilePath, listAllBackups, listBackups, parseBackupFileName, pruneBackups } from "../backup-store";
|
||||||
|
|
||||||
|
describe("backup file naming", () => {
|
||||||
|
it("round-trips slug, kind and UTC timestamp", () => {
|
||||||
|
const name = backupFileName("acme-cuts", "regular", new Date("2026-10-17T03:04:05.678Z"));
|
||||||
|
expect(name).toBe("acme-cuts__20261017T030405Z__regular.7z");
|
||||||
|
expect(parseBackupFileName(name)).toEqual({ slug: "acme-cuts", kind: "regular", createdAt: "2026-10-17T03:04:05Z" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects names this module did not produce", () => {
|
||||||
|
expect(parseBackupFileName("acme__20261017T030405Z__regular.7z.partial")).toBeNull();
|
||||||
|
expect(parseBackupFileName("acme__20261017T030405Z__weird.7z")).toBeNull();
|
||||||
|
expect(parseBackupFileName("../etc/passwd")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses to resolve a path for a mismatched slug or a traversal attempt", () => {
|
||||||
|
process.env.STACKS_DIR = "/stacks";
|
||||||
|
expect(() => backupFilePath("acme", "other__20261017T030405Z__regular.7z")).toThrow(/Invalid backup reference/);
|
||||||
|
expect(() => backupFilePath("acme", "../acme__20261017T030405Z__regular.7z")).toThrow(/Invalid backup reference/);
|
||||||
|
expect(() => backupFilePath("../x", "acme__20261017T030405Z__regular.7z")).toThrow(/Invalid backup reference/);
|
||||||
|
expect(backupFilePath("acme", "acme__20261017T030405Z__regular.7z")).toBe("/stacks/backups/acme/acme__20261017T030405Z__regular.7z");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("retention", () => {
|
||||||
|
let stacksDir: string;
|
||||||
|
const originalStacksDir = process.env.STACKS_DIR;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
stacksDir = await mkdtemp(path.join(tmpdir(), "nc-manager-backup-store-"));
|
||||||
|
process.env.STACKS_DIR = stacksDir;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (originalStacksDir === undefined) delete process.env.STACKS_DIR;
|
||||||
|
else process.env.STACKS_DIR = originalStacksDir;
|
||||||
|
await rm(stacksDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
async function put(slug: string, kind: "regular" | "safety" | "final", iso: string) {
|
||||||
|
const dir = path.join(stacksDir, "backups", slug);
|
||||||
|
await mkdir(dir, { recursive: true });
|
||||||
|
const name = backupFileName(slug, kind, new Date(iso));
|
||||||
|
await writeFile(path.join(dir, name), "x");
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("keeps only the newest 2 regular backups and deletes the rest", async () => {
|
||||||
|
const oldest = await put("acme", "regular", "2026-10-10T03:00:00Z");
|
||||||
|
const middle = await put("acme", "regular", "2026-10-11T03:00:00Z");
|
||||||
|
const newest = await put("acme", "regular", "2026-10-12T03:00:00Z");
|
||||||
|
|
||||||
|
const removed = await pruneBackups("acme", "regular", 2);
|
||||||
|
|
||||||
|
expect(removed).toEqual([oldest]);
|
||||||
|
expect((await listBackups("acme")).map((b) => b.fileName)).toEqual([newest, middle]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing when at or below the limit", async () => {
|
||||||
|
await put("acme", "regular", "2026-10-10T03:00:00Z");
|
||||||
|
await put("acme", "regular", "2026-10-11T03:00:00Z");
|
||||||
|
expect(await pruneBackups("acme", "regular", 2)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("honours a per-business keep count", async () => {
|
||||||
|
for (const day of ["10", "11", "12", "13"]) await put("acme", "regular", `2026-10-${day}T03:00:00Z`);
|
||||||
|
await pruneBackups("acme", "regular", 3);
|
||||||
|
expect(await listBackups("acme")).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never lets regular pruning touch safety or final backups, or another business", async () => {
|
||||||
|
const safety = await put("acme", "safety", "2026-01-01T00:00:00Z");
|
||||||
|
const final = await put("acme", "final", "2026-01-02T00:00:00Z");
|
||||||
|
const other = await put("zeta", "regular", "2026-01-03T00:00:00Z");
|
||||||
|
for (const day of ["10", "11", "12"]) await put("acme", "regular", `2026-10-${day}T03:00:00Z`);
|
||||||
|
|
||||||
|
await pruneBackups("acme", "regular", 1);
|
||||||
|
|
||||||
|
const acme = (await listBackups("acme")).map((b) => b.fileName);
|
||||||
|
expect(acme).toContain(safety);
|
||||||
|
expect(acme).toContain(final);
|
||||||
|
expect(acme.filter((n) => n.endsWith("__regular.7z"))).toHaveLength(1);
|
||||||
|
expect((await listBackups("zeta")).map((b) => b.fileName)).toEqual([other]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never prunes final backups", async () => {
|
||||||
|
for (const day of ["10", "11", "12"]) await put("acme", "final", `2026-10-${day}T03:00:00Z`);
|
||||||
|
expect(await pruneBackups("acme", "final", 1)).toEqual([]);
|
||||||
|
expect(await listBackups("acme")).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores .partial files and the uploads folder when listing", async () => {
|
||||||
|
await put("acme", "regular", "2026-10-10T03:00:00Z");
|
||||||
|
await writeFile(path.join(stacksDir, "backups", "acme", "acme__20261011T030000Z__regular.7z.partial"), "x");
|
||||||
|
await mkdir(path.join(stacksDir, "backups", "_uploads"), { recursive: true });
|
||||||
|
await writeFile(path.join(stacksDir, "backups", "_uploads", "abc.7z"), "x");
|
||||||
|
|
||||||
|
expect(await listBackups("acme")).toHaveLength(1);
|
||||||
|
expect((await listAllBackups()).map((b) => b.slug)).toEqual(["acme"]);
|
||||||
|
expect(await readdir(path.join(stacksDir, "backups", "acme"))).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import { mkdir, mkdtemp, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { CommandSpec } from "../process";
|
||||||
|
|
||||||
|
const containerExistsMock = vi.fn();
|
||||||
|
vi.mock("@/lib/docker", () => ({ containerExists: (...a: unknown[]) => containerExistsMock(...a) }));
|
||||||
|
|
||||||
|
const runToFileMock = vi.fn();
|
||||||
|
const runCaptureMock = vi.fn();
|
||||||
|
vi.mock("@/lib/process", () => ({
|
||||||
|
runToFile: (...a: unknown[]) => runToFileMock(...a),
|
||||||
|
runCapture: (...a: unknown[]) => runCaptureMock(...a),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const sevenZipCreateMock = vi.fn();
|
||||||
|
const sevenZipTestMock = vi.fn();
|
||||||
|
vi.mock("@/lib/sevenzip", () => ({
|
||||||
|
sevenZipCreate: (...a: unknown[]) => sevenZipCreateMock(...a),
|
||||||
|
sevenZipTest: (...a: unknown[]) => sevenZipTestMock(...a),
|
||||||
|
}));
|
||||||
|
|
||||||
|
|
||||||
|
import { performBackup } from "../backup";
|
||||||
|
import { backupFileName, listBackups } from "../backup-store";
|
||||||
|
import { saveBackupSettings } from "../backup-settings";
|
||||||
|
import { parseManifest } from "../backup-manifest";
|
||||||
|
|
||||||
|
const ENV = [
|
||||||
|
'BUSINESS_NAME="Acme Barbers"',
|
||||||
|
'DOMAIN="acme.example.com"',
|
||||||
|
'DATABASE_NAME="acme"',
|
||||||
|
'DATABASE_USER="acme"',
|
||||||
|
'DATABASE_PASSWORD="abc123"',
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
describe("performBackup", () => {
|
||||||
|
let stacksDir: string;
|
||||||
|
const originalStacksDir = process.env.STACKS_DIR;
|
||||||
|
const originalRoot = process.env.MYSQL_ROOT_PASSWORD;
|
||||||
|
/** What the (mocked) 7zz saw in the staging dir at archive time. */
|
||||||
|
let staged: { files: string[]; manifest: string };
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
stacksDir = await mkdtemp(path.join(tmpdir(), "nc-manager-backup-"));
|
||||||
|
process.env.STACKS_DIR = stacksDir;
|
||||||
|
process.env.MYSQL_ROOT_PASSWORD = "root-pw";
|
||||||
|
await mkdir(path.join(stacksDir, "acme"), { recursive: true });
|
||||||
|
await writeFile(path.join(stacksDir, "acme", "compose.yaml"), "name: next-chair-acme\n");
|
||||||
|
await writeFile(path.join(stacksDir, "acme", ".env"), ENV);
|
||||||
|
await mkdir(path.join(stacksDir, "infra", "sites"), { recursive: true });
|
||||||
|
await writeFile(path.join(stacksDir, "infra", "sites", "acme.caddy"), "acme.example.com {\n}\n");
|
||||||
|
|
||||||
|
containerExistsMock.mockReset().mockResolvedValue(true);
|
||||||
|
runCaptureMock.mockReset().mockResolvedValue({ stdout: "20261017180000_add_business_holidays\n", stderr: "" });
|
||||||
|
runToFileMock.mockReset().mockImplementation(async (spec: CommandSpec, file: string) => {
|
||||||
|
const isDump = spec.args.includes("mysqldump");
|
||||||
|
await writeFile(file, isDump ? "-- dump\nCREATE TABLE t (id int);\n-- Dump completed on 2026-10-17\n" : "TAR");
|
||||||
|
});
|
||||||
|
sevenZipTestMock.mockReset().mockResolvedValue(undefined);
|
||||||
|
sevenZipCreateMock.mockReset().mockImplementation(async (p: { archivePath: string; cwd: string }) => {
|
||||||
|
const files = (await readdir(p.cwd, { recursive: true })).map(String).sort();
|
||||||
|
staged = { files, manifest: await readFile(path.join(p.cwd, "manifest.json"), "utf8") };
|
||||||
|
await writeFile(p.archivePath, "7Z-ARCHIVE");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (originalStacksDir === undefined) delete process.env.STACKS_DIR;
|
||||||
|
else process.env.STACKS_DIR = originalStacksDir;
|
||||||
|
if (originalRoot === undefined) delete process.env.MYSQL_ROOT_PASSWORD;
|
||||||
|
else process.env.MYSQL_ROOT_PASSWORD = originalRoot;
|
||||||
|
await rm(stacksDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
const logs = () => {
|
||||||
|
const lines: string[] = [];
|
||||||
|
return { lines, ctx: { log: (l: string) => lines.push(l) } };
|
||||||
|
};
|
||||||
|
|
||||||
|
it("streams the dump and the media to files, packages them with a manifest, verifies, then renames into place", async () => {
|
||||||
|
const { ctx, lines } = logs();
|
||||||
|
const now = new Date("2026-10-17T03:00:00Z");
|
||||||
|
const result = await performBackup("acme", "regular", ctx, now);
|
||||||
|
|
||||||
|
// 1. mysqldump → db.sql, 2. docker cp → media.tar (both streamed, never captured in memory)
|
||||||
|
const [dumpSpec, dumpFile] = runToFileMock.mock.calls[0] as [CommandSpec, string];
|
||||||
|
expect(dumpSpec.args).toEqual(expect.arrayContaining(["mysqldump", "--single-transaction", "--routines", "--triggers", "acme"]));
|
||||||
|
expect(path.basename(dumpFile)).toBe("db.sql");
|
||||||
|
const [mediaSpec, mediaFile] = runToFileMock.mock.calls[1] as [CommandSpec, string];
|
||||||
|
expect(mediaSpec.args).toEqual(["cp", "next-chair-acme:/app/storage/media", "-"]);
|
||||||
|
expect(path.basename(mediaFile)).toBe("media.tar");
|
||||||
|
|
||||||
|
// 3. everything that goes into the archive
|
||||||
|
expect(staged.files).toEqual(["config", "config/.env", "config/compose.yaml", "config/site.caddy", "db.sql", "manifest.json", "media.tar"]);
|
||||||
|
const manifest = parseManifest(staged.manifest);
|
||||||
|
expect(manifest).toMatchObject({
|
||||||
|
slug: "acme", name: "Acme Barbers", domain: "acme.example.com", databaseName: "acme", databaseUser: "acme",
|
||||||
|
kind: "regular", lastMigration: "20261017180000_add_business_holidays", hasCaddySite: true, createdAt: now.toISOString(),
|
||||||
|
});
|
||||||
|
expect(staged.manifest).not.toContain("abc123"); // the DB password lives only in config/.env inside the archive
|
||||||
|
|
||||||
|
// 4. a plain archive (no password), written as .partial first
|
||||||
|
const create = sevenZipCreateMock.mock.calls[0]![0] as { archivePath: string; entries: string[] };
|
||||||
|
expect(create).not.toHaveProperty("passphrase");
|
||||||
|
expect(create.archivePath.endsWith(`${backupFileName("acme", "regular", now)}.partial`)).toBe(true);
|
||||||
|
expect(create.entries).toEqual(["manifest.json", "db.sql", "media.tar", "config"]);
|
||||||
|
|
||||||
|
// 5. verified (7zz t) the .partial, THEN renamed
|
||||||
|
expect(sevenZipTestMock).toHaveBeenCalledWith(create.archivePath);
|
||||||
|
const finalPath = create.archivePath.replace(/\.partial$/, "");
|
||||||
|
expect((await stat(finalPath)).isFile()).toBe(true);
|
||||||
|
expect((await stat(finalPath)).mode & 0o777).toBe(0o600);
|
||||||
|
await expect(stat(create.archivePath)).rejects.toThrow();
|
||||||
|
expect(result).toMatchObject({ slug: "acme", kind: "regular", fileName: backupFileName("acme", "regular", now) });
|
||||||
|
|
||||||
|
// no staging leftovers, and nothing secret in the log
|
||||||
|
expect(await readdir(path.join(stacksDir, "manager", "tmp"))).toEqual([]);
|
||||||
|
expect(lines.join("\n")).not.toContain("abc123");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps only the newest 2 backups after a successful run", async () => {
|
||||||
|
const dir = path.join(stacksDir, "backups", "acme");
|
||||||
|
await mkdir(dir, { recursive: true });
|
||||||
|
for (const iso of ["2026-10-10T03:00:00Z", "2026-10-11T03:00:00Z", "2026-10-12T03:00:00Z"]) {
|
||||||
|
await writeFile(path.join(dir, backupFileName("acme", "regular", new Date(iso))), "old");
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await performBackup("acme", "regular", logs().ctx, new Date("2026-10-13T03:00:00Z"));
|
||||||
|
|
||||||
|
expect((await listBackups("acme")).map((b) => b.createdAt)).toEqual(["2026-10-13T03:00:00Z", "2026-10-12T03:00:00Z"]);
|
||||||
|
expect(result.pruned).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the business's configured keep count", async () => {
|
||||||
|
await saveBackupSettings("acme", { enabled: true, frequency: "daily", time: "03:00", weekday: 0, keep: 3 });
|
||||||
|
const dir = path.join(stacksDir, "backups", "acme");
|
||||||
|
await mkdir(dir, { recursive: true });
|
||||||
|
for (const day of ["10", "11", "12"]) await writeFile(path.join(dir, backupFileName("acme", "regular", new Date(`2026-10-${day}T03:00:00Z`))), "old");
|
||||||
|
await performBackup("acme", "regular", logs().ctx, new Date("2026-10-13T03:00:00Z"));
|
||||||
|
expect(await listBackups("acme")).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a failed verification leaves NO new backup behind and does not prune the good ones", async () => {
|
||||||
|
const dir = path.join(stacksDir, "backups", "acme");
|
||||||
|
await mkdir(dir, { recursive: true });
|
||||||
|
const good = [backupFileName("acme", "regular", new Date("2026-10-11T03:00:00Z")), backupFileName("acme", "regular", new Date("2026-10-12T03:00:00Z"))];
|
||||||
|
for (const name of good) await writeFile(path.join(dir, name), "good");
|
||||||
|
sevenZipTestMock.mockRejectedValue(new Error("7zz exited with code 2: Data Error"));
|
||||||
|
|
||||||
|
await expect(performBackup("acme", "regular", logs().ctx, new Date("2026-10-13T03:00:00Z"))).rejects.toThrow(/Data Error/);
|
||||||
|
|
||||||
|
expect((await readdir(dir)).sort()).toEqual([...good].sort());
|
||||||
|
expect(await readdir(path.join(stacksDir, "manager", "tmp"))).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses an incomplete dump (no completion marker)", async () => {
|
||||||
|
runToFileMock.mockImplementation(async (_spec: CommandSpec, file: string) => writeFile(file, "-- dump\nCREATE TABLE t (id int);\n"));
|
||||||
|
await expect(performBackup("acme", "regular", logs().ctx)).rejects.toThrow(/incomplete/);
|
||||||
|
expect(sevenZipCreateMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads media straight from the volume when the container is gone (a stopped stack is compose-down'd)", async () => {
|
||||||
|
containerExistsMock.mockResolvedValue(false);
|
||||||
|
await performBackup("acme", "regular", logs().ctx);
|
||||||
|
const [mediaSpec] = runToFileMock.mock.calls[1] as [CommandSpec];
|
||||||
|
expect(mediaSpec.args).toContain("next-chair-media-acme:/app/storage/media:ro");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("safety backups prune only other safety backups and never the regular ones", async () => {
|
||||||
|
const dir = path.join(stacksDir, "backups", "acme");
|
||||||
|
await mkdir(dir, { recursive: true });
|
||||||
|
const regular = backupFileName("acme", "regular", new Date("2026-10-01T03:00:00Z"));
|
||||||
|
await writeFile(path.join(dir, regular), "r");
|
||||||
|
for (const day of ["02", "03", "04"]) await writeFile(path.join(dir, backupFileName("acme", "safety", new Date(`2026-10-${day}T03:00:00Z`))), "s");
|
||||||
|
|
||||||
|
await performBackup("acme", "safety", logs().ctx, new Date("2026-10-05T03:00:00Z"));
|
||||||
|
|
||||||
|
const names = (await readdir(dir)).sort();
|
||||||
|
expect(names).toContain(regular);
|
||||||
|
expect(names.filter((n) => n.includes("__safety"))).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits job steps in order when given a step wrapper", async () => {
|
||||||
|
const order: string[] = [];
|
||||||
|
await performBackup("acme", "regular", { log: () => undefined, step: async (id, fn) => (order.push(id), fn()) });
|
||||||
|
expect(order).toEqual(["dump", "media", "config", "archive", "verify", "finish"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { generateBusinessEnv, type BusinessEnvValues } from "../compose-template";
|
||||||
|
|
||||||
|
const baseValues: BusinessEnvValues = {
|
||||||
|
name: "Acme Barbers",
|
||||||
|
domain: "acme.example.com",
|
||||||
|
databaseName: "acme",
|
||||||
|
databaseUser: "acme",
|
||||||
|
databasePassword: "db-password",
|
||||||
|
authSecret: "auth-secret",
|
||||||
|
installationToken: "the-installation-token-value",
|
||||||
|
cronSecret: "cron-secret",
|
||||||
|
calendarTokenEncryptionKey: "calendar-token-encryption-key",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("generateBusinessEnv — INSTALLATION_TOKEN", () => {
|
||||||
|
it("writes the given installation token into the generated .env", () => {
|
||||||
|
const env = generateBusinessEnv(baseValues);
|
||||||
|
expect(env).toContain('INSTALLATION_TOKEN="the-installation-token-value"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never writes the token in a NEXT_PUBLIC_* (build-time-inlined) variable", () => {
|
||||||
|
const env = generateBusinessEnv(baseValues);
|
||||||
|
const publicLines = env.split("\n").filter((line) => line.startsWith("NEXT_PUBLIC_"));
|
||||||
|
for (const line of publicLines) {
|
||||||
|
expect(line).not.toContain("the-installation-token-value");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is the only place a business's INSTALLATION_TOKEN gets written — every business gets a distinct token if given one", () => {
|
||||||
|
const envA = generateBusinessEnv({ ...baseValues, installationToken: "token-a" });
|
||||||
|
const envB = generateBusinessEnv({ ...baseValues, domain: "other.example.com", installationToken: "token-b" });
|
||||||
|
expect(envA).toContain('INSTALLATION_TOKEN="token-a"');
|
||||||
|
expect(envB).toContain('INSTALLATION_TOKEN="token-b"');
|
||||||
|
expect(envA).not.toContain("token-b");
|
||||||
|
expect(envB).not.toContain("token-a");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("generateBusinessEnv — AUTH_URL", () => {
|
||||||
|
it("sets AUTH_URL explicitly to the business's own https domain — not left to header-based detection", () => {
|
||||||
|
const env = generateBusinessEnv(baseValues);
|
||||||
|
expect(env).toContain('AUTH_URL="https://acme.example.com"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("AUTH_URL always matches DOMAIN/NEXT_PUBLIC_APP_URL for a given business", () => {
|
||||||
|
const env = generateBusinessEnv({ ...baseValues, domain: "other-business.example.com" });
|
||||||
|
expect(env).toContain('DOMAIN="other-business.example.com"');
|
||||||
|
expect(env).toContain('AUTH_URL="https://other-business.example.com"');
|
||||||
|
expect(env).toContain('NEXT_PUBLIC_APP_URL="https://other-business.example.com"');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("generateBusinessEnv — SUPER_ADMIN_*", () => {
|
||||||
|
it("leaves SUPER_ADMIN_EMAIL blank when no global default is configured", () => {
|
||||||
|
const env = generateBusinessEnv(baseValues);
|
||||||
|
expect(env).toContain('SUPER_ADMIN_EMAIL=""');
|
||||||
|
expect(env).toContain('SUPER_ADMIN_FIRST_NAME=""');
|
||||||
|
expect(env).toContain('SUPER_ADMIN_LAST_NAME=""');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bakes in the global default Super Administrator when given one", () => {
|
||||||
|
const env = generateBusinessEnv({
|
||||||
|
...baseValues,
|
||||||
|
superAdmin: { email: "lazis002@gmail.com", firstName: "Stefan", lastName: "Lazic" },
|
||||||
|
});
|
||||||
|
expect(env).toContain('SUPER_ADMIN_EMAIL="lazis002@gmail.com"');
|
||||||
|
expect(env).toContain('SUPER_ADMIN_FIRST_NAME="Stefan"');
|
||||||
|
expect(env).toContain('SUPER_ADMIN_LAST_NAME="Lazic"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never writes a SUPER_ADMIN_PASSWORD line — sign-in is OTP-only", () => {
|
||||||
|
const env = generateBusinessEnv({
|
||||||
|
...baseValues,
|
||||||
|
superAdmin: { email: "lazis002@gmail.com", firstName: "Stefan", lastName: "Lazic" },
|
||||||
|
});
|
||||||
|
expect(env).not.toContain("SUPER_ADMIN_PASSWORD");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("generateBusinessEnv — GOOGLE_CALENDAR_* / CALENDAR_TOKEN_ENCRYPTION_KEY", () => {
|
||||||
|
it("leaves the OAuth client blank when no global default is configured", () => {
|
||||||
|
const env = generateBusinessEnv(baseValues);
|
||||||
|
expect(env).toContain('GOOGLE_CALENDAR_CLIENT_ID=""');
|
||||||
|
expect(env).toContain('GOOGLE_CALENDAR_CLIENT_SECRET=""');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bakes in the global default OAuth client when given one", () => {
|
||||||
|
const env = generateBusinessEnv({
|
||||||
|
...baseValues,
|
||||||
|
googleCalendar: { clientId: "abc.apps.googleusercontent.com", clientSecret: "shh" },
|
||||||
|
});
|
||||||
|
expect(env).toContain('GOOGLE_CALENDAR_CLIENT_ID="abc.apps.googleusercontent.com"');
|
||||||
|
expect(env).toContain('GOOGLE_CALENDAR_CLIENT_SECRET="shh"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("always derives the redirect URI from this business's own domain, regardless of the shared OAuth client", () => {
|
||||||
|
const env = generateBusinessEnv({
|
||||||
|
...baseValues,
|
||||||
|
domain: "acme.example.com",
|
||||||
|
googleCalendar: { clientId: "abc.apps.googleusercontent.com", clientSecret: "shh" },
|
||||||
|
});
|
||||||
|
expect(env).toContain('GOOGLE_CALENDAR_REDIRECT_URI="https://acme.example.com/api/staff/calendar/google/callback"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("always writes the given calendarTokenEncryptionKey, distinct per business, even when the OAuth client is shared", () => {
|
||||||
|
const envA = generateBusinessEnv({
|
||||||
|
...baseValues,
|
||||||
|
calendarTokenEncryptionKey: "key-a",
|
||||||
|
googleCalendar: { clientId: "abc.apps.googleusercontent.com", clientSecret: "shh" },
|
||||||
|
});
|
||||||
|
const envB = generateBusinessEnv({
|
||||||
|
...baseValues,
|
||||||
|
domain: "other.example.com",
|
||||||
|
calendarTokenEncryptionKey: "key-b",
|
||||||
|
googleCalendar: { clientId: "abc.apps.googleusercontent.com", clientSecret: "shh" },
|
||||||
|
});
|
||||||
|
expect(envA).toContain('CALENDAR_TOKEN_ENCRYPTION_KEY="key-a"');
|
||||||
|
expect(envB).toContain('CALENDAR_TOKEN_ENCRYPTION_KEY="key-b"');
|
||||||
|
expect(envA).not.toContain("key-b");
|
||||||
|
expect(envB).not.toContain("key-a");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
containerExistsCommand,
|
||||||
|
imageMigrationsCommand,
|
||||||
|
mediaCopyOutCommand,
|
||||||
|
mediaRestoreCommand,
|
||||||
|
mediaVolumeTarCommand,
|
||||||
|
mysqlImportCommand,
|
||||||
|
mysqlQueryCommand,
|
||||||
|
mysqldumpCommand,
|
||||||
|
} from "../docker-commands";
|
||||||
|
import { sevenZipCreateCommand, sevenZipExtractToStdoutCommand, sevenZipTestCommand } from "../sevenzip";
|
||||||
|
|
||||||
|
const ROOT_PASSWORD = "root-pw-for-tests";
|
||||||
|
|
||||||
|
describe("command construction", () => {
|
||||||
|
const saved = { pw: process.env.MYSQL_ROOT_PASSWORD, image: process.env.NEXTCHAIR_IMAGE, mysql: process.env.MYSQL_CONTAINER_NAME };
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.MYSQL_ROOT_PASSWORD = ROOT_PASSWORD;
|
||||||
|
delete process.env.NEXTCHAIR_IMAGE;
|
||||||
|
delete process.env.MYSQL_CONTAINER_NAME;
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
for (const [key, value] of [["MYSQL_ROOT_PASSWORD", saved.pw], ["NEXTCHAIR_IMAGE", saved.image], ["MYSQL_CONTAINER_NAME", saved.mysql]] as const) {
|
||||||
|
if (value === undefined) delete process.env[key];
|
||||||
|
else process.env[key] = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mysqldump: single-transaction + routines + triggers, as an argument array, password only in env", () => {
|
||||||
|
const spec = mysqldumpCommand("acme");
|
||||||
|
expect(spec.command).toBe("docker");
|
||||||
|
expect(spec.args).toEqual([
|
||||||
|
"exec", "-e", "MYSQL_PWD", "next-chair-mysql", "mysqldump", "-uroot",
|
||||||
|
"--single-transaction", "--routines", "--triggers", "--no-tablespaces", "--hex-blob",
|
||||||
|
"--default-character-set=utf8mb4", "acme",
|
||||||
|
]);
|
||||||
|
expect(spec.env).toEqual({ MYSQL_PWD: ROOT_PASSWORD });
|
||||||
|
expect(spec.args.join(" ")).not.toContain(ROOT_PASSWORD);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mysql import reads the dump on stdin (-i) and also keeps the password out of argv", () => {
|
||||||
|
const spec = mysqlImportCommand("acme");
|
||||||
|
expect(spec.args).toEqual(["exec", "-i", "-e", "MYSQL_PWD", "next-chair-mysql", "mysql", "-uroot", "--default-character-set=utf8mb4", "acme"]);
|
||||||
|
expect(spec.args.join(" ")).not.toContain(ROOT_PASSWORD);
|
||||||
|
expect(spec.env?.MYSQL_PWD).toBe(ROOT_PASSWORD);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mysql query passes the SQL as a single argument (never through a shell)", () => {
|
||||||
|
const spec = mysqlQueryCommand("acme", "SELECT 1; SELECT 2");
|
||||||
|
expect(spec.args.at(-2)).toBe("-e");
|
||||||
|
expect(spec.args.at(-1)).toBe("SELECT 1; SELECT 2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("honours MYSQL_CONTAINER_NAME and NEXTCHAIR_IMAGE overrides", () => {
|
||||||
|
process.env.MYSQL_CONTAINER_NAME = "custom-mysql";
|
||||||
|
process.env.NEXTCHAIR_IMAGE = "registry.example/app:1.2";
|
||||||
|
expect(mysqldumpCommand("acme").args).toContain("custom-mysql");
|
||||||
|
expect(imageMigrationsCommand().args).toEqual(["run", "--rm", "--entrypoint", "ls", "registry.example/app:1.2", "/app/prisma/migrations"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("media is streamed out with docker cp to stdout, or read from the volume when the container is gone", () => {
|
||||||
|
expect(mediaCopyOutCommand("next-chair-acme").args).toEqual(["cp", "next-chair-acme:/app/storage/media", "-"]);
|
||||||
|
expect(mediaVolumeTarCommand("acme").args).toEqual([
|
||||||
|
"run", "--rm", "-v", "next-chair-media-acme:/app/storage/media:ro", "--entrypoint", "tar", "nextchair-app:latest",
|
||||||
|
"-c", "-C", "/app/storage", "media",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("media restore unpacks stdin as root into the volume, then chowns to 1001:1001", () => {
|
||||||
|
const spec = mediaRestoreCommand("acme");
|
||||||
|
expect(spec.args.slice(0, 9)).toEqual(["run", "--rm", "-i", "--user", "0", "-v", "next-chair-media-acme:/app/storage/media", "--entrypoint", "sh"]);
|
||||||
|
expect(spec.args.at(-1)).toBe("tar -x -C /app/storage && chown -R 1001:1001 /app/storage/media");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("container existence check", () => {
|
||||||
|
expect(containerExistsCommand("next-chair-acme").args).toEqual(["container", "inspect", "--format", "{{.Id}}", "next-chair-acme"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires MYSQL_ROOT_PASSWORD", () => {
|
||||||
|
delete process.env.MYSQL_ROOT_PASSWORD;
|
||||||
|
expect(() => mysqldumpCommand("acme")).toThrow(/MYSQL_ROOT_PASSWORD/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("7zz command construction", () => {
|
||||||
|
it("create: plain 7z (no password), listed entries, run from the staging dir", () => {
|
||||||
|
const spec = sevenZipCreateCommand({
|
||||||
|
archivePath: "/stacks/backups/acme/x.7z.partial",
|
||||||
|
cwd: "/stacks/manager/tmp/s1",
|
||||||
|
entries: ["manifest.json", "db.sql", "media.tar", "config"],
|
||||||
|
});
|
||||||
|
expect(spec.command).toBe("7zz");
|
||||||
|
expect(spec.cwd).toBe("/stacks/manager/tmp/s1");
|
||||||
|
expect(spec.args).toEqual([
|
||||||
|
"a", "-t7z", "-mx=3", "-y", "-bd",
|
||||||
|
"/stacks/backups/acme/x.7z.partial", "manifest.json", "db.sql", "media.tar", "config",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never asks 7zz for encryption or a password", () => {
|
||||||
|
const all = [
|
||||||
|
...sevenZipCreateCommand({ archivePath: "/a.7z", cwd: "/tmp", entries: ["x"] }).args,
|
||||||
|
...sevenZipTestCommand("/a.7z").args,
|
||||||
|
...sevenZipExtractToStdoutCommand("/a.7z", "db.sql").args,
|
||||||
|
];
|
||||||
|
expect(all.some((arg) => arg.startsWith("-p") || arg.startsWith("-mhe"))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test and stdout-extract commands", () => {
|
||||||
|
expect(sevenZipTestCommand("/a.7z").args).toEqual(["t", "-t7z", "-y", "-bd", "/a.7z"]);
|
||||||
|
expect(sevenZipExtractToStdoutCommand("/a.7z", "db.sql").args).toEqual([
|
||||||
|
"x", "-so", "-t7z", "-y", "-bd", "-bso0", "-bsp0", "/a.7z", "db.sql",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("SEVENZIP_BIN overrides the binary", () => {
|
||||||
|
process.env.SEVENZIP_BIN = "/opt/7zz";
|
||||||
|
try {
|
||||||
|
expect(sevenZipTestCommand("/a.7z").command).toBe("/opt/7zz");
|
||||||
|
} finally {
|
||||||
|
delete process.env.SEVENZIP_BIN;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { mkdtemp, rm } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
isGoogleCalendarConfigComplete,
|
||||||
|
readGoogleCalendarSettingsView,
|
||||||
|
resolveGoogleCalendarSettings,
|
||||||
|
saveGoogleCalendarSettings,
|
||||||
|
saveEmailSettings,
|
||||||
|
readEmailSettingsView,
|
||||||
|
} from "../global-settings";
|
||||||
|
|
||||||
|
describe("global-settings — Google Calendar (encrypted client secret)", () => {
|
||||||
|
let stacksDir: string;
|
||||||
|
const originalStacksDir = process.env.STACKS_DIR;
|
||||||
|
const originalEncryptionKey = process.env.MANAGER_SETTINGS_ENCRYPTION_KEY;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
stacksDir = await mkdtemp(path.join(tmpdir(), "nc-manager-google-calendar-test-"));
|
||||||
|
process.env.STACKS_DIR = stacksDir;
|
||||||
|
process.env.MANAGER_SETTINGS_ENCRYPTION_KEY = "a".repeat(64);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (originalStacksDir === undefined) delete process.env.STACKS_DIR;
|
||||||
|
else process.env.STACKS_DIR = originalStacksDir;
|
||||||
|
if (originalEncryptionKey === undefined) delete process.env.MANAGER_SETTINGS_ENCRYPTION_KEY;
|
||||||
|
else process.env.MANAGER_SETTINGS_ENCRYPTION_KEY = originalEncryptionKey;
|
||||||
|
await rm(stacksDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the empty default before anything has been saved", async () => {
|
||||||
|
expect(await readGoogleCalendarSettingsView()).toEqual({ clientId: "", hasClientSecret: false });
|
||||||
|
expect(await resolveGoogleCalendarSettings()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saves and reads back the client ID/secret, never exposing the secret in the view", async () => {
|
||||||
|
await saveGoogleCalendarSettings({ clientId: "abc.apps.googleusercontent.com", clientSecret: "top-secret" });
|
||||||
|
|
||||||
|
const view = await readGoogleCalendarSettingsView();
|
||||||
|
expect(view).toEqual({ clientId: "abc.apps.googleusercontent.com", hasClientSecret: true });
|
||||||
|
|
||||||
|
const resolved = await resolveGoogleCalendarSettings();
|
||||||
|
expect(resolved).toEqual({ clientId: "abc.apps.googleusercontent.com", clientSecret: "top-secret" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never stores the client secret in plaintext on disk", async () => {
|
||||||
|
await saveGoogleCalendarSettings({ clientId: "abc.apps.googleusercontent.com", clientSecret: "top-secret" });
|
||||||
|
|
||||||
|
const { readFile } = await import("node:fs/promises");
|
||||||
|
const raw = await readFile(path.join(stacksDir, "manager", "global-settings.json"), "utf8");
|
||||||
|
expect(raw).not.toContain("top-secret");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a blank clientSecret on save keeps whatever secret is already stored", async () => {
|
||||||
|
await saveGoogleCalendarSettings({ clientId: "abc.apps.googleusercontent.com", clientSecret: "top-secret" });
|
||||||
|
await saveGoogleCalendarSettings({ clientId: "abc.apps.googleusercontent.com", clientSecret: "" });
|
||||||
|
|
||||||
|
const resolved = await resolveGoogleCalendarSettings();
|
||||||
|
expect(resolved?.clientSecret).toBe("top-secret");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saving does not clobber email settings already stored in the same file", async () => {
|
||||||
|
await saveEmailSettings({
|
||||||
|
host: "smtp.example.com",
|
||||||
|
port: 587,
|
||||||
|
username: "user",
|
||||||
|
password: "pw",
|
||||||
|
encryption: "starttls",
|
||||||
|
fromAddress: "from@example.com",
|
||||||
|
fromName: "",
|
||||||
|
replyTo: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
await saveGoogleCalendarSettings({ clientId: "abc.apps.googleusercontent.com", clientSecret: "top-secret" });
|
||||||
|
|
||||||
|
const email = await readEmailSettingsView();
|
||||||
|
expect(email.host).toBe("smtp.example.com");
|
||||||
|
expect(email.hasPassword).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("isGoogleCalendarConfigComplete requires both a non-blank client ID and secret", () => {
|
||||||
|
expect(isGoogleCalendarConfigComplete({ clientId: "", clientSecret: "" })).toBe(false);
|
||||||
|
expect(isGoogleCalendarConfigComplete({ clientId: "abc", clientSecret: "" })).toBe(false);
|
||||||
|
expect(isGoogleCalendarConfigComplete({ clientId: "", clientSecret: "shh" })).toBe(false);
|
||||||
|
expect(isGoogleCalendarConfigComplete({ clientId: "abc", clientSecret: "shh" })).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { mkdtemp, rm } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { isSuperAdminConfigComplete, readSuperAdminSettings, saveSuperAdminSettings } from "../global-settings";
|
||||||
|
|
||||||
|
describe("global-settings — Super Administrator (not a secret, no encryption involved)", () => {
|
||||||
|
let stacksDir: string;
|
||||||
|
const originalStacksDir = process.env.STACKS_DIR;
|
||||||
|
const originalEncryptionKey = process.env.MANAGER_SETTINGS_ENCRYPTION_KEY;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
stacksDir = await mkdtemp(path.join(tmpdir(), "nc-manager-super-admin-test-"));
|
||||||
|
process.env.STACKS_DIR = stacksDir;
|
||||||
|
process.env.MANAGER_SETTINGS_ENCRYPTION_KEY = "a".repeat(64);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (originalStacksDir === undefined) delete process.env.STACKS_DIR;
|
||||||
|
else process.env.STACKS_DIR = originalStacksDir;
|
||||||
|
if (originalEncryptionKey === undefined) delete process.env.MANAGER_SETTINGS_ENCRYPTION_KEY;
|
||||||
|
else process.env.MANAGER_SETTINGS_ENCRYPTION_KEY = originalEncryptionKey;
|
||||||
|
await rm(stacksDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null before anything has been saved", async () => {
|
||||||
|
expect(await readSuperAdminSettings()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saves and reads back the email/first/last name, lowercasing and trimming the email", async () => {
|
||||||
|
await saveSuperAdminSettings({ email: " Lazis002@Gmail.com ", firstName: " Stefan ", lastName: " Lazic " });
|
||||||
|
|
||||||
|
const saved = await readSuperAdminSettings();
|
||||||
|
expect(saved).toEqual({ email: "lazis002@gmail.com", firstName: "Stefan", lastName: "Lazic" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saving does not clobber email/push settings already stored in the same file", async () => {
|
||||||
|
const { saveEmailSettings } = await import("../global-settings");
|
||||||
|
await saveEmailSettings({
|
||||||
|
host: "smtp.example.com",
|
||||||
|
port: 587,
|
||||||
|
username: "user",
|
||||||
|
password: "pw",
|
||||||
|
encryption: "starttls",
|
||||||
|
fromAddress: "from@example.com",
|
||||||
|
fromName: "",
|
||||||
|
replyTo: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
await saveSuperAdminSettings({ email: "lazis002@gmail.com", firstName: "Stefan", lastName: "Lazic" });
|
||||||
|
|
||||||
|
const { readEmailSettingsView } = await import("../global-settings");
|
||||||
|
const email = await readEmailSettingsView();
|
||||||
|
expect(email.host).toBe("smtp.example.com");
|
||||||
|
expect(email.hasPassword).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("isSuperAdminConfigComplete requires a non-blank email; name is optional", () => {
|
||||||
|
expect(isSuperAdminConfigComplete(null)).toBe(false);
|
||||||
|
expect(isSuperAdminConfigComplete({ email: "", firstName: "Stefan", lastName: "Lazic" })).toBe(false);
|
||||||
|
expect(isSuperAdminConfigComplete({ email: "lazis002@gmail.com", firstName: "", lastName: "" })).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const composeUpMock = vi.fn().mockResolvedValue(undefined);
|
||||||
|
vi.mock("@/lib/docker", () => ({
|
||||||
|
composeUp: (...args: unknown[]) => composeUpMock(...args),
|
||||||
|
composeDown: vi.fn(),
|
||||||
|
composeStatus: vi.fn().mockResolvedValue("stopped"),
|
||||||
|
reloadCaddy: vi.fn(),
|
||||||
|
runMigrations: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("applyGoogleCalendarSettingsToAllBusinesses", () => {
|
||||||
|
let stacksDir: string;
|
||||||
|
const originalStacksDir = process.env.STACKS_DIR;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
stacksDir = await mkdtemp(path.join(tmpdir(), "nc-manager-gcal-rollout-test-"));
|
||||||
|
process.env.STACKS_DIR = stacksDir;
|
||||||
|
composeUpMock.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (originalStacksDir === undefined) delete process.env.STACKS_DIR;
|
||||||
|
else process.env.STACKS_DIR = originalStacksDir;
|
||||||
|
await rm(stacksDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
async function createStack(slug: string, envContent: string) {
|
||||||
|
const dir = path.join(stacksDir, slug);
|
||||||
|
await mkdir(dir, { recursive: true });
|
||||||
|
await writeFile(path.join(dir, "compose.yaml"), "name: test\n", "utf8");
|
||||||
|
await writeFile(path.join(dir, ".env"), envContent, "utf8");
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("writes the shared client ID/secret into every business and force-recreates each container", async () => {
|
||||||
|
const { applyGoogleCalendarSettingsToAllBusinesses } = await import("../google-calendar-rollout");
|
||||||
|
await createStack("acme", ['BUSINESS_NAME="Acme"', 'DOMAIN="acme.example.com"', 'CALENDAR_TOKEN_ENCRYPTION_KEY="existing-key"'].join("\n") + "\n");
|
||||||
|
|
||||||
|
const results = await applyGoogleCalendarSettingsToAllBusinesses({
|
||||||
|
clientId: "shared-client-id",
|
||||||
|
clientSecret: "shared-client-secret",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(results).toEqual([{ slug: "acme", ok: true }]);
|
||||||
|
const env = await readFile(path.join(stacksDir, "acme", ".env"), "utf8");
|
||||||
|
expect(env).toContain('GOOGLE_CALENDAR_CLIENT_ID="shared-client-id"');
|
||||||
|
expect(env).toContain('GOOGLE_CALENDAR_CLIENT_SECRET="shared-client-secret"');
|
||||||
|
expect(composeUpMock).toHaveBeenCalledWith(path.join(stacksDir, "acme"), { forceRecreate: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never touches GOOGLE_CALENDAR_REDIRECT_URI — it stays derived from each business's own domain", async () => {
|
||||||
|
const { applyGoogleCalendarSettingsToAllBusinesses } = await import("../google-calendar-rollout");
|
||||||
|
await createStack(
|
||||||
|
"acme",
|
||||||
|
['BUSINESS_NAME="Acme"', 'DOMAIN="acme.example.com"', 'GOOGLE_CALENDAR_REDIRECT_URI="https://acme.example.com/api/staff/calendar/google/callback"'].join(
|
||||||
|
"\n",
|
||||||
|
) + "\n",
|
||||||
|
);
|
||||||
|
|
||||||
|
await applyGoogleCalendarSettingsToAllBusinesses({ clientId: "shared-client-id", clientSecret: "shared-client-secret" });
|
||||||
|
|
||||||
|
const env = await readFile(path.join(stacksDir, "acme", ".env"), "utf8");
|
||||||
|
expect(env).toContain('GOOGLE_CALENDAR_REDIRECT_URI="https://acme.example.com/api/staff/calendar/google/callback"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves an existing CALENDAR_TOKEN_ENCRYPTION_KEY untouched — it must never be shared across businesses", async () => {
|
||||||
|
const { applyGoogleCalendarSettingsToAllBusinesses } = await import("../google-calendar-rollout");
|
||||||
|
await createStack("acme", ['BUSINESS_NAME="Acme"', 'DOMAIN="acme.example.com"', 'CALENDAR_TOKEN_ENCRYPTION_KEY="already-set-key"'].join("\n") + "\n");
|
||||||
|
|
||||||
|
await applyGoogleCalendarSettingsToAllBusinesses({ clientId: "shared-client-id", clientSecret: "shared-client-secret" });
|
||||||
|
|
||||||
|
const env = await readFile(path.join(stacksDir, "acme", ".env"), "utf8");
|
||||||
|
expect(env).toContain('CALENDAR_TOKEN_ENCRYPTION_KEY="already-set-key"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("backfills a fresh, distinct CALENDAR_TOKEN_ENCRYPTION_KEY for a business that never had one (pre-fix)", async () => {
|
||||||
|
const { applyGoogleCalendarSettingsToAllBusinesses } = await import("../google-calendar-rollout");
|
||||||
|
await createStack("acme", ['BUSINESS_NAME="Acme"', 'DOMAIN="acme.example.com"', 'CALENDAR_TOKEN_ENCRYPTION_KEY=""'].join("\n") + "\n");
|
||||||
|
await createStack("other", ['BUSINESS_NAME="Other"', 'DOMAIN="other.example.com"', 'CALENDAR_TOKEN_ENCRYPTION_KEY=""'].join("\n") + "\n");
|
||||||
|
|
||||||
|
await applyGoogleCalendarSettingsToAllBusinesses({ clientId: "shared-client-id", clientSecret: "shared-client-secret" });
|
||||||
|
|
||||||
|
const envA = await readFile(path.join(stacksDir, "acme", ".env"), "utf8");
|
||||||
|
const envB = await readFile(path.join(stacksDir, "other", ".env"), "utf8");
|
||||||
|
const keyA = /CALENDAR_TOKEN_ENCRYPTION_KEY="([^"]*)"/.exec(envA)?.[1];
|
||||||
|
const keyB = /CALENDAR_TOKEN_ENCRYPTION_KEY="([^"]*)"/.exec(envB)?.[1];
|
||||||
|
expect(keyA).toBeTruthy();
|
||||||
|
expect(keyB).toBeTruthy();
|
||||||
|
expect(keyA).not.toBe(keyB);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports per-business failures without aborting the rest", async () => {
|
||||||
|
const { applyGoogleCalendarSettingsToAllBusinesses } = await import("../google-calendar-rollout");
|
||||||
|
await createStack("acme", ['BUSINESS_NAME="Acme"', 'DOMAIN="acme.example.com"'].join("\n") + "\n");
|
||||||
|
await createStack("broken", ['BUSINESS_NAME="Broken"', 'DOMAIN="broken.example.com"'].join("\n") + "\n");
|
||||||
|
composeUpMock.mockImplementation(async (dir: string) => {
|
||||||
|
if (dir.includes("broken")) throw new Error("container recreate failed");
|
||||||
|
});
|
||||||
|
|
||||||
|
const results = await applyGoogleCalendarSettingsToAllBusinesses({ clientId: "shared-client-id", clientSecret: "shared-client-secret" });
|
||||||
|
|
||||||
|
expect(results).toContainEqual({ slug: "acme", ok: true });
|
||||||
|
expect(results).toContainEqual({ slug: "broken", ok: false, error: "container recreate failed" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
import { mkdtemp, rm } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { DEFAULT_OPERATOR_SETTINGS } from "../global-settings";
|
||||||
|
import { classifyHealthBody, waitForHealth, type HealthSnapshot } from "../health";
|
||||||
|
import { applyObservation, buildAlertEmail, REMINDER_INTERVAL_MS, runHealthCheck, setIntentionallyStopped, type HealthRecord } from "../health-watcher";
|
||||||
|
|
||||||
|
const t0 = new Date("2026-10-17T12:00:00Z");
|
||||||
|
const at = (minutes: number) => new Date(t0.getTime() + minutes * 60_000);
|
||||||
|
const OK = { verdict: "ok" as const, reasons: [] };
|
||||||
|
const DOWN = { verdict: "down" as const, reasons: ["couldn't reach the app"] };
|
||||||
|
const DEGRADED = { verdict: "degraded" as const, reasons: ["12 notifications stale"] };
|
||||||
|
const opts = { dailyReminder: true };
|
||||||
|
|
||||||
|
describe("health state machine (pure)", () => {
|
||||||
|
it("one failed check does NOT alert; the second consecutive one does", () => {
|
||||||
|
const first = applyObservation("acme", undefined, DOWN, at(0), opts);
|
||||||
|
expect(first.alert).toBeNull();
|
||||||
|
expect(first.next.status).toBe("ok");
|
||||||
|
expect(first.next.pending).toEqual({ verdict: "down", count: 1 });
|
||||||
|
|
||||||
|
const second = applyObservation("acme", first.next, DOWN, at(3), opts);
|
||||||
|
expect(second.alert).toMatchObject({ type: "problem", from: "ok", to: "down" });
|
||||||
|
expect(second.next.status).toBe("down");
|
||||||
|
expect(second.next.pending).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a single blip in between resets the count", () => {
|
||||||
|
const a = applyObservation("acme", undefined, DOWN, at(0), opts);
|
||||||
|
const b = applyObservation("acme", a.next, OK, at(3), opts);
|
||||||
|
const c = applyObservation("acme", b.next, DOWN, at(6), opts);
|
||||||
|
expect(c.alert).toBeNull();
|
||||||
|
expect(c.next.pending?.count).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("alerts on a change between bad states and on recovery", () => {
|
||||||
|
let rec: HealthRecord | undefined;
|
||||||
|
const run = (obs: typeof OK | typeof DOWN | typeof DEGRADED, min: number) => {
|
||||||
|
const r = applyObservation("acme", rec, obs, at(min), opts);
|
||||||
|
rec = r.next;
|
||||||
|
return r.alert;
|
||||||
|
};
|
||||||
|
run(DEGRADED, 0);
|
||||||
|
expect(run(DEGRADED, 3)).toMatchObject({ type: "problem", to: "degraded" });
|
||||||
|
run(DOWN, 6);
|
||||||
|
expect(run(DOWN, 9)).toMatchObject({ type: "changed", from: "degraded", to: "down" });
|
||||||
|
// Recovery reports when the bad period began (the down state started at minute 9).
|
||||||
|
expect(run(OK, 12)).toMatchObject({ type: "recovered", from: "down", to: "ok", since: at(9).toISOString() });
|
||||||
|
expect(run(OK, 15)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends a reminder once a day while still bad — not before, and not for ok", () => {
|
||||||
|
const bad = applyObservation("acme", applyObservation("acme", undefined, DOWN, at(0), opts).next, DOWN, at(3), opts);
|
||||||
|
const alerted: HealthRecord = { ...bad.next, lastAlertAt: at(3).toISOString() };
|
||||||
|
|
||||||
|
const early = applyObservation("acme", alerted, DOWN, new Date(at(3).getTime() + REMINDER_INTERVAL_MS - 60_000), opts);
|
||||||
|
expect(early.alert).toBeNull();
|
||||||
|
|
||||||
|
const due = applyObservation("acme", alerted, DOWN, new Date(at(3).getTime() + REMINDER_INTERVAL_MS), opts);
|
||||||
|
expect(due.alert).toMatchObject({ type: "reminder", to: "down" });
|
||||||
|
|
||||||
|
const off = applyObservation("acme", alerted, DOWN, new Date(at(3).getTime() + REMINDER_INTERVAL_MS), { dailyReminder: false });
|
||||||
|
expect(off.alert).toBeNull();
|
||||||
|
|
||||||
|
const healthy = applyObservation("acme", undefined, OK, new Date(t0.getTime() + 3 * REMINDER_INTERVAL_MS), opts);
|
||||||
|
expect(healthy.alert).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("alert emails say what happened and never need a secret", () => {
|
||||||
|
const problem = buildAlertEmail({ type: "problem", slug: "acme", from: "ok", to: "down", reasons: ["connection refused"], since: at(3).toISOString(), raisedAt: at(3).toISOString() }, "Acme Barbers", "acme.example.com");
|
||||||
|
expect(problem.subject).toBe("Acme Barbers (acme.example.com) is DOWN");
|
||||||
|
expect(problem.text).toContain("connection refused");
|
||||||
|
const recovered = buildAlertEmail({ type: "recovered", slug: "acme", from: "down", to: "ok", reasons: [], since: at(9).toISOString(), raisedAt: at(9).toISOString() }, "Acme Barbers", null);
|
||||||
|
expect(recovered.subject).toBe("Acme Barbers has recovered");
|
||||||
|
expect(recovered.text).toContain(`was down since ${at(9).toISOString()}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("classifyHealthBody — consuming the app's /api/health", () => {
|
||||||
|
it("treats today's body (no queue/cron fields) as healthy by reachability", () => {
|
||||||
|
const s = classifyHealthBody(200, { status: "healthy", database: "reachable", installed: true, installedAt: "2026-01-01T00:00:00.000Z" });
|
||||||
|
expect(s).toMatchObject({ verdict: "ok", reasons: [], installed: true, queue: null, lastCronRunAt: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a non-200 or non-JSON response is down", () => {
|
||||||
|
expect(classifyHealthBody(503, { status: "unhealthy" }).verdict).toBe("down");
|
||||||
|
expect(classifyHealthBody(200, null).verdict).toBe("down");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("consumes the planned queue/cron fields and degraded status with reasons", () => {
|
||||||
|
const s = classifyHealthBody(200, {
|
||||||
|
status: "degraded",
|
||||||
|
installed: true,
|
||||||
|
degradedReasons: ["12 notifications pending for over 10 minutes", "cron hasn't run since 11:00"],
|
||||||
|
queue: { stalePendingCount: 12, oldestPendingAgeSeconds: 900, stuckProcessingCount: 1, failedLast24h: 3 },
|
||||||
|
cron: { lastCronRunAt: "2026-10-17T11:00:00.000Z" },
|
||||||
|
});
|
||||||
|
expect(s.verdict).toBe("degraded");
|
||||||
|
expect(s.reasons).toHaveLength(2);
|
||||||
|
expect(s.queue).toEqual({ stalePendingCount: 12, oldestPendingAgeSeconds: 900, stuckProcessingCount: 1, failedLast24h: 3 });
|
||||||
|
expect(s.lastCronRunAt).toBe("2026-10-17T11:00:00.000Z");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("status ok with the new fields present is still ok", () => {
|
||||||
|
expect(classifyHealthBody(200, { status: "ok", installed: true, queue: { stalePendingCount: 0 }, cron: {} }).verdict).toBe("ok");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("degraded with no reasons still gets a reason", () => {
|
||||||
|
expect(classifyHealthBody(200, { status: "degraded" }).reasons).toEqual(["the app reports itself degraded"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("waitForHealth", () => {
|
||||||
|
const snap = (over: Partial<HealthSnapshot>): HealthSnapshot => ({ verdict: "ok", reasons: [], httpStatus: 200, installed: true, installedAt: null, lastCronRunAt: null, queue: null, ...over });
|
||||||
|
|
||||||
|
it("returns once the app answers 200", async () => {
|
||||||
|
const probe = vi.fn().mockResolvedValueOnce(snap({ httpStatus: null, verdict: "down" })).mockResolvedValueOnce(snap({}));
|
||||||
|
await expect(waitForHealth("acme", { probe, sleep: async () => undefined })).resolves.toMatchObject({ httpStatus: 200 });
|
||||||
|
expect(probe).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restore mode insists on installed:true — a reachable but un-installed app times out with a wizard warning", async () => {
|
||||||
|
const probe = vi.fn().mockResolvedValue(snap({ installed: false }));
|
||||||
|
await expect(waitForHealth("acme", { probe, sleep: async () => undefined, timeoutMs: 0, requireInstalled: true })).rejects.toThrow(/setup wizard/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("times out with the reasons when never reachable", async () => {
|
||||||
|
const probe = vi.fn().mockResolvedValue(snap({ httpStatus: null, verdict: "down", reasons: ["connection refused"] }));
|
||||||
|
await expect(waitForHealth("acme", { probe, sleep: async () => undefined, timeoutMs: 0 })).rejects.toThrow(/did not become healthy.*connection refused/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("runHealthCheck (state file + email wiring)", () => {
|
||||||
|
let stacksDir: string;
|
||||||
|
const originalStacksDir = process.env.STACKS_DIR;
|
||||||
|
beforeEach(async () => {
|
||||||
|
stacksDir = await mkdtemp(path.join(tmpdir(), "nc-manager-health-"));
|
||||||
|
process.env.STACKS_DIR = stacksDir;
|
||||||
|
});
|
||||||
|
afterEach(async () => {
|
||||||
|
if (originalStacksDir === undefined) delete process.env.STACKS_DIR;
|
||||||
|
else process.env.STACKS_DIR = originalStacksDir;
|
||||||
|
await rm(stacksDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
const snapshot = (verdict: "ok" | "down" | "degraded"): HealthSnapshot => ({ verdict, reasons: verdict === "ok" ? [] : ["bad"], httpStatus: verdict === "down" ? null : 200, installed: true, installedAt: null, lastCronRunAt: null, queue: null });
|
||||||
|
|
||||||
|
function deps(overrides: Record<string, unknown> = {}) {
|
||||||
|
let verdict: "ok" | "down" | "degraded" = "down";
|
||||||
|
let clock = t0.getTime();
|
||||||
|
const sendEmail = vi.fn().mockResolvedValue({ ok: true, message: "sent" });
|
||||||
|
return {
|
||||||
|
sendEmail,
|
||||||
|
set: (v: typeof verdict) => (verdict = v),
|
||||||
|
advance: (min: number) => (clock += min * 60_000),
|
||||||
|
overrides: {
|
||||||
|
probe: async () => snapshot(verdict),
|
||||||
|
sendEmail,
|
||||||
|
now: () => new Date(clock),
|
||||||
|
isBusy: async () => false,
|
||||||
|
readSettings: async () => ({ ...DEFAULT_OPERATOR_SETTINGS, alertEmail: "ops@example.com" }),
|
||||||
|
describe: async () => ({ label: "Acme", domain: "acme.example.com" }),
|
||||||
|
...overrides,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it("emails once after two failures, once on recovery, and nothing in between", async () => {
|
||||||
|
const d = deps();
|
||||||
|
await runHealthCheck("acme", d.overrides);
|
||||||
|
expect(d.sendEmail).not.toHaveBeenCalled();
|
||||||
|
d.advance(3);
|
||||||
|
await runHealthCheck("acme", d.overrides);
|
||||||
|
expect(d.sendEmail).toHaveBeenCalledTimes(1);
|
||||||
|
expect(d.sendEmail.mock.calls[0]?.[0]).toContain("is DOWN");
|
||||||
|
d.advance(3);
|
||||||
|
await runHealthCheck("acme", d.overrides);
|
||||||
|
expect(d.sendEmail).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
d.set("ok");
|
||||||
|
d.advance(3);
|
||||||
|
await runHealthCheck("acme", d.overrides);
|
||||||
|
expect(d.sendEmail).toHaveBeenCalledTimes(2);
|
||||||
|
expect(d.sendEmail.mock.calls[1]?.[0]).toContain("recovered");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("repeats once a day while still bad", async () => {
|
||||||
|
const d = deps();
|
||||||
|
await runHealthCheck("acme", d.overrides);
|
||||||
|
d.advance(3);
|
||||||
|
await runHealthCheck("acme", d.overrides);
|
||||||
|
d.advance(23 * 60);
|
||||||
|
await runHealthCheck("acme", d.overrides);
|
||||||
|
expect(d.sendEmail).toHaveBeenCalledTimes(1);
|
||||||
|
d.advance(60 + 3);
|
||||||
|
await runHealthCheck("acme", d.overrides);
|
||||||
|
expect(d.sendEmail).toHaveBeenCalledTimes(2);
|
||||||
|
expect(d.sendEmail.mock.calls[1]?.[0]).toContain("Still DOWN");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retries an alert whose email failed, instead of losing it", async () => {
|
||||||
|
const d = deps();
|
||||||
|
d.sendEmail.mockResolvedValueOnce({ ok: false, message: "SMTP down" });
|
||||||
|
await runHealthCheck("acme", d.overrides);
|
||||||
|
d.advance(3);
|
||||||
|
await runHealthCheck("acme", d.overrides); // alert raised, send fails
|
||||||
|
expect(d.sendEmail).toHaveBeenCalledTimes(1);
|
||||||
|
d.advance(3);
|
||||||
|
await runHealthCheck("acme", d.overrides); // same state, but the unsent alert goes out now
|
||||||
|
expect(d.sendEmail).toHaveBeenCalledTimes(2);
|
||||||
|
d.advance(3);
|
||||||
|
await runHealthCheck("acme", d.overrides);
|
||||||
|
expect(d.sendEmail).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends nothing when no alert address is set or the toggle is off", async () => {
|
||||||
|
const noAddress = deps({ readSettings: async () => ({ ...DEFAULT_OPERATOR_SETTINGS, alertEmail: "" }) });
|
||||||
|
await runHealthCheck("acme", noAddress.overrides);
|
||||||
|
noAddress.advance(3);
|
||||||
|
await runHealthCheck("acme", noAddress.overrides);
|
||||||
|
expect(noAddress.sendEmail).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
const toggledOff = deps({ readSettings: async () => ({ ...DEFAULT_OPERATOR_SETTINGS, alertEmail: "ops@example.com", alertOnHealth: false }) });
|
||||||
|
await runHealthCheck("zeta", toggledOff.overrides);
|
||||||
|
toggledOff.advance(3);
|
||||||
|
await runHealthCheck("zeta", toggledOff.overrides);
|
||||||
|
expect(toggledOff.sendEmail).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("doesn't probe a business that is mid-job or was stopped on purpose", async () => {
|
||||||
|
const probe = vi.fn().mockResolvedValue(snapshot("down"));
|
||||||
|
const busy = deps({ isBusy: async () => true, probe });
|
||||||
|
expect(await runHealthCheck("acme", busy.overrides)).toMatchObject({ skipped: expect.stringContaining("job") });
|
||||||
|
|
||||||
|
await setIntentionallyStopped("zeta", true);
|
||||||
|
const stopped = deps({ probe });
|
||||||
|
expect(await runHealthCheck("zeta", stopped.overrides)).toMatchObject({ skipped: "intentionally stopped" });
|
||||||
|
expect(probe).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await setIntentionallyStopped("zeta", false);
|
||||||
|
await runHealthCheck("zeta", stopped.overrides);
|
||||||
|
expect(probe).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import { mkdtemp, rm, writeFile, mkdir } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { fetchInstallationStatus, getInstallationToken, resolveInstallationView } from "../installation-status";
|
||||||
|
|
||||||
|
describe("fetchInstallationStatus", () => {
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports installed with installedAt when the tenant's /api/health says installed: true", async () => {
|
||||||
|
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ status: "healthy", database: "reachable", installed: true, installedAt: "2026-01-15T10:00:00.000Z" }),
|
||||||
|
}) as unknown as typeof fetch;
|
||||||
|
|
||||||
|
const result = await fetchInstallationStatus("acme");
|
||||||
|
expect(result).toEqual({ status: "installed", installedAt: "2026-01-15T10:00:00.000Z" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports not_installed (with no installedAt) when installed: false", async () => {
|
||||||
|
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ status: "healthy", database: "reachable", installed: false, installedAt: null }),
|
||||||
|
}) as unknown as typeof fetch;
|
||||||
|
|
||||||
|
const result = await fetchInstallationStatus("acme");
|
||||||
|
expect(result).toEqual({ status: "not_installed", installedAt: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports unknown — never not_installed — when the fetch throws (container down/unreachable)", async () => {
|
||||||
|
globalThis.fetch = vi.fn().mockRejectedValue(new Error("ECONNREFUSED"));
|
||||||
|
|
||||||
|
const result = await fetchInstallationStatus("acme");
|
||||||
|
expect(result).toEqual({ status: "unknown", installedAt: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports unknown when the response isn't ok (e.g. 503 unhealthy)", async () => {
|
||||||
|
globalThis.fetch = vi.fn().mockResolvedValue({ ok: false, json: async () => ({}) }) as unknown as typeof fetch;
|
||||||
|
|
||||||
|
const result = await fetchInstallationStatus("acme");
|
||||||
|
expect(result).toEqual({ status: "unknown", installedAt: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports unknown for a malformed body (no boolean `installed` field)", async () => {
|
||||||
|
globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ unexpected: true }) }) as unknown as typeof fetch;
|
||||||
|
|
||||||
|
const result = await fetchInstallationStatus("acme");
|
||||||
|
expect(result).toEqual({ status: "unknown", installedAt: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("targets the internal docker network hostname for the given slug, not the public domain", async () => {
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ installed: false }) });
|
||||||
|
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||||
|
|
||||||
|
await fetchInstallationStatus("acme-barbers");
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith("http://next-chair-acme-barbers:3000/api/health", expect.any(Object));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getInstallationToken / resolveInstallationView", () => {
|
||||||
|
let stacksDir: string;
|
||||||
|
const originalStacksDir = process.env.STACKS_DIR;
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
stacksDir = await mkdtemp(path.join(tmpdir(), "nc-manager-test-"));
|
||||||
|
process.env.STACKS_DIR = stacksDir;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
if (originalStacksDir === undefined) delete process.env.STACKS_DIR;
|
||||||
|
else process.env.STACKS_DIR = originalStacksDir;
|
||||||
|
await rm(stacksDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
async function writeStackEnv(slug: string, envContent: string) {
|
||||||
|
const dir = path.join(stacksDir, slug);
|
||||||
|
await mkdir(dir, { recursive: true });
|
||||||
|
await writeFile(path.join(dir, ".env"), envContent, "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
it("reads the existing token straight from that business's own .env — an already-generated token is preserved, not regenerated", async () => {
|
||||||
|
await writeStackEnv("acme", 'BUSINESS_NAME="Acme"\nINSTALLATION_TOKEN="existing-token-value"\n');
|
||||||
|
|
||||||
|
const token = await getInstallationToken("acme");
|
||||||
|
expect(token).toBe("existing-token-value");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null (not an error) for a business with no token in its .env", async () => {
|
||||||
|
await writeStackEnv("legacy-biz", 'BUSINESS_NAME="Legacy"\n');
|
||||||
|
|
||||||
|
const token = await getInstallationToken("legacy-biz");
|
||||||
|
expect(token).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for a business directory that doesn't exist", async () => {
|
||||||
|
const token = await getInstallationToken("does-not-exist");
|
||||||
|
expect(token).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tenant isolation: never returns another business's token", async () => {
|
||||||
|
await writeStackEnv("tenant-a", 'INSTALLATION_TOKEN="token-for-a"\n');
|
||||||
|
await writeStackEnv("tenant-b", 'INSTALLATION_TOKEN="token-for-b"\n');
|
||||||
|
|
||||||
|
expect(await getInstallationToken("tenant-a")).toBe("token-for-a");
|
||||||
|
expect(await getInstallationToken("tenant-b")).toBe("token-for-b");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolveInstallationView never includes a token once installed — the token is not even read from disk in that branch", async () => {
|
||||||
|
await writeStackEnv("acme", 'INSTALLATION_TOKEN="should-never-appear"\n');
|
||||||
|
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ installed: true, installedAt: "2026-01-15T10:00:00.000Z" }),
|
||||||
|
}) as unknown as typeof fetch;
|
||||||
|
|
||||||
|
const view = await resolveInstallationView("acme", "acme.example.com");
|
||||||
|
|
||||||
|
expect(view).toEqual({ kind: "installed", installedAt: "2026-01-15T10:00:00.000Z" });
|
||||||
|
expect(JSON.stringify(view)).not.toContain("should-never-appear");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolveInstallationView surfaces the token pre-install, with the public install URL", async () => {
|
||||||
|
await writeStackEnv("acme", 'INSTALLATION_TOKEN="real-token"\n');
|
||||||
|
globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ installed: false }) }) as unknown as typeof fetch;
|
||||||
|
|
||||||
|
const view = await resolveInstallationView("acme", "acme.example.com");
|
||||||
|
|
||||||
|
expect(view).toEqual({ kind: "not_installed", token: "real-token", installUrl: "https://acme.example.com/install" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolveInstallationView still surfaces the token (not hidden) when status can't be confirmed", async () => {
|
||||||
|
await writeStackEnv("acme", 'INSTALLATION_TOKEN="real-token"\n');
|
||||||
|
globalThis.fetch = vi.fn().mockRejectedValue(new Error("ECONNREFUSED"));
|
||||||
|
|
||||||
|
const view = await resolveInstallationView("acme", "acme.example.com");
|
||||||
|
|
||||||
|
expect(view.kind).toBe("unknown");
|
||||||
|
expect(view).toMatchObject({ token: "real-token" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { pruneOldJobs, readJob, recoverInterruptedJobs, startJob } from "../jobs";
|
||||||
|
import { BusinessLockedError, getActiveLock, readLock } from "../locks";
|
||||||
|
|
||||||
|
function deferred<T = void>() {
|
||||||
|
let resolve!: (value: T) => void;
|
||||||
|
const promise = new Promise<T>((r) => (resolve = r));
|
||||||
|
return { promise, resolve };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("job runner", () => {
|
||||||
|
let stacksDir: string;
|
||||||
|
const originalStacksDir = process.env.STACKS_DIR;
|
||||||
|
beforeEach(async () => {
|
||||||
|
stacksDir = await mkdtemp(path.join(tmpdir(), "nc-manager-jobs-"));
|
||||||
|
process.env.STACKS_DIR = stacksDir;
|
||||||
|
});
|
||||||
|
afterEach(async () => {
|
||||||
|
if (originalStacksDir === undefined) delete process.env.STACKS_DIR;
|
||||||
|
else process.env.STACKS_DIR = originalStacksDir;
|
||||||
|
await rm(stacksDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
const steps = [
|
||||||
|
{ id: "one", label: "First" },
|
||||||
|
{ id: "two", label: "Second" },
|
||||||
|
{ id: "three", label: "Third" },
|
||||||
|
];
|
||||||
|
|
||||||
|
it("writes a pollable record with step states and a log tail, holding the lock only while running", async () => {
|
||||||
|
const gate = deferred();
|
||||||
|
const { jobId, done } = await startJob({
|
||||||
|
kind: "update",
|
||||||
|
slug: "acme",
|
||||||
|
title: "Update acme",
|
||||||
|
steps,
|
||||||
|
run: async (ctx) => {
|
||||||
|
await ctx.step("one", async () => ctx.log("hello from step one"));
|
||||||
|
await ctx.step("two", async () => gate.promise);
|
||||||
|
ctx.skip("three", "not needed");
|
||||||
|
return { ok: true };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Visible immediately, mid-run: step one done, step two running, lock held.
|
||||||
|
await new Promise((r) => setTimeout(r, 30));
|
||||||
|
const midway = await readJob(jobId);
|
||||||
|
expect(midway?.status).toBe("running");
|
||||||
|
expect(midway?.steps.map((s) => s.status)).toEqual(["done", "running", "pending"]);
|
||||||
|
expect(midway?.logTail.join("\n")).toContain("hello from step one");
|
||||||
|
expect((await getActiveLock("acme"))?.jobId).toBe(jobId);
|
||||||
|
|
||||||
|
gate.resolve();
|
||||||
|
const finished = await done;
|
||||||
|
|
||||||
|
expect(finished.status).toBe("succeeded");
|
||||||
|
const onDisk = await readJob(jobId);
|
||||||
|
expect(onDisk?.steps.map((s) => s.status)).toEqual(["done", "done", "skipped"]);
|
||||||
|
expect(onDisk?.result).toEqual({ ok: true });
|
||||||
|
expect(onDisk?.finishedAt).not.toBeNull();
|
||||||
|
expect(await readLock("acme")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("records a failure on the failing step, skips the rest, and still releases the lock", async () => {
|
||||||
|
const { jobId, done } = await startJob({
|
||||||
|
kind: "backup",
|
||||||
|
slug: "acme",
|
||||||
|
title: "Back up acme",
|
||||||
|
steps,
|
||||||
|
run: async (ctx) => {
|
||||||
|
await ctx.step("one", async () => undefined);
|
||||||
|
await ctx.step("two", async () => {
|
||||||
|
throw new Error("mysqldump exited with code 2");
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const finished = await done;
|
||||||
|
expect(finished.status).toBe("failed");
|
||||||
|
expect(finished.error).toBe("mysqldump exited with code 2");
|
||||||
|
expect((await readJob(jobId))?.steps.map((s) => s.status)).toEqual(["done", "failed", "skipped"]);
|
||||||
|
expect((await readJob(jobId))?.steps[1]?.detail).toBe("mysqldump exited with code 2");
|
||||||
|
expect(await readLock("acme")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a second job on the same business is rejected while the first runs, and allowed after", async () => {
|
||||||
|
const gate = deferred();
|
||||||
|
const first = await startJob({ kind: "backup", slug: "acme", title: "a", steps, run: async () => gate.promise });
|
||||||
|
|
||||||
|
await expect(startJob({ kind: "delete", slug: "acme", title: "b", steps, run: async () => undefined })).rejects.toBeInstanceOf(BusinessLockedError);
|
||||||
|
// A different business is unaffected.
|
||||||
|
const other = await startJob({ kind: "update", slug: "zeta", title: "c", steps, run: async () => undefined });
|
||||||
|
await other.done;
|
||||||
|
|
||||||
|
gate.resolve();
|
||||||
|
await first.done;
|
||||||
|
const again = await startJob({ kind: "delete", slug: "acme", title: "d", steps, run: async () => undefined });
|
||||||
|
expect((await again.done).status).toBe("succeeded");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects unknown job ids without touching the filesystem", async () => {
|
||||||
|
expect(await readJob("../../etc/passwd")).toBeNull();
|
||||||
|
expect(await readJob("nope")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recoverInterruptedJobs fails jobs left 'running' by a dead process and frees their locks", async () => {
|
||||||
|
const jobsPath = path.join(stacksDir, "manager", "jobs");
|
||||||
|
const locksPath = path.join(stacksDir, "manager", "locks");
|
||||||
|
await mkdir(jobsPath, { recursive: true });
|
||||||
|
await mkdir(locksPath, { recursive: true });
|
||||||
|
const id = "abc123-deadbeef";
|
||||||
|
await writeFile(
|
||||||
|
path.join(jobsPath, `${id}.json`),
|
||||||
|
JSON.stringify({
|
||||||
|
id, kind: "restore", slug: "acme", title: "Restore acme", status: "running", createdAt: new Date().toISOString(), finishedAt: null,
|
||||||
|
steps: [{ id: "a", label: "A", status: "running", startedAt: null, finishedAt: null, detail: null }, { id: "b", label: "B", status: "pending", startedAt: null, finishedAt: null, detail: null }],
|
||||||
|
logTail: [], error: null, result: null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await writeFile(path.join(locksPath, "acme.json"), JSON.stringify({ slug: "acme", jobId: id, kind: "restore", acquiredAt: new Date().toISOString(), heartbeatAt: new Date().toISOString() }));
|
||||||
|
|
||||||
|
expect(await recoverInterruptedJobs()).toBe(1);
|
||||||
|
|
||||||
|
const job = await readJob(id);
|
||||||
|
expect(job?.status).toBe("failed");
|
||||||
|
expect(job?.error).toMatch(/Interrupted/);
|
||||||
|
expect(job?.steps.map((s) => s.status)).toEqual(["failed", "skipped"]);
|
||||||
|
expect(await readLock("acme")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prunes only old, finished job records", async () => {
|
||||||
|
const { jobId, done } = await startJob({ kind: "update", slug: "acme", title: "x", steps, run: async () => undefined });
|
||||||
|
await done;
|
||||||
|
expect(await pruneOldJobs(14 * 24 * 3600 * 1000)).toBe(0);
|
||||||
|
expect(await pruneOldJobs(14 * 24 * 3600 * 1000, new Date(Date.now() + 15 * 24 * 3600 * 1000))).toBe(1);
|
||||||
|
expect(await readJob(jobId)).toBeNull();
|
||||||
|
expect(await readFile(path.join(stacksDir, "manager", "jobs", "x.json")).catch(() => null)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { mkdtemp, rm } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { acquireLock, assertNotLocked, BusinessLockedError, getActiveLock, isLockStale, readLock, releaseLock, STALE_LOCK_MS, touchLock } from "../locks";
|
||||||
|
|
||||||
|
describe("per-business lock", () => {
|
||||||
|
let stacksDir: string;
|
||||||
|
const originalStacksDir = process.env.STACKS_DIR;
|
||||||
|
beforeEach(async () => {
|
||||||
|
stacksDir = await mkdtemp(path.join(tmpdir(), "nc-manager-locks-"));
|
||||||
|
process.env.STACKS_DIR = stacksDir;
|
||||||
|
});
|
||||||
|
afterEach(async () => {
|
||||||
|
if (originalStacksDir === undefined) delete process.env.STACKS_DIR;
|
||||||
|
else process.env.STACKS_DIR = originalStacksDir;
|
||||||
|
await rm(stacksDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets one job hold a business and rejects a second one (Update vs Delete vs second backup)", async () => {
|
||||||
|
await acquireLock("acme", { jobId: "j1", kind: "backup" });
|
||||||
|
await expect(acquireLock("acme", { jobId: "j2", kind: "update" })).rejects.toBeInstanceOf(BusinessLockedError);
|
||||||
|
await expect(acquireLock("acme", { jobId: "j3", kind: "delete" })).rejects.toThrow(/backup job \(j1\)/);
|
||||||
|
await expect(assertNotLocked("acme")).rejects.toBeInstanceOf(BusinessLockedError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("locks are per business", async () => {
|
||||||
|
await acquireLock("acme", { jobId: "j1", kind: "backup" });
|
||||||
|
await expect(acquireLock("zeta", { jobId: "j2", kind: "backup" })).resolves.toMatchObject({ slug: "zeta" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("only two simultaneous acquirers can't both win", async () => {
|
||||||
|
const results = await Promise.allSettled([
|
||||||
|
acquireLock("acme", { jobId: "a", kind: "backup" }),
|
||||||
|
acquireLock("acme", { jobId: "b", kind: "update" }),
|
||||||
|
acquireLock("acme", { jobId: "c", kind: "restore" }),
|
||||||
|
]);
|
||||||
|
expect(results.filter((r) => r.status === "fulfilled")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("releases only for the owner, then can be re-acquired", async () => {
|
||||||
|
await acquireLock("acme", { jobId: "j1", kind: "backup" });
|
||||||
|
await releaseLock("acme", "someone-else");
|
||||||
|
expect(await readLock("acme")).not.toBeNull();
|
||||||
|
await releaseLock("acme", "j1");
|
||||||
|
expect(await readLock("acme")).toBeNull();
|
||||||
|
await expect(acquireLock("acme", { jobId: "j2", kind: "update" })).resolves.toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("stale-lock rule", () => {
|
||||||
|
it("a lock is stale once its heartbeat is older than the threshold", () => {
|
||||||
|
const now = new Date("2026-10-17T12:00:00Z");
|
||||||
|
const lock = { slug: "acme", jobId: "j1", kind: "backup", acquiredAt: now.toISOString(), heartbeatAt: now.toISOString() };
|
||||||
|
expect(isLockStale(lock, new Date(now.getTime() + STALE_LOCK_MS - 1000))).toBe(false);
|
||||||
|
expect(isLockStale(lock, new Date(now.getTime() + STALE_LOCK_MS + 1000))).toBe(true);
|
||||||
|
expect(isLockStale({ ...lock, heartbeatAt: "garbage" }, now)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a stale lock is taken over by the next job; a fresh one is not", async () => {
|
||||||
|
const old = new Date(Date.now() - STALE_LOCK_MS - 60_000);
|
||||||
|
await acquireLock("acme", { jobId: "dead", kind: "restore" }, old);
|
||||||
|
expect(await getActiveLock("acme")).toBeNull();
|
||||||
|
await expect(assertNotLocked("acme")).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
const taken = await acquireLock("acme", { jobId: "alive", kind: "update" });
|
||||||
|
expect(taken.jobId).toBe("alive");
|
||||||
|
await expect(acquireLock("acme", { jobId: "third", kind: "backup" })).rejects.toBeInstanceOf(BusinessLockedError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a heartbeat keeps a long job's lock alive; a dead owner can't release its successor's lock", async () => {
|
||||||
|
const start = new Date(Date.now() - STALE_LOCK_MS - 60_000);
|
||||||
|
await acquireLock("acme", { jobId: "slow", kind: "backup" }, start);
|
||||||
|
await touchLock("acme", "slow", new Date());
|
||||||
|
expect(await getActiveLock("acme")).not.toBeNull();
|
||||||
|
|
||||||
|
// Pretend it went stale and was taken over.
|
||||||
|
await releaseLock("acme", "slow");
|
||||||
|
await acquireLock("acme", { jobId: "successor", kind: "update" });
|
||||||
|
await releaseLock("acme", "slow");
|
||||||
|
expect((await readLock("acme"))?.jobId).toBe("successor");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const calls: string[] = [];
|
||||||
|
vi.mock("@/lib/docker", () => ({
|
||||||
|
composeUp: vi.fn(async (_dir: string, opts?: unknown) => calls.push(`composeUp:${JSON.stringify(opts)}`)),
|
||||||
|
}));
|
||||||
|
const waitForHealthMock = vi.fn();
|
||||||
|
vi.mock("@/lib/health", () => ({ waitForHealth: (...a: unknown[]) => waitForHealthMock(...a) }));
|
||||||
|
const performBackupMock = vi.fn();
|
||||||
|
vi.mock("@/lib/backup", () => ({ performBackup: (...a: unknown[]) => performBackupMock(...a), BACKUP_STEPS: [] }));
|
||||||
|
vi.mock("@/lib/global-settings", () => ({
|
||||||
|
readOperatorSettings: vi.fn(async () => ({ alertEmail: "", alertOnBackupFailure: true })),
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/provisioning", () => ({ deleteBusiness: vi.fn(async () => (calls.push("deleteBusiness"), { slug: "acme", warnings: [] })) }));
|
||||||
|
vi.mock("@/lib/operator-email", () => ({ sendOperatorEmail: vi.fn() }));
|
||||||
|
|
||||||
|
import { BusinessLockedError } from "../locks";
|
||||||
|
import { startDeleteJob, startUpdateJob } from "../operations";
|
||||||
|
import { startJob } from "../jobs";
|
||||||
|
import { readJob } from "../jobs";
|
||||||
|
|
||||||
|
describe("Update and Delete jobs", () => {
|
||||||
|
let stacksDir: string;
|
||||||
|
const originalStacksDir = process.env.STACKS_DIR;
|
||||||
|
beforeEach(async () => {
|
||||||
|
stacksDir = await mkdtemp(path.join(tmpdir(), "nc-manager-operations-"));
|
||||||
|
process.env.STACKS_DIR = stacksDir;
|
||||||
|
await mkdir(path.join(stacksDir, "acme"), { recursive: true });
|
||||||
|
await writeFile(path.join(stacksDir, "acme", "compose.yaml"), "name: x\n");
|
||||||
|
calls.length = 0;
|
||||||
|
waitForHealthMock.mockReset().mockImplementation(async () => (calls.push("waitForHealth"), undefined));
|
||||||
|
performBackupMock.mockReset().mockImplementation(async (_slug: string, kind: string) => (calls.push(`backup:${kind}`), { fileName: `f-${kind}.7z` }));
|
||||||
|
});
|
||||||
|
afterEach(async () => {
|
||||||
|
if (originalStacksDir === undefined) delete process.env.STACKS_DIR;
|
||||||
|
else process.env.STACKS_DIR = originalStacksDir;
|
||||||
|
await rm(stacksDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Update without backup: recreate container → wait for /api/health → done", async () => {
|
||||||
|
const { jobId, done } = await startUpdateJob("acme", false);
|
||||||
|
const job = await done;
|
||||||
|
expect(job.status).toBe("succeeded");
|
||||||
|
expect(job.steps.map((s) => [s.id, s.status])).toEqual([["recreate", "done"], ["health", "done"], ["done", "done"]]);
|
||||||
|
expect(calls).toEqual(['composeUp:{"forceRecreate":true}', "waitForHealth"]);
|
||||||
|
expect((await readJob(jobId))?.title).toBe("Update acme");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Update with 'back up first': a safety backup runs before the container is touched", async () => {
|
||||||
|
const { done } = await startUpdateJob("acme", true);
|
||||||
|
const job = await done;
|
||||||
|
expect(job.steps.map((s) => s.id)).toEqual(["backup", "recreate", "health", "done"]);
|
||||||
|
expect(calls).toEqual(["backup:safety", 'composeUp:{"forceRecreate":true}', "waitForHealth"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a failed pre-update backup stops the update before the container is recreated", async () => {
|
||||||
|
performBackupMock.mockRejectedValue(new Error("disk full"));
|
||||||
|
const job = await (await startUpdateJob("acme", true)).done;
|
||||||
|
expect(job.status).toBe("failed");
|
||||||
|
expect(job.error).toBe("disk full");
|
||||||
|
expect(calls.some((c) => c.startsWith("composeUp"))).toBe(false);
|
||||||
|
expect(job.steps.map((s) => s.status)).toEqual(["failed", "skipped", "skipped", "skipped"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an unhealthy app after the recreate fails the job with the health error", async () => {
|
||||||
|
waitForHealthMock.mockRejectedValue(new Error('"acme" did not become healthy within 180s.'));
|
||||||
|
const job = await (await startUpdateJob("acme", false)).done;
|
||||||
|
expect(job.status).toBe("failed");
|
||||||
|
expect(job.steps.map((s) => s.status)).toEqual(["done", "failed", "skipped"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Update refuses to run on top of another job for the same business", async () => {
|
||||||
|
let release!: () => void;
|
||||||
|
const gate = new Promise<void>((r) => (release = r));
|
||||||
|
const running = await startJob({ kind: "backup", slug: "acme", title: "x", steps: [], run: async () => gate });
|
||||||
|
await expect(startUpdateJob("acme", false)).rejects.toBeInstanceOf(BusinessLockedError);
|
||||||
|
await expect(startDeleteJob("acme", false)).rejects.toBeInstanceOf(BusinessLockedError);
|
||||||
|
release();
|
||||||
|
await running.done;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Delete with a final backup: backs up first, deletes only afterwards; a failed backup aborts the delete", async () => {
|
||||||
|
const ok = await (await startDeleteJob("acme", true)).done;
|
||||||
|
expect(ok.status).toBe("succeeded");
|
||||||
|
expect(calls).toEqual(["backup:final", "deleteBusiness"]);
|
||||||
|
|
||||||
|
calls.length = 0;
|
||||||
|
performBackupMock.mockRejectedValue(new Error("mysqldump failed"));
|
||||||
|
const failed = await (await startDeleteJob("acme", true)).done;
|
||||||
|
expect(failed.status).toBe("failed");
|
||||||
|
expect(calls).not.toContain("deleteBusiness");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Delete without a final backup skips that step", async () => {
|
||||||
|
const job = await (await startDeleteJob("acme", false)).done;
|
||||||
|
expect(job.steps.map((s) => s.status)).toEqual(["skipped", "done"]);
|
||||||
|
expect(calls).toEqual(["deleteBusiness"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const composeUpMock = vi.fn().mockResolvedValue(undefined);
|
||||||
|
vi.mock("@/lib/docker", () => ({
|
||||||
|
composeUp: (...args: unknown[]) => composeUpMock(...args),
|
||||||
|
composeDown: vi.fn(),
|
||||||
|
composeStatus: vi.fn().mockResolvedValue("stopped"),
|
||||||
|
reloadCaddy: vi.fn(),
|
||||||
|
runMigrations: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("applyOperatorAlertEmailToAllBusinesses", () => {
|
||||||
|
let stacksDir: string;
|
||||||
|
const originalStacksDir = process.env.STACKS_DIR;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
stacksDir = await mkdtemp(path.join(tmpdir(), "nc-manager-operator-rollout-test-"));
|
||||||
|
process.env.STACKS_DIR = stacksDir;
|
||||||
|
composeUpMock.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (originalStacksDir === undefined) delete process.env.STACKS_DIR;
|
||||||
|
else process.env.STACKS_DIR = originalStacksDir;
|
||||||
|
await rm(stacksDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
async function createStack(slug: string, envContent: string) {
|
||||||
|
const dir = path.join(stacksDir, slug);
|
||||||
|
await mkdir(dir, { recursive: true });
|
||||||
|
await writeFile(path.join(dir, "compose.yaml"), "name: test\n", "utf8");
|
||||||
|
await writeFile(path.join(dir, ".env"), envContent, "utf8");
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("writes the alert address into each business and restarts it", async () => {
|
||||||
|
const { applyOperatorAlertEmailToAllBusinesses } = await import("../operator-rollout");
|
||||||
|
await createStack("acme", 'BUSINESS_NAME="Acme"\nDOMAIN="acme.example.com"\n');
|
||||||
|
|
||||||
|
const results = await applyOperatorAlertEmailToAllBusinesses("ops@example.com");
|
||||||
|
|
||||||
|
expect(results).toEqual([{ slug: "acme", ok: true }]);
|
||||||
|
expect(await readFile(path.join(stacksDir, "acme", ".env"), "utf8")).toContain(
|
||||||
|
'OPERATOR_ALERT_EMAIL="ops@example.com"',
|
||||||
|
);
|
||||||
|
expect(composeUpMock).toHaveBeenCalledWith(path.join(stacksDir, "acme"), {
|
||||||
|
forceRecreate: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves a business that already has this address alone — no rewrite, no restart", async () => {
|
||||||
|
const { applyOperatorAlertEmailToAllBusinesses } = await import("../operator-rollout");
|
||||||
|
await createStack("acme", 'BUSINESS_NAME="Acme"\nOPERATOR_ALERT_EMAIL="ops@example.com"\n');
|
||||||
|
|
||||||
|
const results = await applyOperatorAlertEmailToAllBusinesses("ops@example.com");
|
||||||
|
|
||||||
|
expect(results).toEqual([{ slug: "acme", ok: true, unchanged: true }]);
|
||||||
|
expect(composeUpMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an empty address turns alerts off, and one failing business doesn't stop the rest", async () => {
|
||||||
|
const { applyOperatorAlertEmailToAllBusinesses } = await import("../operator-rollout");
|
||||||
|
await createStack("alpha", 'OPERATOR_ALERT_EMAIL="ops@example.com"\n');
|
||||||
|
await createStack("beta", 'OPERATOR_ALERT_EMAIL="ops@example.com"\n');
|
||||||
|
composeUpMock.mockRejectedValueOnce(new Error("docker unavailable"));
|
||||||
|
|
||||||
|
const results = await applyOperatorAlertEmailToAllBusinesses("");
|
||||||
|
|
||||||
|
expect(results).toHaveLength(2);
|
||||||
|
expect(results.filter((r) => r.ok)).toHaveLength(1);
|
||||||
|
expect(results.find((r) => !r.ok)?.error).toContain("docker unavailable");
|
||||||
|
expect(await readFile(path.join(stacksDir, "beta", ".env"), "utf8")).toContain(
|
||||||
|
'OPERATOR_ALERT_EMAIL=""',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { CommandError, runCapture, runFromFile, runPipeline, runToFile } from "../process";
|
||||||
|
|
||||||
|
const node = (script: string, env?: Record<string, string>) => ({ command: process.execPath, args: ["-e", script], env });
|
||||||
|
|
||||||
|
describe("streaming subprocess helpers", () => {
|
||||||
|
let dir: string;
|
||||||
|
beforeEach(async () => {
|
||||||
|
dir = await mkdtemp(path.join(tmpdir(), "nc-manager-process-"));
|
||||||
|
});
|
||||||
|
afterEach(async () => {
|
||||||
|
await rm(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runToFile streams far more than execFile's default buffer without buffering it", async () => {
|
||||||
|
const out = path.join(dir, "big.bin");
|
||||||
|
// ~30 MB, written in chunks — would blow execFile's 1 MB default maxBuffer.
|
||||||
|
await runToFile(node("const b=Buffer.alloc(1024*1024,97);let n=30;(function w(){while(n>0){n--;if(!process.stdout.write(b))return process.stdout.once('drain',w)}})()"), out);
|
||||||
|
expect((await readFile(out)).length).toBe(30 * 1024 * 1024);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runToFile rejects on a non-zero exit with the stderr tail (and no command arguments)", async () => {
|
||||||
|
const out = path.join(dir, "x");
|
||||||
|
const error = await runToFile(node("console.error('boom: access denied');process.exit(3)", { MYSQL_PWD: "hunter2" }), out).catch((e: unknown) => e);
|
||||||
|
expect(error).toBeInstanceOf(CommandError);
|
||||||
|
expect((error as CommandError).exitCode).toBe(3);
|
||||||
|
expect((error as CommandError).message).toContain("boom: access denied");
|
||||||
|
expect((error as CommandError).message).not.toContain("hunter2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes env to the child but not via arguments", async () => {
|
||||||
|
const { stdout } = await runCapture(node("process.stdout.write(process.env.MYSQL_PWD)", { MYSQL_PWD: "from-env" }));
|
||||||
|
expect(stdout).toBe("from-env");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runFromFile feeds a file to stdin", async () => {
|
||||||
|
const input = path.join(dir, "in.txt");
|
||||||
|
const output = path.join(dir, "out.txt");
|
||||||
|
await writeFile(input, "line one\nline two\n");
|
||||||
|
await runFromFile(node(`require('fs').writeFileSync(${JSON.stringify(output)}, require('fs').readFileSync(0))`), input);
|
||||||
|
expect(await readFile(output, "utf8")).toBe("line one\nline two\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runPipeline pipes the producer's stdout into the consumer's stdin", async () => {
|
||||||
|
const output = path.join(dir, "piped.txt");
|
||||||
|
await runPipeline(
|
||||||
|
node("process.stdout.write('hello pipeline')"),
|
||||||
|
node(`require('fs').writeFileSync(${JSON.stringify(output)}, require('fs').readFileSync(0))`),
|
||||||
|
);
|
||||||
|
expect(await readFile(output, "utf8")).toBe("hello pipeline");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runPipeline fails when the producer fails even if the consumer 'succeeds' on the truncated stream", async () => {
|
||||||
|
await expect(runPipeline(node("process.stdout.write('partial');console.error('Wrong password');process.exit(2)"), node("require('fs').readFileSync(0)"))).rejects.toThrow(/Wrong password/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runPipeline fails when the consumer fails", async () => {
|
||||||
|
await expect(runPipeline(node("process.stdout.write('x'.repeat(1000))"), node("console.error('ERROR 1064 syntax');process.exit(1)"))).rejects.toThrow(/1064/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a missing binary clearly", async () => {
|
||||||
|
await expect(runCapture({ command: "definitely-not-a-real-binary-xyz", args: [] })).rejects.toThrow(/Couldn't run definitely-not-a-real-binary-xyz/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runCapture caps output", async () => {
|
||||||
|
await expect(runCapture(node("process.stdout.write('x'.repeat(5000))"), { maxBytes: 100 })).rejects.toThrow(/more than 100 bytes/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { randomHex } from "../random";
|
||||||
|
|
||||||
|
describe("randomHex", () => {
|
||||||
|
it("returns a hex string of the requested byte length", () => {
|
||||||
|
const value = randomHex(32);
|
||||||
|
expect(value).toMatch(/^[a-f0-9]+$/);
|
||||||
|
expect(value).toHaveLength(64);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is sufficiently long/unpredictable — two calls never collide", () => {
|
||||||
|
const a = randomHex(32);
|
||||||
|
const b = randomHex(32);
|
||||||
|
expect(a).not.toBe(b);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { CommandSpec } from "../process";
|
||||||
|
|
||||||
|
/** Every side effect, in the order it happens — the restore's contract. */
|
||||||
|
const calls: string[] = [];
|
||||||
|
const record = (label: string) => calls.push(label);
|
||||||
|
|
||||||
|
vi.mock("@/lib/docker", () => ({
|
||||||
|
composeDown: vi.fn(async (_dir: string, opts?: unknown) => record(`composeDown${opts ? ":" + JSON.stringify(opts) : ""}`)),
|
||||||
|
composeCreate: vi.fn(async () => record("composeCreate")),
|
||||||
|
composeUp: vi.fn(async () => record("composeUp")),
|
||||||
|
listImageMigrations: vi.fn(async () => {
|
||||||
|
record("listImageMigrations");
|
||||||
|
return ["20260912165903_add_staff_selected_by_customer", "20261017180000_add_business_holidays"];
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const assertDomainAvailableMock = vi.fn();
|
||||||
|
const recreateDatabaseMock = vi.fn();
|
||||||
|
vi.mock("@/lib/provisioning", () => ({
|
||||||
|
assertDomainAvailable: (...a: unknown[]) => assertDomainAvailableMock(...a),
|
||||||
|
recreateDatabaseAndUser: (...a: unknown[]) => recreateDatabaseMock(...a),
|
||||||
|
writeCaddySiteAndReload: vi.fn(async (slug: string, domain: string) => record(`writeCaddy:${slug}:${domain}`)),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const runPipelineMock = vi.fn();
|
||||||
|
vi.mock("@/lib/process", () => ({ runPipeline: (...a: unknown[]) => runPipelineMock(...a) }));
|
||||||
|
|
||||||
|
const sevenZipTestMock = vi.fn();
|
||||||
|
const sevenZipReadMemberMock = vi.fn();
|
||||||
|
vi.mock("@/lib/sevenzip", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<typeof import("../sevenzip")>()),
|
||||||
|
sevenZipTest: (...a: unknown[]) => sevenZipTestMock(...a),
|
||||||
|
sevenZipReadMember: (...a: unknown[]) => sevenZipReadMemberMock(...a),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const waitForHealthMock = vi.fn();
|
||||||
|
vi.mock("@/lib/health", () => ({ waitForHealth: (...a: unknown[]) => waitForHealthMock(...a) }));
|
||||||
|
|
||||||
|
const performBackupMock = vi.fn();
|
||||||
|
vi.mock("@/lib/backup", () => ({ performBackup: (...a: unknown[]) => performBackupMock(...a) }));
|
||||||
|
|
||||||
|
import { performRestore, RESTORE_STEPS } from "../restore";
|
||||||
|
|
||||||
|
const DB_PASSWORD = "f".repeat(32);
|
||||||
|
const ARCHIVE = "/stacks/backups/acme/acme__20261017T030000Z__regular.7z";
|
||||||
|
const BACKED_UP_ENV = ['BUSINESS_NAME="Acme Barbers"', 'DOMAIN="acme.example.com"', 'DATABASE_NAME="acme"', 'DATABASE_USER="acme"', `DATABASE_PASSWORD="${DB_PASSWORD}"`, 'AUTH_SECRET="s3cret-from-backup"'].join("\n") + "\n";
|
||||||
|
|
||||||
|
function manifest(over: Record<string, unknown> = {}) {
|
||||||
|
return JSON.stringify({
|
||||||
|
formatVersion: 1, slug: "acme", name: "Acme Barbers", domain: "acme.example.com", databaseName: "acme", databaseUser: "acme",
|
||||||
|
createdAt: "2026-10-17T03:00:00.000Z", kind: "regular", lastMigration: "20261017180000_add_business_holidays", appImage: null, hasCaddySite: true, ...over,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("performRestore", () => {
|
||||||
|
let stacksDir: string;
|
||||||
|
const originalStacksDir = process.env.STACKS_DIR;
|
||||||
|
const originalRoot = process.env.MYSQL_ROOT_PASSWORD;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
stacksDir = await mkdtemp(path.join(tmpdir(), "nc-manager-restore-"));
|
||||||
|
process.env.STACKS_DIR = stacksDir;
|
||||||
|
process.env.MYSQL_ROOT_PASSWORD = "root-pw";
|
||||||
|
calls.length = 0;
|
||||||
|
assertDomainAvailableMock.mockReset().mockImplementation(async () => record("assertDomainAvailable"));
|
||||||
|
recreateDatabaseMock.mockReset().mockImplementation(async () => record("recreateDatabase"));
|
||||||
|
runPipelineMock.mockReset().mockImplementation(async (source: CommandSpec) => record(`pipeline:${source.args.at(-1)}`));
|
||||||
|
sevenZipTestMock.mockReset().mockImplementation(async () => record("7zz-test"));
|
||||||
|
sevenZipReadMemberMock.mockReset().mockImplementation(async (_a: string, member: string) => {
|
||||||
|
record(`read:${member}`);
|
||||||
|
return member === "manifest.json" ? manifest() : BACKED_UP_ENV;
|
||||||
|
});
|
||||||
|
waitForHealthMock.mockReset().mockImplementation(async (_slug: string, opts: unknown) => record(`waitForHealth:${JSON.stringify((opts as { requireInstalled?: boolean }).requireInstalled)}`));
|
||||||
|
performBackupMock.mockReset().mockImplementation(async (slug: string, kind: string) => {
|
||||||
|
record(`safetyBackup:${slug}:${kind}`);
|
||||||
|
return { fileName: "acme__20261017T040000Z__safety.7z" };
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (originalStacksDir === undefined) delete process.env.STACKS_DIR;
|
||||||
|
else process.env.STACKS_DIR = originalStacksDir;
|
||||||
|
if (originalRoot === undefined) delete process.env.MYSQL_ROOT_PASSWORD;
|
||||||
|
else process.env.MYSQL_ROOT_PASSWORD = originalRoot;
|
||||||
|
await rm(stacksDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
function makeCtx() {
|
||||||
|
const steps: string[] = [];
|
||||||
|
const lines: string[] = [];
|
||||||
|
return {
|
||||||
|
steps, lines,
|
||||||
|
ctx: {
|
||||||
|
log: (l: string) => lines.push(l),
|
||||||
|
step: async <T,>(id: string, fn: () => Promise<T>) => (steps.push(`step:${id}`), fn()),
|
||||||
|
skip: (id: string) => steps.push(`skip:${id}`),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function existingStack() {
|
||||||
|
await mkdir(path.join(stacksDir, "acme"), { recursive: true });
|
||||||
|
await writeFile(path.join(stacksDir, "acme", "compose.yaml"), "name: old\n");
|
||||||
|
await writeFile(path.join(stacksDir, "acme", ".env"), 'DOMAIN="acme.example.com"\nDATABASE_NAME="acme"\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
it("fresh server: runs the steps in the planned order and the side effects in the planned order", async () => {
|
||||||
|
const { ctx, steps } = makeCtx();
|
||||||
|
const result = await performRestore({ archivePath: ARCHIVE, replace: false, ctx });
|
||||||
|
|
||||||
|
expect(steps).toEqual([
|
||||||
|
"step:verify", "step:check", "skip:safety", "skip:stop", "step:database", "step:import", "step:config", "step:media", "step:start", "step:health",
|
||||||
|
]);
|
||||||
|
// The declared step list (what the UI renders) matches what actually runs.
|
||||||
|
expect(RESTORE_STEPS.map((s) => `${s.id}`)).toEqual(["verify", "check", "safety", "stop", "database", "import", "config", "media", "start", "health"]);
|
||||||
|
|
||||||
|
expect(calls).toEqual([
|
||||||
|
"7zz-test", "read:manifest.json", "read:config/.env",
|
||||||
|
"assertDomainAvailable", "listImageMigrations",
|
||||||
|
"recreateDatabase",
|
||||||
|
"pipeline:db.sql",
|
||||||
|
"writeCaddy:acme:acme.example.com",
|
||||||
|
"composeCreate", "pipeline:media.tar",
|
||||||
|
"composeUp",
|
||||||
|
"waitForHealth:true",
|
||||||
|
]);
|
||||||
|
expect(result).toEqual({ slug: "acme", domain: "acme.example.com", safetyBackup: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recreates the DB with the password FROM THE BACKED-UP .env, and writes that .env verbatim (0600) plus a regenerated compose.yaml", async () => {
|
||||||
|
await performRestore({ archivePath: ARCHIVE, replace: false, ctx: makeCtx().ctx });
|
||||||
|
|
||||||
|
expect(recreateDatabaseMock).toHaveBeenCalledWith("acme", "acme", DB_PASSWORD);
|
||||||
|
const envPath = path.join(stacksDir, "acme", ".env");
|
||||||
|
expect(await readFile(envPath, "utf8")).toBe(BACKED_UP_ENV);
|
||||||
|
expect((await stat(envPath)).mode & 0o777).toBe(0o600);
|
||||||
|
const compose = await readFile(path.join(stacksDir, "acme", "compose.yaml"), "utf8");
|
||||||
|
expect(compose).toContain("container_name: next-chair-acme");
|
||||||
|
expect(compose).toContain("next-chair-media-acme:/app/storage/media");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds the import and media pipelines as argument arrays: 7zz -so → docker, no password in argv", async () => {
|
||||||
|
await performRestore({ archivePath: ARCHIVE, replace: false, ctx: makeCtx().ctx });
|
||||||
|
|
||||||
|
const [dumpSource, dumpSink] = runPipelineMock.mock.calls[0] as [CommandSpec, CommandSpec];
|
||||||
|
expect(dumpSource.command).toBe("7zz");
|
||||||
|
expect(dumpSource.args).toEqual(["x", "-so", "-t7z", "-y", "-bd", "-bso0", "-bsp0", ARCHIVE, "db.sql"]);
|
||||||
|
expect(dumpSink).toMatchObject({ command: "docker" });
|
||||||
|
expect(dumpSink.args).toEqual(["exec", "-i", "-e", "MYSQL_PWD", "next-chair-mysql", "mysql", "-uroot", "--default-character-set=utf8mb4", "acme"]);
|
||||||
|
expect(dumpSink.args.join(" ")).not.toContain("root-pw");
|
||||||
|
|
||||||
|
const [mediaSource, mediaSink] = runPipelineMock.mock.calls[1] as [CommandSpec, CommandSpec];
|
||||||
|
expect(mediaSource.args.at(-1)).toBe("media.tar");
|
||||||
|
expect(mediaSink.args).toContain("--user");
|
||||||
|
expect(mediaSink.args.at(-1)).toContain("chown -R 1001:1001");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("existing business + replace: safety backup FIRST, then compose down (with volumes), then everything else", async () => {
|
||||||
|
await existingStack();
|
||||||
|
const { ctx, steps } = makeCtx();
|
||||||
|
const result = await performRestore({ archivePath: ARCHIVE, replace: true, ctx });
|
||||||
|
|
||||||
|
expect(steps).toEqual([
|
||||||
|
"step:verify", "step:check", "step:safety", "step:stop", "step:database", "step:import", "step:config", "step:media", "step:start", "step:health",
|
||||||
|
]);
|
||||||
|
const safety = calls.indexOf("safetyBackup:acme:safety");
|
||||||
|
const down = calls.findIndex((c) => c.startsWith("composeDown"));
|
||||||
|
expect(safety).toBeGreaterThan(-1);
|
||||||
|
expect(down).toBeGreaterThan(safety);
|
||||||
|
expect(calls[down]).toBe('composeDown:{"removeVolumes":true}');
|
||||||
|
expect(calls.indexOf("recreateDatabase")).toBeGreaterThan(down);
|
||||||
|
expect(result.safetyBackup).toBe("acme__20261017T040000Z__safety.7z");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("existing business without 'replace': refuses before touching anything", async () => {
|
||||||
|
await existingStack();
|
||||||
|
await expect(performRestore({ archivePath: ARCHIVE, replace: false, ctx: makeCtx().ctx })).rejects.toThrow(/already exists.*replace/);
|
||||||
|
expect(performBackupMock).not.toHaveBeenCalled();
|
||||||
|
expect(recreateDatabaseMock).not.toHaveBeenCalled();
|
||||||
|
expect(calls.some((c) => c.startsWith("composeDown"))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("domain conflict: refuses before any destructive step", async () => {
|
||||||
|
assertDomainAvailableMock.mockRejectedValue(new Error('Domain "acme.example.com" is already in use by "other".'));
|
||||||
|
await expect(performRestore({ archivePath: ARCHIVE, replace: false, ctx: makeCtx().ctx })).rejects.toThrow(/already in use/);
|
||||||
|
expect(recreateDatabaseMock).not.toHaveBeenCalled();
|
||||||
|
expect(runPipelineMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("version guard: a backup from a newer schema than the image is refused before any change", async () => {
|
||||||
|
sevenZipReadMemberMock.mockImplementation(async (_a: string, member: string) =>
|
||||||
|
member === "manifest.json" ? manifest({ lastMigration: "20271231000000_from_the_future" }) : BACKED_UP_ENV,
|
||||||
|
);
|
||||||
|
await expect(performRestore({ archivePath: ARCHIVE, replace: false, ctx: makeCtx().ctx })).rejects.toThrow(/newer than the running app image/);
|
||||||
|
expect(recreateDatabaseMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses an archive whose .env disagrees with its manifest", async () => {
|
||||||
|
sevenZipReadMemberMock.mockImplementation(async (_a: string, member: string) =>
|
||||||
|
member === "manifest.json" ? manifest() : BACKED_UP_ENV.replace('DATABASE_NAME="acme"', 'DATABASE_NAME="other"'),
|
||||||
|
);
|
||||||
|
await expect(performRestore({ archivePath: ARCHIVE, replace: false, ctx: makeCtx().ctx })).rejects.toThrow(/doesn't match its manifest/);
|
||||||
|
expect(recreateDatabaseMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a bad or corrupt archive fails in the first step", async () => {
|
||||||
|
sevenZipTestMock.mockRejectedValue(new Error("7zz exited with code 2: Data error"));
|
||||||
|
await expect(performRestore({ archivePath: ARCHIVE, replace: false, ctx: makeCtx().ctx })).rejects.toThrow(/Data error/);
|
||||||
|
expect(assertDomainAvailableMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never logs any .env secret", async () => {
|
||||||
|
const { ctx, lines } = makeCtx();
|
||||||
|
await performRestore({ archivePath: ARCHIVE, replace: false, ctx });
|
||||||
|
const all = lines.join("\n");
|
||||||
|
expect(all).not.toContain(DB_PASSWORD);
|
||||||
|
expect(all).not.toContain("s3cret-from-backup");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { MemoryStateStore, runTick, taskKey, type SchedulerConfig, type SchedulerTask } from "../scheduler";
|
||||||
|
|
||||||
|
const config: SchedulerConfig = { notificationsIntervalSeconds: 30, healthIntervalMinutes: 3 };
|
||||||
|
|
||||||
|
function harness(tasks: SchedulerTask[], slugs: string[] = ["acme", "zeta"]) {
|
||||||
|
let nowMs = Date.parse("2026-10-17T12:00:00Z");
|
||||||
|
const store = new MemoryStateStore();
|
||||||
|
const inFlight = new Set<string>();
|
||||||
|
const tick = () =>
|
||||||
|
runTick({ tasks, store, listSlugs: async () => slugs, config: async () => config, now: () => new Date(nowMs), inFlight });
|
||||||
|
return { store, tick, advance: (ms: number) => (nowMs += ms), inFlight };
|
||||||
|
}
|
||||||
|
|
||||||
|
function task(overrides: Partial<SchedulerTask> & { run?: SchedulerTask["run"] } = {}): SchedulerTask & { calls: (string | null)[] } {
|
||||||
|
const calls: (string | null)[] = [];
|
||||||
|
return {
|
||||||
|
id: "notifications",
|
||||||
|
label: "n",
|
||||||
|
scope: "business",
|
||||||
|
intervalMs: (c) => c.notificationsIntervalSeconds * 1000,
|
||||||
|
run: async ({ slug }) => {
|
||||||
|
calls.push(slug);
|
||||||
|
return { ok: true, message: "HTTP 200" };
|
||||||
|
},
|
||||||
|
...overrides,
|
||||||
|
calls,
|
||||||
|
} as SchedulerTask & { calls: (string | null)[] };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("scheduler due-time", () => {
|
||||||
|
it("runs a per-business task for every business on the first tick", async () => {
|
||||||
|
const t = task();
|
||||||
|
const h = harness([t]);
|
||||||
|
const { started, done } = await h.tick();
|
||||||
|
await done;
|
||||||
|
expect(started.sort()).toEqual(["notifications:acme", "notifications:zeta"]);
|
||||||
|
expect(t.calls.sort()).toEqual(["acme", "zeta"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not run again until the task's interval has elapsed (30s default for notifications)", async () => {
|
||||||
|
const t = task();
|
||||||
|
const h = harness([t], ["acme"]);
|
||||||
|
await (await h.tick()).done;
|
||||||
|
h.advance(29_000);
|
||||||
|
await (await h.tick()).done;
|
||||||
|
expect(t.calls).toHaveLength(1);
|
||||||
|
h.advance(1_000);
|
||||||
|
await (await h.tick()).done;
|
||||||
|
expect(t.calls).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the configured interval, per task", async () => {
|
||||||
|
const fast = task({ id: "fast", intervalMs: () => 10_000 });
|
||||||
|
const slow = task({ id: "slow", intervalMs: () => 60_000 });
|
||||||
|
const h = harness([fast, slow], ["acme"]);
|
||||||
|
await (await h.tick()).done;
|
||||||
|
h.advance(10_000);
|
||||||
|
await (await h.tick()).done;
|
||||||
|
expect(fast.calls).toHaveLength(2);
|
||||||
|
expect(slow.calls).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a once-a-day task survives a restart: persisted lastStartedAt is honoured", async () => {
|
||||||
|
const daily = task({ id: "reviews", intervalMs: () => 24 * 3600 * 1000 });
|
||||||
|
const h = harness([daily], ["acme"]);
|
||||||
|
h.store.patch(taskKey("reviews", "acme"), { lastStartedAt: new Date(Date.parse("2026-10-17T12:00:00Z") - 3600_000).toISOString() });
|
||||||
|
await (await h.tick()).done;
|
||||||
|
expect(daily.calls).toHaveLength(0);
|
||||||
|
h.advance(23 * 3600 * 1000);
|
||||||
|
await (await h.tick()).done;
|
||||||
|
expect(daily.calls).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a custom isDue (scheduled backups) overrides the interval", async () => {
|
||||||
|
const isDue = vi.fn().mockResolvedValueOnce(false).mockResolvedValueOnce(true);
|
||||||
|
const backups = task({ id: "backups", isDue });
|
||||||
|
const h = harness([backups], ["acme"]);
|
||||||
|
await (await h.tick()).done;
|
||||||
|
expect(backups.calls).toHaveLength(0);
|
||||||
|
h.advance(5_000);
|
||||||
|
await (await h.tick()).done;
|
||||||
|
expect(backups.calls).toEqual(["acme"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("global tasks run once, not once per business", async () => {
|
||||||
|
const g = task({ id: "maintenance", scope: "global", intervalMs: () => 3600_000 });
|
||||||
|
const h = harness([g]);
|
||||||
|
await (await h.tick()).done;
|
||||||
|
expect(g.calls).toEqual([null]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("scheduler overlap guard and results", () => {
|
||||||
|
it("never starts a task/business pair that is still in flight, however many ticks pass", async () => {
|
||||||
|
let release!: () => void;
|
||||||
|
const gate = new Promise<void>((r) => (release = r));
|
||||||
|
const slow = task({
|
||||||
|
run: async ({ slug }) => {
|
||||||
|
(slow as unknown as { calls: (string | null)[] }).calls.push(slug);
|
||||||
|
await gate;
|
||||||
|
return { ok: true, message: "done" };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const h = harness([slow], ["acme", "zeta"]);
|
||||||
|
|
||||||
|
const first = await h.tick();
|
||||||
|
expect(first.started).toHaveLength(2);
|
||||||
|
// Plenty of time passes while both are still running...
|
||||||
|
h.advance(10 * 60_000);
|
||||||
|
const second = await h.tick();
|
||||||
|
expect(second.started).toHaveLength(0);
|
||||||
|
expect(h.inFlight.size).toBe(2);
|
||||||
|
|
||||||
|
release();
|
||||||
|
await first.done;
|
||||||
|
expect(h.inFlight.size).toBe(0);
|
||||||
|
h.advance(60_000);
|
||||||
|
expect((await h.tick()).started).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("one slow business doesn't block another: guards are per business", async () => {
|
||||||
|
let release!: () => void;
|
||||||
|
const gate = new Promise<void>((r) => (release = r));
|
||||||
|
const calls: string[] = [];
|
||||||
|
const t = task({
|
||||||
|
run: async ({ slug }) => {
|
||||||
|
calls.push(slug!);
|
||||||
|
if (slug === "acme") await gate;
|
||||||
|
return { ok: true, message: "ok" };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const h = harness([t], ["acme", "zeta"]);
|
||||||
|
await h.tick();
|
||||||
|
h.advance(30_000);
|
||||||
|
const second = await h.tick();
|
||||||
|
expect(second.started).toEqual(["notifications:zeta"]);
|
||||||
|
release();
|
||||||
|
await second.done;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("records the last result, including a thrown error, and keeps running other tasks", async () => {
|
||||||
|
const boom = task({ id: "boom", run: async () => { throw new Error("kaput"); } });
|
||||||
|
const fine = task({ id: "fine" });
|
||||||
|
const h = harness([boom, fine], ["acme"]);
|
||||||
|
await (await h.tick()).done;
|
||||||
|
const tasks = h.store.get().tasks;
|
||||||
|
expect(tasks["boom:acme"]).toMatchObject({ lastOk: false, lastMessage: "kaput" });
|
||||||
|
expect(tasks["fine:acme"]).toMatchObject({ lastOk: true, lastMessage: "HTTP 200" });
|
||||||
|
expect(h.store.get().heartbeatAt).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("forgets state for businesses that no longer exist", async () => {
|
||||||
|
const t = task();
|
||||||
|
const h = harness([t], ["acme"]);
|
||||||
|
h.store.patch("notifications:gone", { lastStartedAt: "2026-01-01T00:00:00Z" });
|
||||||
|
await (await h.tick()).done;
|
||||||
|
expect(h.store.get().tasks["notifications:gone"]).toBeUndefined();
|
||||||
|
expect(h.store.get().tasks["notifications:acme"]).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const composeUpMock = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const reloadCaddyMock = vi.fn().mockResolvedValue(undefined);
|
||||||
|
vi.mock("@/lib/docker", () => ({
|
||||||
|
composeUp: (...args: unknown[]) => composeUpMock(...args),
|
||||||
|
composeDown: vi.fn(),
|
||||||
|
composeStatus: vi.fn().mockResolvedValue("stopped"),
|
||||||
|
reloadCaddy: (...args: unknown[]) => reloadCaddyMock(...args),
|
||||||
|
runMigrations: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regression coverage for a real production bug: a business behind a
|
||||||
|
* reverse proxy redirected every post-sign-in navigation to
|
||||||
|
* http://localhost:3000 instead of its own domain, because AUTH_URL was
|
||||||
|
* never set and Auth.js's header-based host detection didn't behave as
|
||||||
|
* expected in that deployment. The fix makes AUTH_URL explicit at
|
||||||
|
* provisioning time (see compose-template.test.ts) — this file covers
|
||||||
|
* the other place a business's domain gets set: editing it later, where
|
||||||
|
* AUTH_URL must keep tracking DOMAIN exactly like NEXT_PUBLIC_APP_URL
|
||||||
|
* already did, or a business that changes domains would silently regress
|
||||||
|
* back into the same bug.
|
||||||
|
*/
|
||||||
|
describe("updateBusiness — AUTH_URL tracks domain changes", () => {
|
||||||
|
let stacksDir: string;
|
||||||
|
const originalStacksDir = process.env.STACKS_DIR;
|
||||||
|
const originalMysqlHost = process.env.MYSQL_HOST;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
stacksDir = await mkdtemp(path.join(tmpdir(), "nc-manager-updatebiz-test-"));
|
||||||
|
process.env.STACKS_DIR = stacksDir;
|
||||||
|
// updateBusiness's own domain-availability check just scans other
|
||||||
|
// stacks' .env files via listBusinessStacks() — no real MySQL needed
|
||||||
|
// for this test since we never change the domain to something
|
||||||
|
// already in use.
|
||||||
|
composeUpMock.mockClear();
|
||||||
|
reloadCaddyMock.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (originalStacksDir === undefined) delete process.env.STACKS_DIR;
|
||||||
|
else process.env.STACKS_DIR = originalStacksDir;
|
||||||
|
if (originalMysqlHost === undefined) delete process.env.MYSQL_HOST;
|
||||||
|
else process.env.MYSQL_HOST = originalMysqlHost;
|
||||||
|
await rm(stacksDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
async function createStack(slug: string, envContent: string) {
|
||||||
|
const dir = path.join(stacksDir, slug);
|
||||||
|
await mkdir(dir, { recursive: true });
|
||||||
|
await writeFile(path.join(dir, "compose.yaml"), "name: test\n", "utf8");
|
||||||
|
await writeFile(path.join(dir, ".env"), envContent, "utf8");
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("updates AUTH_URL to the new domain, alongside DOMAIN and NEXT_PUBLIC_APP_URL", async () => {
|
||||||
|
const { updateBusiness } = await import("../provisioning");
|
||||||
|
await createStack(
|
||||||
|
"acme",
|
||||||
|
[
|
||||||
|
'BUSINESS_NAME="Acme"',
|
||||||
|
'DOMAIN="old-domain.example.com"',
|
||||||
|
'AUTH_URL="https://old-domain.example.com"',
|
||||||
|
'NEXT_PUBLIC_APP_URL="https://old-domain.example.com"',
|
||||||
|
'AUTH_SECRET="unrelated-secret"',
|
||||||
|
].join("\n") + "\n",
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await updateBusiness({ slug: "acme", name: "Acme", domain: "new-domain.example.com" });
|
||||||
|
|
||||||
|
expect(result.restarted).toBe(true);
|
||||||
|
const envAfter = await readFile(path.join(stacksDir, "acme", ".env"), "utf8");
|
||||||
|
expect(envAfter).toContain('DOMAIN="new-domain.example.com"');
|
||||||
|
expect(envAfter).toContain('AUTH_URL="https://new-domain.example.com"');
|
||||||
|
expect(envAfter).toContain('NEXT_PUBLIC_APP_URL="https://new-domain.example.com"');
|
||||||
|
expect(envAfter).not.toContain("old-domain.example.com");
|
||||||
|
// Unrelated secrets untouched.
|
||||||
|
expect(envAfter).toContain('AUTH_SECRET="unrelated-secret"');
|
||||||
|
|
||||||
|
expect(composeUpMock).toHaveBeenCalledWith(path.join(stacksDir, "acme"), { forceRecreate: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves AUTH_URL alone (no restart) when only the business name changes, not the domain", async () => {
|
||||||
|
const { updateBusiness } = await import("../provisioning");
|
||||||
|
await createStack(
|
||||||
|
"acme",
|
||||||
|
['BUSINESS_NAME="Acme"', 'DOMAIN="acme.example.com"', 'AUTH_URL="https://acme.example.com"'].join("\n") + "\n",
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await updateBusiness({ slug: "acme", name: "Acme Barbers Ltd", domain: "acme.example.com" });
|
||||||
|
|
||||||
|
expect(result.restarted).toBe(false);
|
||||||
|
expect(composeUpMock).not.toHaveBeenCalled();
|
||||||
|
const envAfter = await readFile(path.join(stacksDir, "acme", ".env"), "utf8");
|
||||||
|
expect(envAfter).toContain('AUTH_URL="https://acme.example.com"');
|
||||||
|
expect(envAfter).toContain('BUSINESS_NAME="Acme Barbers Ltd"');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single, dedicated platform-operator credential via env vars — not
|
||||||
|
* tied to any one business's own Super Administrator (each business has
|
||||||
|
* its own separate database; this tool has no "primary" one of its own,
|
||||||
|
* so there's no single business account it could sensibly check against).
|
||||||
|
* Bcrypt cost 12 and the dummy-hash timing-safe comparison mirror the
|
||||||
|
* main app's own convention (see scripts/ensure-super-admin.ts and
|
||||||
|
* src/auth.ts) — copied, not imported: this app never imports from the
|
||||||
|
* main app's src/ at all (see session.ts's header for why).
|
||||||
|
*/
|
||||||
|
|
||||||
|
// A real bcrypt hash of an unguessable value, used only so a request for
|
||||||
|
// a wrong/missing MANAGER_EMAIL still pays the same bcrypt.compare cost
|
||||||
|
// as a wrong password — otherwise "no such email" and "wrong password"
|
||||||
|
// are distinguishable by response time.
|
||||||
|
const DUMMY_HASH = bcrypt.hashSync("nextchair-manager-dummy-hash", 12);
|
||||||
|
|
||||||
|
export async function verifyManagerCredentials(email: string, password: string): Promise<boolean> {
|
||||||
|
const expectedEmail = process.env.MANAGER_EMAIL?.trim().toLowerCase();
|
||||||
|
const expectedHash = process.env.MANAGER_PASSWORD_HASH;
|
||||||
|
if (!expectedEmail || !expectedHash) {
|
||||||
|
throw new Error("MANAGER_EMAIL and MANAGER_PASSWORD_HASH must both be set.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchesEmail = email.trim().toLowerCase() === expectedEmail;
|
||||||
|
const valid = await bcrypt.compare(password, matchesEmail ? expectedHash : DUMMY_HASH);
|
||||||
|
return matchesEmail && valid;
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { isValidSlug } from "./slug";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The manifest.json at the root of every backup archive — what a restore
|
||||||
|
* (possibly on a brand-new server that has never seen this business)
|
||||||
|
* learns about the backup before touching anything.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const BACKUP_FORMAT_VERSION = 1;
|
||||||
|
|
||||||
|
export const BACKUP_MEMBERS = {
|
||||||
|
manifest: "manifest.json",
|
||||||
|
database: "db.sql",
|
||||||
|
media: "media.tar",
|
||||||
|
env: "config/.env",
|
||||||
|
compose: "config/compose.yaml",
|
||||||
|
caddy: "config/site.caddy",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type BackupKind = "regular" | "safety" | "final";
|
||||||
|
export const BACKUP_KINDS: readonly BackupKind[] = ["regular", "safety", "final"];
|
||||||
|
|
||||||
|
export interface BackupManifest {
|
||||||
|
formatVersion: number;
|
||||||
|
slug: string;
|
||||||
|
name: string | null;
|
||||||
|
domain: string;
|
||||||
|
databaseName: string;
|
||||||
|
databaseUser: string;
|
||||||
|
createdAt: string;
|
||||||
|
kind: BackupKind;
|
||||||
|
/** Newest applied Prisma migration in the dumped database; null if it couldn't be read. */
|
||||||
|
lastMigration: string | null;
|
||||||
|
appImage: string | null;
|
||||||
|
hasCaddySite: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ManifestError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "ManifestError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const SAFE_DB_IDENTIFIER = /^[a-z][a-z0-9_]{1,48}[a-z0-9]$/;
|
||||||
|
const MIGRATION_NAME = /^\d{14}_[A-Za-z0-9_]+$/;
|
||||||
|
|
||||||
|
function requireString(obj: Record<string, unknown>, key: string): string {
|
||||||
|
const value = obj[key];
|
||||||
|
if (typeof value !== "string" || !value.trim()) throw new ManifestError(`manifest.json is missing "${key}".`);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildManifest(input: Omit<BackupManifest, "formatVersion">): BackupManifest {
|
||||||
|
return { formatVersion: BACKUP_FORMAT_VERSION, ...input };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Strict parse — a restore trusts nothing from the archive: slug, DB names and domain all end up in file paths or SQL. */
|
||||||
|
export function parseManifest(raw: string): BackupManifest {
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
throw new ManifestError("manifest.json isn't valid JSON — this doesn't look like a NextChair backup.");
|
||||||
|
}
|
||||||
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new ManifestError("manifest.json has an unexpected shape.");
|
||||||
|
const obj = parsed as Record<string, unknown>;
|
||||||
|
|
||||||
|
const formatVersion = obj.formatVersion;
|
||||||
|
if (typeof formatVersion !== "number" || !Number.isInteger(formatVersion) || formatVersion < 1) {
|
||||||
|
throw new ManifestError("manifest.json has no valid formatVersion.");
|
||||||
|
}
|
||||||
|
if (formatVersion > BACKUP_FORMAT_VERSION) {
|
||||||
|
throw new ManifestError(
|
||||||
|
`This backup uses format v${formatVersion}, newer than this Manager understands (v${BACKUP_FORMAT_VERSION}). Update the Manager first.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const slug = requireString(obj, "slug");
|
||||||
|
if (!isValidSlug(slug)) throw new ManifestError(`The backup's slug "${slug}" isn't valid.`);
|
||||||
|
|
||||||
|
const domain = requireString(obj, "domain").trim().toLowerCase();
|
||||||
|
if (/["\s]/.test(domain)) throw new ManifestError("The backup's domain isn't valid.");
|
||||||
|
|
||||||
|
const databaseName = requireString(obj, "databaseName");
|
||||||
|
const databaseUser = requireString(obj, "databaseUser");
|
||||||
|
if (!SAFE_DB_IDENTIFIER.test(databaseName) || !SAFE_DB_IDENTIFIER.test(databaseUser)) {
|
||||||
|
throw new ManifestError("The backup names an unsafe database or user identifier.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const createdAt = requireString(obj, "createdAt");
|
||||||
|
if (Number.isNaN(Date.parse(createdAt))) throw new ManifestError("The backup's createdAt isn't a valid date.");
|
||||||
|
|
||||||
|
const kind = obj.kind;
|
||||||
|
if (kind !== "regular" && kind !== "safety" && kind !== "final") throw new ManifestError("The backup has an unknown kind.");
|
||||||
|
|
||||||
|
const lastMigration = obj.lastMigration;
|
||||||
|
if (lastMigration !== null && lastMigration !== undefined) {
|
||||||
|
if (typeof lastMigration !== "string" || !MIGRATION_NAME.test(lastMigration)) throw new ManifestError("The backup's lastMigration isn't valid.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = typeof obj.name === "string" && obj.name.trim() ? obj.name : null;
|
||||||
|
if (name && (name.includes('"') || name.includes("\n") || name.includes("\r"))) throw new ManifestError("The backup's business name isn't valid.");
|
||||||
|
|
||||||
|
return {
|
||||||
|
formatVersion,
|
||||||
|
slug,
|
||||||
|
name,
|
||||||
|
domain,
|
||||||
|
databaseName,
|
||||||
|
databaseUser,
|
||||||
|
createdAt,
|
||||||
|
kind,
|
||||||
|
lastMigration: typeof lastMigration === "string" ? lastMigration : null,
|
||||||
|
appImage: typeof obj.appImage === "string" ? obj.appImage : null,
|
||||||
|
hasCaddySite: obj.hasCaddySite === true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Migration folders start with a 14-digit timestamp, so the prefix orders them. */
|
||||||
|
function migrationStamp(name: string): string {
|
||||||
|
return name.slice(0, 14);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Version guard: a dump taken on a newer schema than the running image
|
||||||
|
* knows would boot an app that can't read its own database. Refuse when
|
||||||
|
* the backup's last migration is newer than the image's newest. (An older
|
||||||
|
* dump is fine — the container's entrypoint runs `prisma migrate deploy`.)
|
||||||
|
*/
|
||||||
|
export function checkMigrationCompatibility(
|
||||||
|
backupLastMigration: string | null,
|
||||||
|
imageMigrations: string[],
|
||||||
|
): { ok: true } | { ok: false; reason: string } {
|
||||||
|
if (!backupLastMigration) return { ok: true };
|
||||||
|
if (imageMigrations.length === 0) {
|
||||||
|
return { ok: false, reason: "Couldn't read the migration list from the app image, so the schema version can't be checked. Is the app image built?" };
|
||||||
|
}
|
||||||
|
const newestInImage = [...imageMigrations].sort().at(-1)!;
|
||||||
|
if (migrationStamp(backupLastMigration) > migrationStamp(newestInImage)) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
reason: `This backup's database is on migration "${backupLastMigration}", newer than the running app image's newest ("${newestInImage}"). Rebuild/update the app image first, then restore.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import { DEFAULT_KEEP, MAX_KEEP } from "./backup-store";
|
||||||
|
import { readJsonFile, writeJsonFileAtomic } from "./json-file";
|
||||||
|
import { backupSettingsPath } from "./manager-paths";
|
||||||
|
import { withMutex } from "./singleton";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-business backup schedule, in one manager-side file keyed by slug
|
||||||
|
* (STACKS_DIR/manager/backup-settings.json) — never inside a business's
|
||||||
|
* own stack directory, so it survives that stack being deleted and
|
||||||
|
* restored.
|
||||||
|
*
|
||||||
|
* Times are the Manager container's LOCAL time (set TZ on the container
|
||||||
|
* to change it; the default is UTC).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type BackupFrequency = "daily" | "weekly";
|
||||||
|
|
||||||
|
export interface BackupSettings {
|
||||||
|
enabled: boolean;
|
||||||
|
frequency: BackupFrequency;
|
||||||
|
/** "HH:MM", 24h, Manager-local. */
|
||||||
|
time: string;
|
||||||
|
/** 0 = Sunday … 6 = Saturday; only used when weekly. */
|
||||||
|
weekday: number;
|
||||||
|
/** How many regular backups to keep (newest first). */
|
||||||
|
keep: number;
|
||||||
|
enabledAt: string | null;
|
||||||
|
lastAttemptAt: string | null;
|
||||||
|
lastSuccessAt: string | null;
|
||||||
|
lastStatus: "ok" | "failed" | null;
|
||||||
|
lastMessage: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_BACKUP_SETTINGS: BackupSettings = {
|
||||||
|
enabled: false,
|
||||||
|
frequency: "daily",
|
||||||
|
time: "03:00",
|
||||||
|
weekday: 0,
|
||||||
|
keep: DEFAULT_KEEP,
|
||||||
|
enabledAt: null,
|
||||||
|
lastAttemptAt: null,
|
||||||
|
lastSuccessAt: null,
|
||||||
|
lastStatus: null,
|
||||||
|
lastMessage: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
interface BackupSettingsFile {
|
||||||
|
businesses: Record<string, Partial<BackupSettings>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TIME_PATTERN = /^([01]\d|2[0-3]):([0-5]\d)$/;
|
||||||
|
|
||||||
|
export function isValidBackupTime(value: string): boolean {
|
||||||
|
return TIME_PATTERN.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalize(raw: Partial<BackupSettings> | undefined): BackupSettings {
|
||||||
|
const merged = { ...DEFAULT_BACKUP_SETTINGS, ...(raw ?? {}) };
|
||||||
|
return {
|
||||||
|
...merged,
|
||||||
|
frequency: merged.frequency === "weekly" ? "weekly" : "daily",
|
||||||
|
time: isValidBackupTime(merged.time) ? merged.time : DEFAULT_BACKUP_SETTINGS.time,
|
||||||
|
weekday: Number.isInteger(merged.weekday) && merged.weekday >= 0 && merged.weekday <= 6 ? merged.weekday : 0,
|
||||||
|
keep: Number.isInteger(merged.keep) ? Math.min(MAX_KEEP, Math.max(1, merged.keep)) : DEFAULT_KEEP,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readFile(): Promise<BackupSettingsFile> {
|
||||||
|
const file = await readJsonFile<BackupSettingsFile>(backupSettingsPath(), { businesses: {} });
|
||||||
|
return { businesses: file.businesses ?? {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readBackupSettings(slug: string): Promise<BackupSettings> {
|
||||||
|
return normalize((await readFile()).businesses[slug]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readAllBackupSettings(): Promise<Record<string, BackupSettings>> {
|
||||||
|
const file = await readFile();
|
||||||
|
return Object.fromEntries(Object.entries(file.businesses).map(([slug, raw]) => [slug, normalize(raw)]));
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackupSettingsInput {
|
||||||
|
enabled: boolean;
|
||||||
|
frequency: BackupFrequency;
|
||||||
|
time: string;
|
||||||
|
weekday: number;
|
||||||
|
keep: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateBackupSettingsInput(input: BackupSettingsInput): string | null {
|
||||||
|
if (!isValidBackupTime(input.time)) return "Enter the time as HH:MM (24-hour).";
|
||||||
|
if (input.frequency !== "daily" && input.frequency !== "weekly") return "Choose daily or weekly.";
|
||||||
|
if (!Number.isInteger(input.weekday) || input.weekday < 0 || input.weekday > 6) return "Choose a day of the week.";
|
||||||
|
if (!Number.isInteger(input.keep) || input.keep < 1 || input.keep > MAX_KEEP) return `Keep between 1 and ${MAX_KEEP} backups.`;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveBackupSettings(slug: string, input: BackupSettingsInput, now: Date = new Date()): Promise<BackupSettings> {
|
||||||
|
return withMutex("backup-settings", async () => {
|
||||||
|
const file = await readFile();
|
||||||
|
const current = normalize(file.businesses[slug]);
|
||||||
|
const next: BackupSettings = {
|
||||||
|
...current,
|
||||||
|
enabled: input.enabled,
|
||||||
|
frequency: input.frequency,
|
||||||
|
time: input.time,
|
||||||
|
weekday: input.weekday,
|
||||||
|
keep: input.keep,
|
||||||
|
// The first scheduled run waits for the next scheduled time after enabling, rather than firing the moment the box is ticked ("Back up now" exists for that).
|
||||||
|
enabledAt: input.enabled && !current.enabled ? now.toISOString() : current.enabledAt,
|
||||||
|
};
|
||||||
|
file.businesses[slug] = next;
|
||||||
|
await writeJsonFileAtomic(backupSettingsPath(), file);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recordBackupOutcome(
|
||||||
|
slug: string,
|
||||||
|
outcome: { ok: boolean; message: string; at?: Date },
|
||||||
|
): Promise<void> {
|
||||||
|
return withMutex("backup-settings", async () => {
|
||||||
|
const file = await readFile();
|
||||||
|
const current = normalize(file.businesses[slug]);
|
||||||
|
const at = (outcome.at ?? new Date()).toISOString();
|
||||||
|
file.businesses[slug] = {
|
||||||
|
...current,
|
||||||
|
lastAttemptAt: at,
|
||||||
|
lastSuccessAt: outcome.ok ? at : current.lastSuccessAt,
|
||||||
|
lastStatus: outcome.ok ? "ok" : "failed",
|
||||||
|
lastMessage: outcome.message.slice(0, 500),
|
||||||
|
};
|
||||||
|
await writeJsonFileAtomic(backupSettingsPath(), file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Marks that a scheduled run was STARTED (so a failing backup retries at the next scheduled time, not every scheduler tick). */
|
||||||
|
export function recordBackupAttempt(slug: string, at: Date = new Date()): Promise<void> {
|
||||||
|
return withMutex("backup-settings", async () => {
|
||||||
|
const file = await readFile();
|
||||||
|
file.businesses[slug] = { ...normalize(file.businesses[slug]), lastAttemptAt: at.toISOString() };
|
||||||
|
await writeJsonFileAtomic(backupSettingsPath(), file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The most recent scheduled moment at or before `now` (Manager-local time). */
|
||||||
|
export function lastScheduledOccurrence(settings: Pick<BackupSettings, "frequency" | "time" | "weekday">, now: Date): Date {
|
||||||
|
const [hours, minutes] = settings.time.split(":").map(Number) as [number, number];
|
||||||
|
const candidate = new Date(now);
|
||||||
|
candidate.setHours(hours, minutes, 0, 0);
|
||||||
|
if (candidate.getTime() > now.getTime()) candidate.setDate(candidate.getDate() - 1);
|
||||||
|
if (settings.frequency === "weekly") {
|
||||||
|
while (candidate.getDay() !== settings.weekday) candidate.setDate(candidate.getDate() - 1);
|
||||||
|
}
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Due when a scheduled moment has passed since the later of "last
|
||||||
|
* attempt" and "when it was enabled". Keyed on attempts, not successes:
|
||||||
|
* a backup that fails (disk full, MySQL down) is retried at the NEXT
|
||||||
|
* scheduled time and alerted on, instead of being re-run every few
|
||||||
|
* seconds all day.
|
||||||
|
*/
|
||||||
|
export function isBackupDue(settings: BackupSettings, now: Date): boolean {
|
||||||
|
if (!settings.enabled) return false;
|
||||||
|
const occurrence = lastScheduledOccurrence(settings, now);
|
||||||
|
const since = Math.max(
|
||||||
|
settings.lastAttemptAt ? Date.parse(settings.lastAttemptAt) : 0,
|
||||||
|
settings.enabledAt ? Date.parse(settings.enabledAt) : 0,
|
||||||
|
);
|
||||||
|
return occurrence.getTime() > since;
|
||||||
|
}
|
||||||
Loaded 100 of 403 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user