From 68a62e02a4c8786eaf1b5111f0386e8631204487 Mon Sep 17 00:00:00 2001
From: Stefan Lazic
Date: Sat, 17 Oct 2026 23:10:18 +0100
Subject: [PATCH] Remaining features
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Built
No-show flag and block:
Two new numbers under Booking Rules (blank means off).
A symbol appears on the clients list, client page, calendar entries and appointment pages.
Online booking is refused at the block number, and customers are told at the details step.
Staff can still book a blocked customer in, and an owner can reset someone with a two-tap button.
The count resets when they attend, and matches the same email or phone, so a guest can’t dodge it with a new profile.
Email rules: staff and owners get email only for customer cancellations, schedule changes and invitations. Routine events (new booking, reschedule) are push-only. Nobody is told about their own action. A barber reassignment sends one customer email instead of two. Reward expiry sends one warning.
Email redesign: your brand colour, a details card, Add to calendar and View on map links, a preferences link, hidden preview text, and dark mode. Replies now go to the business’s own email.
WhatsApp “due now” queue: a list in time order with a highlighted next one, an owner setting for how many hours ahead (12–72), and a once-a-day push telling each barber how many reminders are waiting.
Monitoring: /api/health now reports whether notifications are actually going out. The worker resets stuck deliveries and alerts the owner on the first setup failure (such as a bad SMTP login). The app emails your operator address about server errors, and the manager pushes that address to each business.
Google scope: now only calendar.app.created. Events go to a calendar the app creates in the staff member’s Google account, called “ bookings”.
---
.env.business.example | 8 +-
README.md | 84 ++++---
docs/phase-6-summary.md | 4 +-
manager/Dockerfile | 2 +-
manager/README.md | 15 +-
.../app/api/backups/[slug]/[file]/route.ts | 2 +-
manager/src/app/api/jobs/[id]/route.ts | 3 +-
manager/src/app/dashboard/[slug]/actions.ts | 5 +-
.../app/dashboard/[slug]/backups/actions.ts | 26 +-
.../[slug]/backups/backups-panel.tsx | 41 +--
.../src/app/dashboard/[slug]/backups/page.tsx | 7 +-
.../app/dashboard/[slug]/business-actions.tsx | 4 -
.../app/dashboard/[slug]/delete/actions.ts | 12 +-
.../[slug]/delete/delete-business-form.tsx | 4 +-
.../src/app/dashboard/passphrase-notice.tsx | 33 ---
manager/src/app/dashboard/restore/actions.ts | 34 +--
manager/src/app/dashboard/restore/page.tsx | 7 +-
.../app/dashboard/restore/restore-wizard.tsx | 30 +--
manager/src/app/dashboard/settings/actions.ts | 14 +-
.../settings/operator-settings-form.tsx | 12 +
manager/src/lib/__tests__/backup.test.ts | 21 +-
.../src/lib/__tests__/docker-commands.test.ts | 29 +--
manager/src/lib/__tests__/operations.test.ts | 3 -
.../lib/__tests__/operator-rollout.test.ts | 79 ++++++
manager/src/lib/__tests__/restore.test.ts | 36 ++-
manager/src/lib/backup.ts | 13 +-
manager/src/lib/compose-template.ts | 3 +
manager/src/lib/global-settings.ts | 60 +----
manager/src/lib/job-state.ts | 4 +-
manager/src/lib/operations.ts | 21 +-
manager/src/lib/operator-rollout.ts | 51 ++++
manager/src/lib/process.ts | 2 +-
manager/src/lib/provisioning.ts | 2 +
manager/src/lib/restore.ts | 27 +-
manager/src/lib/sevenzip.ts | 43 ++--
.../migration.sql | 22 ++
prisma/schema.prisma | 30 +++
.../(owner)/owner/appointments/[id]/page.tsx | 7 +
src/app/(owner)/owner/calendar/page.tsx | 10 +-
.../owner/settings/booking-rules/page.tsx | 2 +
.../(owner)/owner/settings/business/page.tsx | 1 +
.../(owner)/owner/settings/loyalty/page.tsx | 4 -
.../(staff)/staff/appointments/[id]/page.tsx | 7 +
src/app/(staff)/staff/clients/[id]/page.tsx | 25 ++
src/app/(staff)/staff/clients/page.tsx | 16 +-
src/app/(staff)/staff/page.tsx | 10 +-
src/app/(staff)/staff/reminders/page.tsx | 92 +++++--
.../(staff)/staff/settings/calendar/page.tsx | 1 +
.../staff/settings/notifications/page.tsx | 1 +
.../api/cron/process-notifications/route.ts | 15 +-
src/app/api/health/route.ts | 33 ++-
.../staff/calendar/google/callback/route.ts | 29 ++-
src/components/ui/appointment-card.tsx | 8 +-
.../booking/actions/customer-appointments.ts | 1 +
src/features/booking/actions/draft.ts | 16 +-
src/features/booking/error-messages.ts | 2 +
src/features/calendar/actions/calendar.ts | 4 +
.../components/calendar-connection-panel.tsx | 24 +-
src/features/owner/actions/appointments.ts | 11 +-
src/features/owner/actions/business.ts | 5 +
.../owner/components/booking-rules-form.tsx | 27 ++
.../components/business-details-form.tsx | 18 ++
.../owner/components/loyalty-config-form.tsx | 12 -
.../staff/actions/appointment-actions.ts | 5 +-
src/features/staff/actions/no-show.ts | 45 ++++
.../staff/components/day-calendar.tsx | 5 +
.../staff/components/no-show-badge.tsx | 38 +++
.../staff/components/reminders-link.tsx | 24 +-
.../components/reset-no-shows-button.tsx | 60 +++++
src/instrumentation.ts | 21 ++
src/lib/auth/otp.service.ts | 5 +-
.../monitoring/__tests__/monitoring.test.ts | 31 +++
.../monitoring/__tests__/report-error.test.ts | 38 +++
src/lib/monitoring/alert-limiter.ts | 39 +++
src/lib/monitoring/report-error.ts | 90 +++++++
src/services/business/business.service.ts | 2 +
.../google-calendar.provider.test.ts | 153 +++++++++++-
src/services/calendar/calendar-provider.ts | 6 +
.../calendar/google-calendar.provider.ts | 39 ++-
.../calendar/mock-calendar.provider.ts | 10 +
.../__tests__/no-show.integration.test.ts | 234 ++++++++++++++++++
.../__tests__/no-show.service.test.ts | 91 +++++++
src/services/customer/no-show.service.ts | 199 +++++++++++++++
.../loyalty/__tests__/loyalty-config.test.ts | 2 -
.../loyalty-owner.integration.test.ts | 19 +-
src/services/loyalty/loyalty.service.ts | 12 +-
.../notification.service.integration.test.ts | 23 +-
.../queue-health.integration.test.ts | 91 +++++++
.../__tests__/queue-health.test.ts | 94 +++++++
.../notifications/__tests__/types.test.ts | 26 +-
src/services/notifications/failure-reasons.ts | 11 +
.../providers/brevo-email-provider.ts | 2 +-
.../notifications/providers/email-provider.ts | 6 +
src/services/notifications/queue-health.ts | 140 +++++++++++
src/services/notifications/reminder-worker.ts | 23 +-
src/services/notifications/render-email.ts | 85 ++++++-
.../templates/__tests__/templates.test.ts | 138 ++++++++---
.../notifications/templates/booking-emails.ts | 68 +++--
.../notifications/templates/generic-email.ts | 16 +-
.../notifications/templates/layout.ts | 145 ++++++++++-
.../notifications/templates/loyalty-emails.ts | 22 +-
.../notifications/templates/otp-email.ts | 2 +-
.../notifications/templates/shared.ts | 11 +-
.../notifications/templates/staff-emails.ts | 91 -------
src/services/notifications/types.ts | 32 +--
.../reporting/owner-reporting.service.ts | 2 -
.../booking-notifications.integration.test.ts | 139 ++++++++++-
.../__tests__/booking-rule.service.test.ts | 22 ++
.../whatsapp-reminders.integration.test.ts | 110 +++++++-
.../scheduling/appointment.service.ts | 9 +
.../scheduling/booking-notifications.ts | 57 +++--
.../scheduling/booking-rule.service.ts | 15 ++
src/services/scheduling/booking.service.ts | 27 +-
.../scheduling/business-rules.service.ts | 3 +
src/services/scheduling/types.ts | 1 +
.../scheduling/whatsapp-reminders.service.ts | 102 ++++++++
116 files changed, 3089 insertions(+), 766 deletions(-)
delete mode 100644 manager/src/app/dashboard/passphrase-notice.tsx
create mode 100644 manager/src/lib/__tests__/operator-rollout.test.ts
create mode 100644 manager/src/lib/operator-rollout.ts
create mode 100644 prisma/migrations/20261018090000_no_show_whatsapp_lead_cron_status_calendar_mode/migration.sql
create mode 100644 src/features/staff/actions/no-show.ts
create mode 100644 src/features/staff/components/no-show-badge.tsx
create mode 100644 src/features/staff/components/reset-no-shows-button.tsx
create mode 100644 src/instrumentation.ts
create mode 100644 src/lib/monitoring/__tests__/monitoring.test.ts
create mode 100644 src/lib/monitoring/__tests__/report-error.test.ts
create mode 100644 src/lib/monitoring/alert-limiter.ts
create mode 100644 src/lib/monitoring/report-error.ts
create mode 100644 src/services/customer/__tests__/no-show.integration.test.ts
create mode 100644 src/services/customer/__tests__/no-show.service.test.ts
create mode 100644 src/services/customer/no-show.service.ts
create mode 100644 src/services/notifications/__tests__/queue-health.integration.test.ts
create mode 100644 src/services/notifications/__tests__/queue-health.test.ts
create mode 100644 src/services/notifications/failure-reasons.ts
create mode 100644 src/services/notifications/queue-health.ts
delete mode 100644 src/services/notifications/templates/staff-emails.ts
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.
+