diff --git a/.env.business.example b/.env.business.example
index 159ae45..79f329f 100644
--- a/.env.business.example
+++ b/.env.business.example
@@ -53,10 +53,14 @@ INSTALLATION_TOKEN="replace-me"
# 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 and stacks/infra/scripts/process-cron.sh for the one-time
-# crontab setup this requires.
+# section: the Manager's built-in scheduler normally does this (the host
+# crontab script is only a fallback).
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) ──
# Platform-level account with every Owner permission for THIS business.
# Runs automatically on every container start (see docker/entrypoint.sh)
diff --git a/README.md b/README.md
index a5a6fbe..9a59336 100644
--- a/README.md
+++ b/README.md
@@ -138,54 +138,62 @@ this for you), then finish its setup by visiting
Repeat this step for each additional business, whenever you want one.
-### 6. Notification delivery (required once, covers every business)
+### 6. Notification delivery (handled by the Manager)
-Staff invitation emails, appointment reminders, loyalty reward emails,
-and Google Reviews sync are **not sent automatically** — the app only
-queues them; something has to actually trigger delivery on a schedule by
-calling two `CRON_SECRET`-gated endpoints
-(`/api/cron/process-notifications`, `/api/cron/sync-google-reviews` — see
-their own route files for the full reasoning). Skip this step and every
-business's queued notifications just sit as `PENDING` forever — the UI
-still says "an invitation email has been sent" the moment it's queued,
-regardless of whether anything has actually delivered it yet.
+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.
-[stacks/infra/scripts/process-cron.sh](stacks/infra/scripts/process-cron.sh)
-calls both endpoints for **every** business under `STACKS_DIR` in one
-run, so a single crontab entry covers businesses added later too — no
-per-business cron line to maintain.
+**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.
-Many minimal cloud VPS images (including some Debian ones) don't ship
-`cron` at all — `crontab -e` failing with `command not found` means this
-first:
+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
-sudo apt update && sudo apt install -y cron
-sudo systemctl enable --now cron
-```
-
-Then add this to the VPS's crontab (`crontab -e`) once — **not** `sudo
-crontab -e`: run it as the same non-root user that already owns
-`/opt/nextchair` (the one you've been running `git pull`/`docker build`
-as), so the log file below is writable without needing root:
-
-```bash
-# Deliver queued notifications every 2 minutes.
-*/2 * * * * STACKS_DIR=/opt/nextchair/stacks /opt/nextchair/stacks/infra/scripts/process-cron.sh notifications >> /opt/nextchair/nextchair-cron.log 2>&1
+# 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
```
-(Adjust `STACKS_DIR`/the log path if you cloned this repo somewhere other
-than `/opt/nextchair`, or don't have write access to it — `/var/log/`
-itself is **not** a safe default here: it needs root, and a crontab
-entry that can't write its own log file just silently produces no file
-at all, which looks identical to "the job never ran.") To confirm it's
-working, or to flush a notification that's stuck pending right now, run
-either line's command by hand and check
-`/opt/nextchair/nextchair-cron.log` — a healthy run logs `
-process-notifications ok` for every business.
+A healthy run logs ` 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
diff --git a/docs/phase-6-summary.md b/docs/phase-6-summary.md
index 97c3c20..f6ffd92 100644
--- a/docs/phase-6-summary.md
+++ b/docs/phase-6-summary.md
@@ -157,10 +157,10 @@ npm run test:e2e # needs DATABASE_URL → a real (throwaway) Postgres
## 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~~** — *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.
-- **"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.
- **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.
diff --git a/manager/Dockerfile b/manager/Dockerfile
index 9219e98..cdaa204 100644
--- a/manager/Dockerfile
+++ b/manager/Dockerfile
@@ -30,7 +30,7 @@ ENV NODE_ENV=production
# stacks/infra/compose.yaml's manager service (docker.sock).
RUN apk add --no-cache docker-cli docker-cli-compose
-# 7-Zip for encrypted backups (`7zz a -mhe=on -p…`, see src/lib/sevenzip.ts).
+# 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
diff --git a/manager/README.md b/manager/README.md
index b1e71db..056acba 100644
--- a/manager/README.md
+++ b/manager/README.md
@@ -63,16 +63,17 @@ service. State lives next to `global-settings.json`, under
over; on boot, jobs left "running" are marked failed and their locks
released.
- **Backups** (`src/lib/backup*.ts`, `restore.ts`; UI at a business's
- *Backups* page and the global *Restore* page): an encrypted `.7z`
- (`7zz`, AES-256, encrypted headers) of the database dump, media and
+ *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//`. Written as `.partial`, verified
with `7zz t`, then renamed; the newest N (default 2, per business) are
kept. Safety backups (before an update or a replacing restore) and final
- backups (before a delete) are kept separately and never evict them. One
- passphrase protects everything: generated at the first enable / first
- *Back up now*, stored encrypted (`MANAGER_SETTINGS_ENCRYPTION_KEY`),
- **shown once** — save it, a fresh server needs it to restore. Restore
- works onto an existing business (after a safety backup) or a server that
+ 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 /
diff --git a/manager/src/app/api/backups/[slug]/[file]/route.ts b/manager/src/app/api/backups/[slug]/[file]/route.ts
index fb38291..c0d9197 100644
--- a/manager/src/app/api/backups/[slug]/[file]/route.ts
+++ b/manager/src/app/api/backups/[slug]/[file]/route.ts
@@ -6,7 +6,7 @@ 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 encrypted; the download itself exposes nothing without the passphrase. */
+/** 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;
diff --git a/manager/src/app/api/jobs/[id]/route.ts b/manager/src/app/api/jobs/[id]/route.ts
index c2f024b..985172a 100644
--- a/manager/src/app/api/jobs/[id]/route.ts
+++ b/manager/src/app/api/jobs/[id]/route.ts
@@ -6,8 +6,7 @@ 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 (the restore passphrase only ever
- * lives in the running job's closure).
+ * and a result summary — never secrets.
*/
export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
diff --git a/manager/src/app/dashboard/[slug]/actions.ts b/manager/src/app/dashboard/[slug]/actions.ts
index 390dae4..805ff19 100644
--- a/manager/src/app/dashboard/[slug]/actions.ts
+++ b/manager/src/app/dashboard/[slug]/actions.ts
@@ -4,7 +4,6 @@ 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 { ensureBackupPassphrase, hasBackupPassphrase } from "@/lib/global-settings";
import { setIntentionallyStopped } from "@/lib/health-watcher";
import { fetchInstallationStatus } from "@/lib/installation-status";
import { NO_JOB, type JobStartState } from "@/lib/job-state";
@@ -55,10 +54,8 @@ export async function restartStackAction(slug: string): Promise {
export async function updateStackAction(slug: string, backupFirst: boolean): Promise {
await assertStack(slug);
try {
- // "Back up first" needs a passphrase; if this is the very first backup anywhere, it is minted here and shown once.
- const newPassphrase = backupFirst && !(await hasBackupPassphrase()) ? (await ensureBackupPassphrase()).passphrase : null;
const { jobId } = await startUpdateJob(slug, backupFirst);
- return { error: null, jobId, newPassphrase };
+ 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." };
diff --git a/manager/src/app/dashboard/[slug]/backups/actions.ts b/manager/src/app/dashboard/[slug]/backups/actions.ts
index dc033ba..f3b5516 100644
--- a/manager/src/app/dashboard/[slug]/backups/actions.ts
+++ b/manager/src/app/dashboard/[slug]/backups/actions.ts
@@ -3,7 +3,6 @@
import { revalidatePath } from "next/cache";
import { saveBackupSettings, validateBackupSettingsInput, type BackupFrequency } from "@/lib/backup-settings";
import { deleteBackup } from "@/lib/backup-store";
-import { ensureBackupPassphrase, hasBackupPassphrase, rotateBackupPassphrase } from "@/lib/global-settings";
import { NO_JOB, type JobStartState } from "@/lib/job-state";
import { assertNotLocked, BusinessLockedError } from "@/lib/locks";
import { startBackupJob } from "@/lib/operations";
@@ -16,8 +15,6 @@ async function assertBusiness(slug: string): Promise {
export interface SaveBackupSettingsState {
error: string | null;
saved: boolean;
- /** Plaintext passphrase — only in the one response that just generated it (first enable anywhere). */
- newPassphrase: string | null;
}
export async function saveBackupSettingsAction(
@@ -35,26 +32,18 @@ export async function saveBackupSettingsAction(
keep: Number(formData.get("keep") ?? 2),
};
const problem = validateBackupSettingsInput(input);
- if (problem) return { error: problem, saved: false, newPassphrase: null };
-
- // Generated at the first enable, encrypted with the Manager's key, shown once.
- let newPassphrase: string | null = null;
- if (input.enabled) {
- const ensured = await ensureBackupPassphrase();
- if (ensured.created) newPassphrase = ensured.passphrase;
- }
+ if (problem) return { error: problem, saved: false };
await saveBackupSettings(slug, input);
revalidatePath(`/dashboard/${slug}/backups`);
- return { error: null, saved: true, newPassphrase };
+ return { error: null, saved: true };
}
export async function backupNowAction(slug: string): Promise {
await assertBusiness(slug);
try {
- const newPassphrase = (await hasBackupPassphrase()) ? null : (await ensureBackupPassphrase()).passphrase;
const { jobId } = await startBackupJob(slug, "regular");
- return { error: null, jobId, newPassphrase };
+ 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." };
@@ -71,12 +60,3 @@ export async function deleteBackupAction(slug: string, fileName: string): Promis
return { error: error instanceof Error ? error.message : "Couldn't delete that backup." };
}
}
-
-/** Replaces the passphrase used for NEW backups. Older archives still need the old one — the UI says so. Returns the new value once. */
-export async function rotatePassphraseAction(): Promise<{ error: string | null; newPassphrase: string | null }> {
- try {
- return { error: null, newPassphrase: await rotateBackupPassphrase() };
- } catch (error) {
- return { error: error instanceof Error ? error.message : "Couldn't generate a passphrase.", newPassphrase: null };
- }
-}
diff --git a/manager/src/app/dashboard/[slug]/backups/backups-panel.tsx b/manager/src/app/dashboard/[slug]/backups/backups-panel.tsx
index 04c47d5..8f68501 100644
--- a/manager/src/app/dashboard/[slug]/backups/backups-panel.tsx
+++ b/manager/src/app/dashboard/[slug]/backups/backups-panel.tsx
@@ -5,11 +5,9 @@ import { useRouter } from "next/navigation";
import { useActionState, useState, useTransition } from "react";
import type { BackupEntry } from "@/lib/backup-store";
import { JobProgress } from "../../job-progress";
-import { PassphraseNotice } from "../../passphrase-notice";
import {
backupNowAction,
deleteBackupAction,
- rotatePassphraseAction,
saveBackupSettingsAction,
type SaveBackupSettingsState,
} from "./actions";
@@ -27,7 +25,7 @@ interface SettingsView {
}
const WEEKDAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
-const initialSaveState: SaveBackupSettingsState = { error: null, saved: false, newPassphrase: null };
+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`;
@@ -50,16 +48,12 @@ export function BackupsPanel({
slug,
settings,
backups,
- hasPassphrase,
- passphraseCreatedAt,
activeJobId,
timeZone,
}: {
slug: string;
settings: SettingsView;
backups: BackupEntry[];
- hasPassphrase: boolean;
- passphraseCreatedAt: string | null;
activeJobId: string | null;
timeZone: string;
}) {
@@ -70,8 +64,6 @@ export function BackupsPanel({
const [error, setError] = useState(null);
const [jobId, setJobId] = useState(activeJobId);
const [jobRunning, setJobRunning] = useState(activeJobId !== null);
- const [shownPassphrase, setShownPassphrase] = useState(null);
- const passphraseToShow = shownPassphrase ?? saveState.newPassphrase;
const backupNow = () => {
setError(null);
@@ -81,7 +73,6 @@ export function BackupsPanel({
setError(result.error);
return;
}
- if (result.newPassphrase) setShownPassphrase(result.newPassphrase);
setJobId(result.jobId);
setJobRunning(true);
});
@@ -97,25 +88,8 @@ export function BackupsPanel({
});
};
- const rotate = () => {
- if (
- !window.confirm(
- "Generate a NEW passphrase? New backups will use it. Existing backups keep the old one — you will need both to open all of them.",
- )
- ) {
- return;
- }
- startTransition(async () => {
- const result = await rotatePassphraseAction();
- if (result.error) setError(result.error);
- else setShownPassphrase(result.newPassphrase);
- });
- };
-
return (
- {passphraseToShow && }
-
Schedule
-
Passphrase
+
Handle with care
- {hasPassphrase
- ? `A passphrase protects every backup${passphraseCreatedAt ? ` (created ${new Date(passphraseCreatedAt).toLocaleDateString()})` : ""}. It is stored encrypted here and cannot be shown again — keep your saved copy; a fresh server needs it to restore.`
- : "No passphrase yet — one is generated (and shown once) the first time you enable backups or press Back up now."}
+ Backups are not password-protected. Each one contains this business's secrets (its .env: 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.
diff --git a/manager/src/app/dashboard/[slug]/delete/actions.ts b/manager/src/app/dashboard/[slug]/delete/actions.ts
index a41b400..4f2d73e 100644
--- a/manager/src/app/dashboard/[slug]/delete/actions.ts
+++ b/manager/src/app/dashboard/[slug]/delete/actions.ts
@@ -1,6 +1,5 @@
"use server";
-import { ensureBackupPassphrase, hasBackupPassphrase } from "@/lib/global-settings";
import { BusinessLockedError } from "@/lib/locks";
import { startDeleteJob } from "@/lib/operations";
@@ -8,8 +7,6 @@ export interface DeleteBusinessState {
error: string | null;
/** Set once the delete job has started — the form then follows it with . */
jobId: string | null;
- /** Plaintext backup passphrase, only in the response that just generated it. */
- newPassphrase: string | null;
}
/**
@@ -28,16 +25,15 @@ export async function deleteBusinessAction(
): Promise {
const confirmation = String(formData.get("confirmation") ?? "");
if (confirmation !== slug) {
- return { error: `Type "${slug}" exactly to confirm deletion.`, jobId: null, newPassphrase: null };
+ return { error: `Type "${slug}" exactly to confirm deletion.`, jobId: null };
}
const finalBackup = formData.get("finalBackup") === "on";
try {
- const newPassphrase = finalBackup && !(await hasBackupPassphrase()) ? (await ensureBackupPassphrase()).passphrase : null;
const { jobId } = await startDeleteJob(slug, finalBackup);
- return { error: null, jobId, newPassphrase };
+ return { error: null, jobId };
} catch (error) {
- if (error instanceof BusinessLockedError) return { error: error.message, jobId: null, newPassphrase: null };
- return { error: error instanceof Error ? error.message : "Failed to delete this business.", jobId: null, newPassphrase: null };
+ if (error instanceof BusinessLockedError) return { error: error.message, jobId: null };
+ return { error: error instanceof Error ? error.message : "Failed to delete this business.", jobId: null };
}
}
diff --git a/manager/src/app/dashboard/[slug]/delete/delete-business-form.tsx b/manager/src/app/dashboard/[slug]/delete/delete-business-form.tsx
index 40517b3..fcf3462 100644
--- a/manager/src/app/dashboard/[slug]/delete/delete-business-form.tsx
+++ b/manager/src/app/dashboard/[slug]/delete/delete-business-form.tsx
@@ -3,10 +3,9 @@
import Link from "next/link";
import { useActionState, useState } from "react";
import { JobProgress } from "../../job-progress";
-import { PassphraseNotice } from "../../passphrase-notice";
import { deleteBusinessAction, type DeleteBusinessState } from "./actions";
-const initialState: DeleteBusinessState = { error: null, jobId: null, newPassphrase: null };
+const initialState: DeleteBusinessState = { error: null, jobId: null };
export function DeleteBusinessForm({ slug }: { slug: string }) {
const boundAction = deleteBusinessAction.bind(null, slug);
@@ -17,7 +16,6 @@ export function DeleteBusinessForm({ slug }: { slug: string }) {
if (state.jobId) {
return (
- {state.newPassphrase && }
Back to businesses
diff --git a/manager/src/app/dashboard/passphrase-notice.tsx b/manager/src/app/dashboard/passphrase-notice.tsx
deleted file mode 100644
index d9b28d3..0000000
--- a/manager/src/app/dashboard/passphrase-notice.tsx
+++ /dev/null
@@ -1,33 +0,0 @@
-"use client";
-
-import { useState } from "react";
-
-/** The backup passphrase, shown exactly once (in the response that generated it). Nothing here persists it — the server keeps only an encrypted copy. */
-export function PassphraseNotice({ passphrase }: { passphrase: string }) {
- const [copied, setCopied] = useState(false);
-
- async function copy() {
- try {
- await navigator.clipboard.writeText(passphrase);
- setCopied(true);
- } catch {
- setCopied(false);
- }
- }
-
- return (
-
-
Save this backup passphrase now — it won't be shown again.
-
- Every backup is encrypted with it. On a new server you'll need to type it to restore; without it the backups
- cannot be opened by anyone.
-
-
- {passphrase}
-
-
-
- );
-}
diff --git a/manager/src/app/dashboard/restore/actions.ts b/manager/src/app/dashboard/restore/actions.ts
index af91d60..5320b52 100644
--- a/manager/src/app/dashboard/restore/actions.ts
+++ b/manager/src/app/dashboard/restore/actions.ts
@@ -1,6 +1,5 @@
"use server";
-import { ensureBackupPassphrase, hasBackupPassphrase, readBackupPassphrase } from "@/lib/global-settings";
import { BusinessLockedError } from "@/lib/locks";
import { startRestoreJob } from "@/lib/operations";
import { planRestore, readArchiveManifest, resolveArchivePath, type RestoreSource } from "@/lib/restore";
@@ -30,17 +29,11 @@ export interface InspectRestoreState {
preview: RestorePreview | null;
}
-async function passphraseFor(input: string): Promise {
- const passphrase = input.trim() || (await readBackupPassphrase()) || "";
- if (!passphrase) throw new Error("Enter the backup passphrase (this server has no stored one).");
- return passphrase;
-}
-
-/** Opens only the encrypted header (fast) and reports what restoring would do — nothing is changed. A wrong passphrase fails here. */
-export async function inspectRestoreAction(sourceKey: string, passphraseInput: string): Promise {
+/** Reads only the manifest (fast) and reports what restoring would do — nothing is changed. */
+export async function inspectRestoreAction(sourceKey: string): Promise {
try {
const source = parseSourceKey(sourceKey);
- const manifest = await readArchiveManifest(resolveArchivePath(source), await passphraseFor(passphraseInput));
+ const manifest = await readArchiveManifest(resolveArchivePath(source));
const plan = await planRestore(manifest);
return {
error: null,
@@ -57,11 +50,13 @@ export async function inspectRestoreAction(sourceKey: string, passphraseInput: s
},
};
} catch (error) {
- // A wrong passphrase makes 7zz fail with a generic message; say so plainly instead of passing its output through.
+ // 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 looksLikePassphrase = /wrong password|can not open encrypted|data error|headers error|exited with code 2/i.test(message);
+ const looksUnreadable = /wrong password|can not open|data error|headers error|exited with code 2/i.test(message);
return {
- error: looksLikePassphrase ? "Couldn't open the archive — wrong passphrase, or the file isn't a valid NextChair backup." : message || "Couldn't read that backup.",
+ 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,
};
}
@@ -70,20 +65,17 @@ export async function inspectRestoreAction(sourceKey: string, passphraseInput: s
export interface StartRestoreState {
error: string | null;
jobId: string | null;
- newPassphrase: string | null;
}
export async function startRestoreAction(
sourceKey: string,
- passphraseInput: string,
replace: boolean,
confirmSlug: string,
): Promise {
- const fail = (error: string): StartRestoreState => ({ error, jobId: null, newPassphrase: null });
+ const fail = (error: string): StartRestoreState => ({ error, jobId: null });
try {
const source = parseSourceKey(sourceKey);
- const passphrase = await passphraseFor(passphraseInput);
- const manifest = await readArchiveManifest(resolveArchivePath(source), passphrase);
+ const manifest = await readArchiveManifest(resolveArchivePath(source));
// Re-checked here, not trusted from the preview the browser holds.
const plan = await planRestore(manifest);
@@ -93,10 +85,8 @@ export async function startRestoreAction(
return fail(`"${manifest.slug}" already exists. Tick "replace" and type its slug to confirm overwriting it.`);
}
- // Replacing takes a safety backup, which needs a stored passphrase; mint one (shown once) if this server has none yet.
- const newPassphrase = plan.slugExists && !(await hasBackupPassphrase()) ? (await ensureBackupPassphrase()).passphrase : null;
- const { jobId } = await startRestoreJob({ source, passphrase, replace });
- return { error: null, jobId, newPassphrase };
+ 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.");
diff --git a/manager/src/app/dashboard/restore/page.tsx b/manager/src/app/dashboard/restore/page.tsx
index d29181b..4a7d151 100644
--- a/manager/src/app/dashboard/restore/page.tsx
+++ b/manager/src/app/dashboard/restore/page.tsx
@@ -1,7 +1,6 @@
import type { Metadata } from "next";
import Link from "next/link";
import { listAllBackups } from "@/lib/backup-store";
-import { hasBackupPassphrase } from "@/lib/global-settings";
import { RestoreWizard } from "./restore-wizard";
export const metadata: Metadata = { title: "Restore" };
@@ -9,7 +8,7 @@ export const dynamic = "force-dynamic";
export default async function RestorePage({ searchParams }: { searchParams: Promise<{ slug?: string; file?: string }> }) {
const { slug, file } = await searchParams;
- const [backups, hasPassphrase] = await Promise.all([listAllBackups(), hasBackupPassphrase()]);
+ 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;
@@ -23,8 +22,7 @@ export default async function RestorePage({ searchParams }: { searchParams: Prom
Restore a business
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 .7z, enter the passphrase, check what it contains, then
- confirm.
+ backup or upload a .7z, check what it contains, then confirm.
+ {noShowStanding.count} no-show{noShowStanding.count === 1 ? "" : "s"} since their last visit.
+ {noShowStanding.blocked && " Online booking is blocked for them — you can still book them in."}
+
{row.customerName}
diff --git a/src/app/(staff)/staff/settings/calendar/page.tsx b/src/app/(staff)/staff/settings/calendar/page.tsx
index d817daf..fe27574 100644
--- a/src/app/(staff)/staff/settings/calendar/page.tsx
+++ b/src/app/(staff)/staff/settings/calendar/page.tsx
@@ -39,6 +39,7 @@ export default async function StaffCalendarSettingsPage() {
connected: true,
accountEmail: connection.accountEmail,
selectedCalendarName: connection.selectedCalendarName,
+ appCreatedCalendar: connection.appCreatedCalendar,
lastSyncAt: connection.lastSyncAt?.toISOString(),
lastSyncError: connection.lastSyncError,
}
diff --git a/src/app/(staff)/staff/settings/notifications/page.tsx b/src/app/(staff)/staff/settings/notifications/page.tsx
index daf617f..5eb5c9b 100644
--- a/src/app/(staff)/staff/settings/notifications/page.tsx
+++ b/src/app/(staff)/staff/settings/notifications/page.tsx
@@ -22,6 +22,7 @@ const STAFF_GROUPS: NotificationPreferenceGroup[] = [
{ type: NotificationType.STAFF_APPOINTMENT_RESCHEDULED, label: "Appointment rescheduled" },
{ type: NotificationType.STAFF_APPOINTMENT_CANCELLED, label: "Appointment cancelled" },
{ type: NotificationType.STAFF_APPOINTMENT_REMINDER, label: "Upcoming appointment reminders" },
+ { type: NotificationType.STAFF_WHATSAPP_REMINDERS_DUE, label: "WhatsApp reminders ready to send" },
{ type: NotificationType.STAFF_SCHEDULE_CHANGED, label: "Your schedule changed" },
{ type: NotificationType.STAFF_INVITATION_RECEIVED, label: "Team invitations" },
];
diff --git a/src/app/api/cron/process-notifications/route.ts b/src/app/api/cron/process-notifications/route.ts
index 651b3fc..06b4406 100644
--- a/src/app/api/cron/process-notifications/route.ts
+++ b/src/app/api/cron/process-notifications/route.ts
@@ -1,8 +1,9 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db/prisma";
-import { isInstalledForRequest } from "@/lib/installation/installation-state";
+import { INSTALLATION_SINGLETON_ID, isInstalledForRequest } from "@/lib/installation/installation-state";
import { secretsMatch } from "@/lib/security/secrets-match";
import { expireOverdueRewards } from "@/services/loyalty/loyalty.service";
+import { nudgeStaffWhatsAppReminders } from "@/services/scheduling/whatsapp-reminders.service";
import { getEmailProvider, getPushProvider } from "@/services/notifications/providers";
import { processDueNotificationDeliveries } from "@/services/notifications/reminder-worker";
@@ -48,6 +49,11 @@ export async function POST(request: Request) {
return NextResponse.json({ skipped: "not_installed" });
}
+ // Proof of life for /api/health: "the cron stopped" must be distinguishable from "nothing to send".
+ await prisma.applicationInstallation
+ .updateMany({ where: { id: INSTALLATION_SINGLETON_ID }, data: { lastCronRunAt: new Date() } })
+ .catch(() => {});
+
const result = await processDueNotificationDeliveries(prisma, {
emailProvider: getEmailProvider(),
pushProvider: getPushProvider(),
@@ -55,5 +61,12 @@ export async function POST(request: Request) {
// Piggybacks the loyalty reward expiry sweep on the same scheduled trigger
// rather than standing up a second cron endpoint for one small job.
await expireOverdueRewards(prisma);
+ // Same trigger again: tell barbers when WhatsApp reminders are waiting to be sent by hand.
+ // Never lets a failure here fail the run — the notification queue above already did its job.
+ try {
+ await nudgeStaffWhatsAppReminders(prisma);
+ } catch (error) {
+ console.error("[cron] WhatsApp reminder nudge failed", error instanceof Error ? error.message : error);
+ }
return NextResponse.json(result);
}
diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts
index 0ea2d92..8c84b9b 100644
--- a/src/app/api/health/route.ts
+++ b/src/app/api/health/route.ts
@@ -1,6 +1,8 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db/prisma";
import { getInstallationState } from "@/lib/installation/installation-state";
+import { INSTALLATION_SINGLETON_ID } from "@/lib/installation/installation-state";
+import { assessQueueHealth, getQueueHealth } from "@/services/notifications/queue-health";
/**
* Docker/orchestrator health check — deliberately never requires
@@ -17,6 +19,11 @@ import { getInstallationState } from "@/lib/installation/installation-state";
* installation token, rather than Manager needing its own authenticated
* status endpoint. Deliberately still just a boolean + a timestamp: never
* the installation token itself, never anything else about the tenant.
+ *
+ * Also reports whether notifications are actually going out (`queue`,
+ * `cron`, `degradedReasons`): the Manager emails the operator when this
+ * turns "degraded". It still answers HTTP 200 when degraded — the Docker
+ * healthcheck must not restart a container over a stopped cron.
*/
export async function GET() {
try {
@@ -27,10 +34,34 @@ export async function GET() {
const { installed, installedAt } = await getInstallationState().catch(() => ({ installed: false, installedAt: null }));
+ // Never lets the queue report break the probe itself — a failure here just omits the extra fields.
+ let queueReport: Record = {};
+ let status: "healthy" | "degraded" = "healthy";
+ try {
+ const [queue, installation] = await Promise.all([
+ getQueueHealth(prisma),
+ prisma.applicationInstallation.findUnique({
+ where: { id: INSTALLATION_SINGLETON_ID },
+ select: { lastCronRunAt: true },
+ }),
+ ]);
+ const lastCronRunAt = installation?.lastCronRunAt ?? null;
+ const assessment = assessQueueHealth({ installed, installedAt, lastCronRunAt, queue });
+ status = assessment.status;
+ queueReport = {
+ queue,
+ cron: { lastCronRunAt: lastCronRunAt ? lastCronRunAt.toISOString() : null },
+ degradedReasons: assessment.degradedReasons,
+ };
+ } catch {
+ // omitted
+ }
+
return NextResponse.json({
- status: "healthy",
+ status,
database: "reachable",
installed,
installedAt: installedAt ? installedAt.toISOString() : null,
+ ...queueReport,
});
}
diff --git a/src/app/api/staff/calendar/google/callback/route.ts b/src/app/api/staff/calendar/google/callback/route.ts
index bc0eba7..4fb5b83 100644
--- a/src/app/api/staff/calendar/google/callback/route.ts
+++ b/src/app/api/staff/calendar/google/callback/route.ts
@@ -40,7 +40,10 @@ export async function GET(request: Request) {
if (!session?.user?.id) {
return NextResponse.redirect(new URL("/sign-in", process.env.NEXT_PUBLIC_APP_URL));
}
- const staffProfile = await prisma.staffProfile.findUnique({ where: { userId: session.user.id }, select: { id: true } });
+ const staffProfile = await prisma.staffProfile.findUnique({
+ where: { userId: session.user.id },
+ select: { id: true, business: { select: { name: true, timezone: true } } },
+ });
if (!staffProfile) {
return settingsRedirect("error", "not_staff");
}
@@ -57,11 +60,23 @@ export async function GET(request: Request) {
try {
const provider = new GoogleCalendarProvider();
const tokens = await provider.exchangeCodeForTokens({ code, redirectUri: getRedirectUri() });
- const calendars = await provider.listWritableCalendars(tokens.accessToken);
- const primaryCalendar = calendars.find((c) => c.primary) ?? calendars[0];
- if (!primaryCalendar) {
- return settingsRedirect("error", "no_writable_calendar");
- }
+ // The narrow scope can't list a person's calendars (that is the point of it), so events go to a
+ // calendar this app creates. Reconnecting reuses the one it made before, if it's still there,
+ // rather than adding another "bookings" calendar every time.
+ const existing = await prisma.staffCalendarConnection.findUnique({
+ where: { staffId: staffProfile.id },
+ select: { appCreatedCalendar: true, selectedCalendarId: true, selectedCalendarName: true },
+ });
+ const reusable =
+ existing?.appCreatedCalendar &&
+ (await provider.calendarExists({ accessToken: tokens.accessToken, calendarId: existing.selectedCalendarId }));
+ const primaryCalendar = reusable
+ ? { id: existing.selectedCalendarId, name: existing.selectedCalendarName }
+ : await provider.createCalendar({
+ accessToken: tokens.accessToken,
+ name: `${staffProfile.business.name} bookings`,
+ timeZone: staffProfile.business.timezone,
+ });
const connection = await prisma.staffCalendarConnection.upsert({
where: { staffId: staffProfile.id },
@@ -74,6 +89,7 @@ export async function GET(request: Request) {
tokenExpiresAt: tokens.expiresAt,
selectedCalendarId: primaryCalendar.id,
selectedCalendarName: primaryCalendar.name,
+ appCreatedCalendar: true,
},
update: {
externalAccountId: tokens.externalAccountId,
@@ -83,6 +99,7 @@ export async function GET(request: Request) {
tokenExpiresAt: tokens.expiresAt,
selectedCalendarId: primaryCalendar.id,
selectedCalendarName: primaryCalendar.name,
+ appCreatedCalendar: true,
revokedAt: null,
lastSyncError: null,
},
diff --git a/src/components/ui/appointment-card.tsx b/src/components/ui/appointment-card.tsx
index 6fa1837..ca772d3 100644
--- a/src/components/ui/appointment-card.tsx
+++ b/src/components/ui/appointment-card.tsx
@@ -1,5 +1,5 @@
import Image from "next/image";
-import { Gift, NotebookText } from "lucide-react";
+import { Gift, NotebookText, UserX } from "lucide-react";
import type { ReactNode } from "react";
import { Card } from "@/components/ui/card";
import { StatusPill } from "@/components/ui/status-pill";
@@ -20,6 +20,8 @@ export interface AppointmentCardProps {
hasNotes?: boolean;
/** Small loyalty-reward-available indicator — same. */
hasAvailableReward?: boolean;
+ /** Customer has reached the no-show flag threshold — staff cards only. */
+ noShowFlagged?: boolean;
/** Reschedule/Cancel/Book again buttons — left to the caller so this
* component stays agnostic of which actions apply to which status. */
actions?: ReactNode;
@@ -35,6 +37,7 @@ export function AppointmentCard({
formattedTime,
hasNotes,
hasAvailableReward,
+ noShowFlagged,
actions,
}: AppointmentCardProps) {
return (
@@ -72,6 +75,9 @@ export function AppointmentCard({
aria-label="Reward available"
/>
)}
+ {noShowFlagged && (
+
+ )}
{personLabel} {person.name}
diff --git a/src/features/booking/actions/customer-appointments.ts b/src/features/booking/actions/customer-appointments.ts
index c00da8a..d490ad1 100644
--- a/src/features/booking/actions/customer-appointments.ts
+++ b/src/features/booking/actions/customer-appointments.ts
@@ -86,6 +86,7 @@ export async function rescheduleCustomerAppointmentAction(input: unknown): Promi
appointmentId: parsed.data.appointmentId,
staffId: parsed.data.staffId,
startAt: new Date(parsed.data.startAt),
+ actorUserId: session?.user?.id,
});
if (!result.success) {
diff --git a/src/features/booking/actions/draft.ts b/src/features/booking/actions/draft.ts
index 10eb1cd..1da27f0 100644
--- a/src/features/booking/actions/draft.ts
+++ b/src/features/booking/actions/draft.ts
@@ -5,10 +5,11 @@ import { auth } from "@/auth";
import { clearDraft, readDraft, writeDraft } from "@/lib/booking-draft/cookie";
import { guestDetailsSchema } from "@/lib/booking-draft/schema";
import { prisma } from "@/lib/db/prisma";
+import { getNoShowStanding } from "@/services/customer/no-show.service";
import { getAppointmentById } from "@/services/scheduling/appointment.service";
import { validateBookingSlot } from "@/services/scheduling/availability.service";
import { assertCanAccessAppointment, PermissionDeniedError } from "../authorization";
-import { getFirstFriendlyMessage } from "../error-messages";
+import { getFirstFriendlyMessage, getFriendlyMessage } from "../error-messages";
import {
preselectServiceSchema,
saveBarberTimeStepSchema,
@@ -170,6 +171,14 @@ export async function saveDetailsStepAction(input: unknown): Promise = {
STAFF_CANNOT_PERFORM_SERVICE: "This barber doesn't offer one of the selected services.",
BUSINESS_CLOSED: "The business is closed at this time — please choose another slot.",
BUSINESS_HOLIDAY: "The business is closed on that date — please choose another day.",
+ CUSTOMER_BOOKING_BLOCKED:
+ "We can't take online bookings for you right now — please get in touch with us to arrange an appointment.",
STAFF_NOT_WORKING: "This barber isn't working at that time — please choose another slot or barber.",
STAFF_ON_BREAK: "This barber is on a break at that time — please choose another slot.",
STAFF_ON_LEAVE: "This barber is away on that date — please choose another barber or date.",
diff --git a/src/features/calendar/actions/calendar.ts b/src/features/calendar/actions/calendar.ts
index c4d76e5..d557e75 100644
--- a/src/features/calendar/actions/calendar.ts
+++ b/src/features/calendar/actions/calendar.ts
@@ -72,6 +72,10 @@ export async function listAvailableCalendarsAction(): Promise
)}
- Sync your confirmed appointments to your own Google Calendar. This connects a Google account separately
- from sign-in — you can use a different one if you like.
+ Sync your confirmed appointments to Google Calendar. We create a calendar for them in your Google account
+ and never see or change your other calendars or events. This connects a Google account separately from
+ sign-in — you can use a different one if you like.
+ {state.appCreatedCalendar ? "Appointments are added to the calendar " : "Syncing to "}“
+ {state.selectedCalendarName}”
+ {state.appCreatedCalendar && " in your Google account — show or hide it from Google Calendar's sidebar."}
+
{state.lastSyncAt &&
Last synced {new Date(state.lastSyncAt).toLocaleString()}
}
{state.lastSyncError &&
Last sync error: {state.lastSyncError}
}
@@ -135,9 +145,11 @@ export function CalendarConnectionPanel({ initial, configured }: { initial: Conn
-
+ {!state.appCreatedCalendar && (
+
+ )}
diff --git a/src/features/owner/actions/appointments.ts b/src/features/owner/actions/appointments.ts
index c51b79e..27b0651 100644
--- a/src/features/owner/actions/appointments.ts
+++ b/src/features/owner/actions/appointments.ts
@@ -170,6 +170,7 @@ export async function ownerRescheduleAppointmentAction(params: {
appointmentId: params.appointmentId,
staffId: params.staffId,
startAt: new Date(params.startAt),
+ actorUserId: owner.userId,
});
if (!result.success) {
return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't update this appointment." };
@@ -183,9 +184,13 @@ export async function ownerRescheduleAppointmentAction(params: {
entityId: params.appointmentId,
metadata: { fromStaffId: previousStaffId, toStaffId: params.staffId },
});
- await notifyBookingBarberChanged(prisma, result.appointment, {
- customerSelectedStaff: appointment.staffSelectedByCustomer,
- });
+ // When the time moved too, the "rescheduled" notice already names the new barber — one
+ // message, not two. The dedicated notice is only for a pure reassignment.
+ if (appointment.startAt.getTime() === new Date(params.startAt).getTime()) {
+ await notifyBookingBarberChanged(prisma, result.appointment, {
+ customerSelectedStaff: appointment.staffSelectedByCustomer,
+ });
+ }
}
await syncAppointmentSafely(prisma, params.appointmentId);
diff --git a/src/features/owner/actions/business.ts b/src/features/owner/actions/business.ts
index a565e0b..9fceef5 100644
--- a/src/features/owner/actions/business.ts
+++ b/src/features/owner/actions/business.ts
@@ -13,6 +13,7 @@ import { parseSplashScreens } from "@/lib/business/branding";
import type { SplashScreenEntry } from "@/lib/business/branding";
import { MEDIA_MAX_UPLOAD_BYTES, storeMediaFile } from "@/lib/media/media-storage";
import { createMediaAsset } from "@/services/media/media.service";
+import { MAX_WHATSAPP_LEAD_HOURS, validateWhatsAppLeadHours } from "@/services/scheduling/whatsapp-reminders.service";
import {
setOpeningHours,
updateAppIconSettings,
@@ -104,6 +105,10 @@ export async function updateBusinessSettingsAction(settings: BusinessSettingsInp
const { staff, error } = await requireBusinessManager();
if (!staff) return { success: false, error: error ?? "Not authorized." };
+ if (settings.whatsappReminderLeadHours !== undefined && !validateWhatsAppLeadHours(settings.whatsappReminderLeadHours)) {
+ return { success: false, error: `WhatsApp reminders can be due between 1 and ${MAX_WHATSAPP_LEAD_HOURS} hours ahead.` };
+ }
+
await updateBusinessSettings(prisma, { businessId: staff.businessId, settings });
await writeAuditLog(prisma, {
diff --git a/src/features/owner/components/booking-rules-form.tsx b/src/features/owner/components/booking-rules-form.tsx
index c204a21..da318a7 100644
--- a/src/features/owner/components/booking-rules-form.tsx
+++ b/src/features/owner/components/booking-rules-form.tsx
@@ -126,6 +126,33 @@ export function BookingRulesForm({ initial }: { initial: BookingRuleInput }) {
+
+
No-shows
+
+
+ Count the appointments a customer missed since they last attended (a completed appointment, or
+ when you reset them). Leave a box empty to turn that part off. Staff can always book a blocked
+ customer in directly.
+