Built No-show flag and block: Two new numbers under Booking Rules (blank means off). A symbol appears on the clients list, client page, calendar entries and appointment pages. Online booking is refused at the block number, and customers are told at the details step. Staff can still book a blocked customer in, and an owner can reset someone with a two-tap button. The count resets when they attend, and matches the same email or phone, so a guest can’t dodge it with a new profile. Email rules: staff and owners get email only for customer cancellations, schedule changes and invitations. Routine events (new booking, reschedule) are push-only. Nobody is told about their own action. A barber reassignment sends one customer email instead of two. Reward expiry sends one warning. Email redesign: your brand colour, a details card, Add to calendar and View on map links, a preferences link, hidden preview text, and dark mode. Replies now go to the business’s own email. WhatsApp “due now” queue: a list in time order with a highlighted next one, an owner setting for how many hours ahead (12–72), and a once-a-day push telling each barber how many reminders are waiting. Monitoring: /api/health now reports whether notifications are actually going out. The worker resets stuck deliveries and alerts the owner on the first setup failure (such as a bad SMTP login). The app emails your operator address about server errors, and the manager pushes that address to each business. Google scope: now only calendar.app.created. Events go to a calendar the app creates in the staff member’s Google account, called “<Business> bookings”.
This commit is contained in:
116 files changed
+3056
-733
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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 `<slug>
|
||||
process-notifications ok` for every business.
|
||||
A healthy run logs `<slug> process-notifications ok` for every business. Use
|
||||
only one of the two mechanisms at a time as your normal setup.
|
||||
|
||||
## Updating after a code change
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+8
-7
@@ -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/<slug>/`. Written as `.partial`, verified
|
||||
with `7zz t`, then renamed; the newest N (default 2, per business) are
|
||||
kept. Safety backups (before an update or a replacing restore) and final
|
||||
backups (before a delete) are kept separately and never evict them. One
|
||||
passphrase protects everything: generated at the first enable / first
|
||||
*Back up now*, stored encrypted (`MANAGER_SETTINGS_ENCRYPTION_KEY`),
|
||||
**shown once** — save it, a fresh server needs it to restore. Restore
|
||||
works onto an existing business (after a safety backup) or a server that
|
||||
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 /
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<void> {
|
||||
export async function updateStackAction(slug: string, backupFirst: boolean): Promise<JobStartState> {
|
||||
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." };
|
||||
|
||||
@@ -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<void> {
|
||||
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<JobStartState> {
|
||||
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 };
|
||||
}
|
||||
}
|
||||
@@ -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<string | null>(null);
|
||||
const [jobId, setJobId] = useState<string | null>(activeJobId);
|
||||
const [jobRunning, setJobRunning] = useState(activeJobId !== null);
|
||||
const [shownPassphrase, setShownPassphrase] = useState<string | null>(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 (
|
||||
<div className="space-y-8">
|
||||
{passphraseToShow && <PassphraseNotice passphrase={passphraseToShow} />}
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-lg font-semibold">Schedule</h2>
|
||||
<form action={saveAction} className="space-y-4 rounded-md border border-line bg-surface p-5">
|
||||
@@ -251,17 +225,12 @@ export function BackupsPanel({
|
||||
</section>
|
||||
|
||||
<section className="space-y-2 text-sm text-muted">
|
||||
<h2 className="text-lg font-semibold text-ink">Passphrase</h2>
|
||||
<h2 className="text-lg font-semibold text-ink">Handle with care</h2>
|
||||
<p>
|
||||
{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 <span className="font-mono">.env</span>: database
|
||||
password, sign-in secret, email password, push keys) as well as its data. They are stored owner-only on this server; if you download one,
|
||||
keep it somewhere as private as the server itself.
|
||||
</p>
|
||||
{hasPassphrase && (
|
||||
<button type="button" onClick={rotate} disabled={isPending} className="font-medium text-ink underline disabled:opacity-60">
|
||||
Generate a new passphrase…
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,7 +3,6 @@ import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { readBackupSettings } from "@/lib/backup-settings";
|
||||
import { listBackups } from "@/lib/backup-store";
|
||||
import { backupPassphraseCreatedAt, hasBackupPassphrase } from "@/lib/global-settings";
|
||||
import { getActiveLock } from "@/lib/locks";
|
||||
import { listBusinessStacks } from "@/lib/stacks";
|
||||
import { BackupsPanel } from "./backups-panel";
|
||||
@@ -17,11 +16,9 @@ export default async function BackupsPage({ params }: { params: Promise<{ slug:
|
||||
const stack = stacks.find((s) => s.slug === slug);
|
||||
if (!stack) notFound();
|
||||
|
||||
const [settings, backups, hasPassphrase, passphraseCreatedAt, lock] = await Promise.all([
|
||||
const [settings, backups, lock] = await Promise.all([
|
||||
readBackupSettings(slug),
|
||||
listBackups(slug),
|
||||
hasBackupPassphrase(),
|
||||
backupPassphraseCreatedAt(),
|
||||
getActiveLock(slug),
|
||||
]);
|
||||
|
||||
@@ -56,8 +53,6 @@ export default async function BackupsPage({ params }: { params: Promise<{ slug:
|
||||
lastMessage: settings.lastMessage,
|
||||
}}
|
||||
backups={backups}
|
||||
hasPassphrase={hasPassphrase}
|
||||
passphraseCreatedAt={passphraseCreatedAt}
|
||||
activeJobId={lock?.jobId ?? null}
|
||||
timeZone={Intl.DateTimeFormat().resolvedOptions().timeZone}
|
||||
/>
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { JobProgress } from "../job-progress";
|
||||
import { PassphraseNotice } from "../passphrase-notice";
|
||||
import {
|
||||
fetchLogsAction,
|
||||
restartStackAction,
|
||||
@@ -19,7 +18,6 @@ export function BusinessActions({ slug, activeJobId }: { slug: string; activeJob
|
||||
const [backupFirst, setBackupFirst] = useState(true);
|
||||
const [jobId, setJobId] = useState<string | null>(activeJobId);
|
||||
const [jobRunning, setJobRunning] = useState(activeJobId !== null);
|
||||
const [newPassphrase, setNewPassphrase] = useState<string | null>(null);
|
||||
|
||||
// Quick actions are refused server-side while a job holds the business; the buttons just mirror that.
|
||||
const busy = isPending || jobRunning;
|
||||
@@ -44,7 +42,6 @@ export function BusinessActions({ slug, activeJobId }: { slug: string; activeJob
|
||||
setError(result.error);
|
||||
return;
|
||||
}
|
||||
setNewPassphrase(result.newPassphrase);
|
||||
setJobId(result.jobId);
|
||||
setJobRunning(true);
|
||||
setUpdateOpen(false);
|
||||
@@ -139,7 +136,6 @@ export function BusinessActions({ slug, activeJobId }: { slug: string; activeJob
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{newPassphrase && <PassphraseNotice passphrase={newPassphrase} />}
|
||||
{jobId && <JobProgress key={jobId} jobId={jobId} onFinished={() => setJobRunning(false)} />}
|
||||
{logs !== null && (
|
||||
<pre className="max-h-96 overflow-auto rounded-md bg-ink p-4 text-xs text-white">{logs}</pre>
|
||||
|
||||
@@ -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 <JobProgress>. */
|
||||
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<DeleteBusinessState> {
|
||||
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 };
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="space-y-4">
|
||||
{state.newPassphrase && <PassphraseNotice passphrase={state.newPassphrase} />}
|
||||
<JobProgress jobId={state.jobId} refreshOnFinish={false}>
|
||||
<Link href="/dashboard" className="inline-block rounded-md bg-ink px-4 py-2 text-sm font-semibold text-white">
|
||||
Back to businesses
|
||||
|
||||
@@ -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 (
|
||||
<div role="alert" className="space-y-2 rounded-md border border-amber-300 bg-amber-50 p-4 text-sm">
|
||||
<p className="font-semibold text-amber-900">Save this backup passphrase now — it won't be shown again.</p>
|
||||
<p className="text-amber-900">
|
||||
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.
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<code className="select-all rounded bg-white px-3 py-1.5 font-mono text-base font-semibold">{passphrase}</code>
|
||||
<button type="button" onClick={copy} className="rounded-md border border-line bg-white px-3 py-1.5 text-sm font-medium">
|
||||
{copied ? "Copied" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string> {
|
||||
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<InspectRestoreState> {
|
||||
/** Reads only the manifest (fast) and reports what restoring would do — nothing is changed. */
|
||||
export async function inspectRestoreAction(sourceKey: string): Promise<InspectRestoreState> {
|
||||
try {
|
||||
const source = parseSourceKey(sourceKey);
|
||||
const manifest = await readArchiveManifest(resolveArchivePath(source), 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<StartRestoreState> {
|
||||
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.");
|
||||
|
||||
@@ -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
|
||||
<h1 className="mt-2 text-xl font-bold">Restore a business</h1>
|
||||
<p className="text-sm text-muted">
|
||||
Restores a backup onto the business it came from, or onto a fresh server where it never existed. Pick a stored
|
||||
backup or upload a <span className="font-mono">.7z</span>, enter the passphrase, check what it contains, then
|
||||
confirm.
|
||||
backup or upload a <span className="font-mono">.7z</span>, check what it contains, then confirm.
|
||||
</p>
|
||||
</div>
|
||||
<RestoreWizard
|
||||
@@ -37,7 +35,6 @@ export default async function RestorePage({ searchParams }: { searchParams: Prom
|
||||
sizeBytes: b.sizeBytes,
|
||||
}))}
|
||||
preselected={preselected}
|
||||
hasStoredPassphrase={hasPassphrase}
|
||||
maxUploadBytes={maxUploadBytes}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import Link from "next/link";
|
||||
import { useRef, useState, useTransition } from "react";
|
||||
import { JobProgress } from "../job-progress";
|
||||
import { PassphraseNotice } from "../passphrase-notice";
|
||||
import { inspectRestoreAction, startRestoreAction, type RestorePreview } from "./actions";
|
||||
|
||||
interface BackupOption {
|
||||
@@ -24,12 +23,10 @@ function formatBytes(bytes: number): string {
|
||||
export function RestoreWizard({
|
||||
backups,
|
||||
preselected,
|
||||
hasStoredPassphrase,
|
||||
maxUploadBytes,
|
||||
}: {
|
||||
backups: BackupOption[];
|
||||
preselected: string | null;
|
||||
hasStoredPassphrase: boolean;
|
||||
maxUploadBytes: number;
|
||||
}) {
|
||||
const [mode, setMode] = useState<"stored" | "upload">(backups.length === 0 ? "upload" : "stored");
|
||||
@@ -37,14 +34,12 @@ export function RestoreWizard({
|
||||
const [uploadKey, setUploadKey] = useState<string | null>(null);
|
||||
const [uploadProgress, setUploadProgress] = useState<number | null>(null);
|
||||
const [uploadName, setUploadName] = useState<string | null>(null);
|
||||
const [passphrase, setPassphrase] = useState("");
|
||||
const [preview, setPreview] = useState<RestorePreview | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [replace, setReplace] = useState(false);
|
||||
const [confirmSlug, setConfirmSlug] = useState("");
|
||||
const [jobId, setJobId] = useState<string | null>(null);
|
||||
const [restoredSlug, setRestoredSlug] = useState<string | null>(null);
|
||||
const [newPassphrase, setNewPassphrase] = useState<string | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const xhrRef = useRef<XMLHttpRequest | null>(null);
|
||||
|
||||
@@ -91,7 +86,7 @@ export function RestoreWizard({
|
||||
if (!sourceKey) return;
|
||||
resetPreview();
|
||||
startTransition(async () => {
|
||||
const result = await inspectRestoreAction(sourceKey, passphrase);
|
||||
const result = await inspectRestoreAction(sourceKey);
|
||||
if (result.error) setError(result.error);
|
||||
setPreview(result.preview);
|
||||
});
|
||||
@@ -101,12 +96,11 @@ export function RestoreWizard({
|
||||
if (!sourceKey || !preview) return;
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
const result = await startRestoreAction(sourceKey, passphrase, replace, confirmSlug);
|
||||
const result = await startRestoreAction(sourceKey, replace, confirmSlug);
|
||||
if (result.error) {
|
||||
setError(result.error);
|
||||
return;
|
||||
}
|
||||
setNewPassphrase(result.newPassphrase);
|
||||
setRestoredSlug(preview.slug);
|
||||
setJobId(result.jobId);
|
||||
});
|
||||
@@ -118,7 +112,6 @@ export function RestoreWizard({
|
||||
if (jobId) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{newPassphrase && <PassphraseNotice passphrase={newPassphrase} />}
|
||||
<JobProgress jobId={jobId}>
|
||||
{restoredSlug && (
|
||||
<Link href={`/dashboard/${restoredSlug}`} className="inline-block rounded-md bg-ink px-4 py-2 text-sm font-semibold text-white">
|
||||
@@ -199,28 +192,13 @@ export function RestoreWizard({
|
||||
</fieldset>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="passphrase" className="block text-sm font-semibold">
|
||||
2. Passphrase
|
||||
</label>
|
||||
<input
|
||||
id="passphrase"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={passphrase}
|
||||
onChange={(e) => {
|
||||
setPassphrase(e.target.value);
|
||||
resetPreview();
|
||||
}}
|
||||
placeholder={hasStoredPassphrase ? "Leave blank to use this server's stored passphrase" : "Required on a fresh server"}
|
||||
className="w-full rounded-md border border-line px-3 py-2 font-mono text-sm"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={inspect}
|
||||
disabled={isPending || !sourceKey || (!passphrase && !hasStoredPassphrase)}
|
||||
disabled={isPending || !sourceKey}
|
||||
className="rounded-md bg-ink px-4 py-2 text-sm font-semibold text-white disabled:opacity-60"
|
||||
>
|
||||
{isPending && !preview ? "Checking…" : "Check this backup"}
|
||||
{isPending && !preview ? "Checking…" : "2. Check this backup"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { applyEmailSettingsToAllBusinesses, type EmailRolloutResult } from "@/lib/email-rollout";
|
||||
import { applyGoogleCalendarSettingsToAllBusinesses, type GoogleCalendarRolloutResult } from "@/lib/google-calendar-rollout";
|
||||
import { applyOperatorAlertEmailToAllBusinesses, type OperatorRolloutResult } from "@/lib/operator-rollout";
|
||||
import { applyPushSettingsToAllBusinesses, type PushRolloutResult } from "@/lib/push-rollout";
|
||||
import {
|
||||
generateVapidKeys,
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
resolvePushSettings,
|
||||
saveEmailSettings,
|
||||
saveGoogleCalendarSettings,
|
||||
readOperatorSettings,
|
||||
saveOperatorSettings,
|
||||
savePushSubject,
|
||||
saveSuperAdminSettings,
|
||||
@@ -367,6 +369,8 @@ export async function reapplyGoogleCalendarSettingsAction(
|
||||
export interface SaveOperatorSettingsState {
|
||||
error: string | null;
|
||||
saved: boolean;
|
||||
/** Set when the alert address changed and was pushed to the businesses (each restarts to pick it up); null when nothing needed rolling out. */
|
||||
rollout?: OperatorRolloutResult[] | null;
|
||||
}
|
||||
|
||||
export async function saveOperatorSettingsAction(
|
||||
@@ -386,7 +390,9 @@ export async function saveOperatorSettingsAction(
|
||||
return { error: "Health check interval must be between 1 and 60 minutes.", saved: false };
|
||||
}
|
||||
|
||||
let previousAlertEmail = "";
|
||||
try {
|
||||
previousAlertEmail = (await readOperatorSettings()).alertEmail;
|
||||
await saveOperatorSettings({
|
||||
alertEmail,
|
||||
alertOnHealth: formData.get("alertOnHealth") === "on",
|
||||
@@ -399,7 +405,13 @@ export async function saveOperatorSettingsAction(
|
||||
} catch (error) {
|
||||
return { error: error instanceof Error ? error.message : "Couldn't save settings.", saved: false };
|
||||
}
|
||||
return { error: null, saved: true };
|
||||
|
||||
// Businesses read the alert address from their own .env, so a changed address has to be pushed out
|
||||
// (and each restarted once). An unchanged one isn't touched at all.
|
||||
let rollout: OperatorRolloutResult[] | null = null;
|
||||
if (alertEmail !== previousAlertEmail) rollout = await applyOperatorAlertEmailToAllBusinesses(alertEmail);
|
||||
|
||||
return { error: null, saved: true, rollout };
|
||||
}
|
||||
|
||||
export interface SendTestAlertState {
|
||||
|
||||
@@ -109,6 +109,18 @@ export function OperatorSettingsForm({ initial, status }: { initial: OperatorSet
|
||||
</p>
|
||||
)}
|
||||
{saveState.saved && !saveState.error && <p className="text-sm text-green-700">Saved.</p>}
|
||||
{saveState.rollout && saveState.rollout.length > 0 && (
|
||||
<div className="text-sm">
|
||||
<p className="text-muted">The alert address was sent to your businesses so they can email you about server errors:</p>
|
||||
<ul className="mt-1 space-y-0.5">
|
||||
{saveState.rollout.map((r) => (
|
||||
<li key={r.slug} className={r.ok ? "text-green-700" : "text-danger"}>
|
||||
{r.slug}: {r.ok ? (r.unchanged ? "already set" : "updated and restarted") : `failed — ${r.error}`}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-3 pt-2">
|
||||
<button
|
||||
|
||||
@@ -21,15 +21,12 @@ vi.mock("@/lib/sevenzip", () => ({
|
||||
sevenZipTest: (...a: unknown[]) => sevenZipTestMock(...a),
|
||||
}));
|
||||
|
||||
const readBackupPassphraseMock = vi.fn();
|
||||
vi.mock("@/lib/global-settings", () => ({ readBackupPassphrase: () => readBackupPassphraseMock() }));
|
||||
|
||||
import { performBackup } from "../backup";
|
||||
import { backupFileName, listBackups } from "../backup-store";
|
||||
import { saveBackupSettings } from "../backup-settings";
|
||||
import { parseManifest } from "../backup-manifest";
|
||||
|
||||
const PASSPHRASE = "test-passphrase-123";
|
||||
const ENV = [
|
||||
'BUSINESS_NAME="Acme Barbers"',
|
||||
'DOMAIN="acme.example.com"',
|
||||
@@ -56,7 +53,6 @@ describe("performBackup", () => {
|
||||
await writeFile(path.join(stacksDir, "infra", "sites", "acme.caddy"), "acme.example.com {\n}\n");
|
||||
|
||||
containerExistsMock.mockReset().mockResolvedValue(true);
|
||||
readBackupPassphraseMock.mockReset().mockResolvedValue(PASSPHRASE);
|
||||
runCaptureMock.mockReset().mockResolvedValue({ stdout: "20261017180000_add_business_holidays\n", stderr: "" });
|
||||
runToFileMock.mockReset().mockImplementation(async (spec: CommandSpec, file: string) => {
|
||||
const isDump = spec.args.includes("mysqldump");
|
||||
@@ -103,16 +99,16 @@ describe("performBackup", () => {
|
||||
slug: "acme", name: "Acme Barbers", domain: "acme.example.com", databaseName: "acme", databaseUser: "acme",
|
||||
kind: "regular", lastMigration: "20261017180000_add_business_holidays", hasCaddySite: true, createdAt: now.toISOString(),
|
||||
});
|
||||
expect(staged.manifest).not.toContain("abc123"); // the DB password lives only in config/.env inside the encrypted archive
|
||||
expect(staged.manifest).not.toContain("abc123"); // the DB password lives only in config/.env inside the archive
|
||||
|
||||
// 4. encrypted with the stored passphrase, written as .partial first
|
||||
const create = sevenZipCreateMock.mock.calls[0]![0] as { archivePath: string; passphrase: string; entries: string[] };
|
||||
expect(create.passphrase).toBe(PASSPHRASE);
|
||||
// 4. a plain archive (no password), written as .partial first
|
||||
const create = sevenZipCreateMock.mock.calls[0]![0] as { archivePath: string; entries: string[] };
|
||||
expect(create).not.toHaveProperty("passphrase");
|
||||
expect(create.archivePath.endsWith(`${backupFileName("acme", "regular", now)}.partial`)).toBe(true);
|
||||
expect(create.entries).toEqual(["manifest.json", "db.sql", "media.tar", "config"]);
|
||||
|
||||
// 5. verified (7zz t) the .partial, THEN renamed
|
||||
expect(sevenZipTestMock).toHaveBeenCalledWith(create.archivePath, PASSPHRASE);
|
||||
expect(sevenZipTestMock).toHaveBeenCalledWith(create.archivePath);
|
||||
const finalPath = create.archivePath.replace(/\.partial$/, "");
|
||||
expect((await stat(finalPath)).isFile()).toBe(true);
|
||||
expect((await stat(finalPath)).mode & 0o777).toBe(0o600);
|
||||
@@ -121,7 +117,6 @@ describe("performBackup", () => {
|
||||
|
||||
// no staging leftovers, and nothing secret in the log
|
||||
expect(await readdir(path.join(stacksDir, "manager", "tmp"))).toEqual([]);
|
||||
expect(lines.join("\n")).not.toContain(PASSPHRASE);
|
||||
expect(lines.join("\n")).not.toContain("abc123");
|
||||
});
|
||||
|
||||
@@ -187,12 +182,6 @@ describe("performBackup", () => {
|
||||
expect(names.filter((n) => n.includes("__safety"))).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("fails clearly when no passphrase exists yet", async () => {
|
||||
readBackupPassphraseMock.mockResolvedValue(null);
|
||||
await expect(performBackup("acme", "regular", logs().ctx)).rejects.toThrow(/No backup passphrase/);
|
||||
expect(runToFileMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("emits job steps in order when given a step wrapper", async () => {
|
||||
const order: string[] = [];
|
||||
await performBackup("acme", "regular", { log: () => undefined, step: async (id, fn) => (order.push(id), fn()) });
|
||||
|
||||
@@ -84,39 +84,40 @@ describe("command construction", () => {
|
||||
});
|
||||
|
||||
describe("7zz command construction", () => {
|
||||
const passphrase = "correct-horse-battery";
|
||||
|
||||
it("create: 7z, encrypted headers, passphrase, listed entries, run from the staging dir", () => {
|
||||
it("create: plain 7z (no password), listed entries, run from the staging dir", () => {
|
||||
const spec = sevenZipCreateCommand({
|
||||
archivePath: "/stacks/backups/acme/x.7z.partial",
|
||||
cwd: "/stacks/manager/tmp/s1",
|
||||
entries: ["manifest.json", "db.sql", "media.tar", "config"],
|
||||
passphrase,
|
||||
});
|
||||
expect(spec.command).toBe("7zz");
|
||||
expect(spec.cwd).toBe("/stacks/manager/tmp/s1");
|
||||
expect(spec.args).toEqual([
|
||||
"a", "-t7z", "-mx=3", "-mhe=on", `-p${passphrase}`, "-y", "-bd",
|
||||
"a", "-t7z", "-mx=3", "-y", "-bd",
|
||||
"/stacks/backups/acme/x.7z.partial", "manifest.json", "db.sql", "media.tar", "config",
|
||||
]);
|
||||
});
|
||||
|
||||
it("test and stdout-extract commands", () => {
|
||||
expect(sevenZipTestCommand("/a.7z", passphrase).args).toEqual(["t", "-t7z", `-p${passphrase}`, "-y", "-bd", "/a.7z"]);
|
||||
expect(sevenZipExtractToStdoutCommand("/a.7z", passphrase, "db.sql").args).toEqual([
|
||||
"x", "-so", "-t7z", `-p${passphrase}`, "-y", "-bd", "-bso0", "-bsp0", "/a.7z", "db.sql",
|
||||
]);
|
||||
it("never asks 7zz for encryption or a password", () => {
|
||||
const all = [
|
||||
...sevenZipCreateCommand({ archivePath: "/a.7z", cwd: "/tmp", entries: ["x"] }).args,
|
||||
...sevenZipTestCommand("/a.7z").args,
|
||||
...sevenZipExtractToStdoutCommand("/a.7z", "db.sql").args,
|
||||
];
|
||||
expect(all.some((arg) => arg.startsWith("-p") || arg.startsWith("-mhe"))).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses a short passphrase (an empty -p would hang on a prompt)", () => {
|
||||
expect(() => sevenZipTestCommand("/a.7z", "")).toThrow(/at least 8/);
|
||||
expect(() => sevenZipTestCommand("/a.7z", "short")).toThrow(/at least 8/);
|
||||
it("test and stdout-extract commands", () => {
|
||||
expect(sevenZipTestCommand("/a.7z").args).toEqual(["t", "-t7z", "-y", "-bd", "/a.7z"]);
|
||||
expect(sevenZipExtractToStdoutCommand("/a.7z", "db.sql").args).toEqual([
|
||||
"x", "-so", "-t7z", "-y", "-bd", "-bso0", "-bsp0", "/a.7z", "db.sql",
|
||||
]);
|
||||
});
|
||||
|
||||
it("SEVENZIP_BIN overrides the binary", () => {
|
||||
process.env.SEVENZIP_BIN = "/opt/7zz";
|
||||
try {
|
||||
expect(sevenZipTestCommand("/a.7z", passphrase).command).toBe("/opt/7zz");
|
||||
expect(sevenZipTestCommand("/a.7z").command).toBe("/opt/7zz");
|
||||
} finally {
|
||||
delete process.env.SEVENZIP_BIN;
|
||||
}
|
||||
|
||||
@@ -11,9 +11,7 @@ const waitForHealthMock = vi.fn();
|
||||
vi.mock("@/lib/health", () => ({ waitForHealth: (...a: unknown[]) => waitForHealthMock(...a) }));
|
||||
const performBackupMock = vi.fn();
|
||||
vi.mock("@/lib/backup", () => ({ performBackup: (...a: unknown[]) => performBackupMock(...a), BACKUP_STEPS: [] }));
|
||||
const readBackupPassphraseMock = vi.fn();
|
||||
vi.mock("@/lib/global-settings", () => ({
|
||||
readBackupPassphrase: () => readBackupPassphraseMock(),
|
||||
readOperatorSettings: vi.fn(async () => ({ alertEmail: "", alertOnBackupFailure: true })),
|
||||
}));
|
||||
vi.mock("@/lib/provisioning", () => ({ deleteBusiness: vi.fn(async () => (calls.push("deleteBusiness"), { slug: "acme", warnings: [] })) }));
|
||||
@@ -35,7 +33,6 @@ describe("Update and Delete jobs", () => {
|
||||
calls.length = 0;
|
||||
waitForHealthMock.mockReset().mockImplementation(async () => (calls.push("waitForHealth"), undefined));
|
||||
performBackupMock.mockReset().mockImplementation(async (_slug: string, kind: string) => (calls.push(`backup:${kind}`), { fileName: `f-${kind}.7z` }));
|
||||
readBackupPassphraseMock.mockReset().mockResolvedValue("a-stored-passphrase");
|
||||
});
|
||||
afterEach(async () => {
|
||||
if (originalStacksDir === undefined) delete process.env.STACKS_DIR;
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const composeUpMock = vi.fn().mockResolvedValue(undefined);
|
||||
vi.mock("@/lib/docker", () => ({
|
||||
composeUp: (...args: unknown[]) => composeUpMock(...args),
|
||||
composeDown: vi.fn(),
|
||||
composeStatus: vi.fn().mockResolvedValue("stopped"),
|
||||
reloadCaddy: vi.fn(),
|
||||
runMigrations: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("applyOperatorAlertEmailToAllBusinesses", () => {
|
||||
let stacksDir: string;
|
||||
const originalStacksDir = process.env.STACKS_DIR;
|
||||
|
||||
beforeEach(async () => {
|
||||
stacksDir = await mkdtemp(path.join(tmpdir(), "nc-manager-operator-rollout-test-"));
|
||||
process.env.STACKS_DIR = stacksDir;
|
||||
composeUpMock.mockClear();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (originalStacksDir === undefined) delete process.env.STACKS_DIR;
|
||||
else process.env.STACKS_DIR = originalStacksDir;
|
||||
await rm(stacksDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function createStack(slug: string, envContent: string) {
|
||||
const dir = path.join(stacksDir, slug);
|
||||
await mkdir(dir, { recursive: true });
|
||||
await writeFile(path.join(dir, "compose.yaml"), "name: test\n", "utf8");
|
||||
await writeFile(path.join(dir, ".env"), envContent, "utf8");
|
||||
return dir;
|
||||
}
|
||||
|
||||
it("writes the alert address into each business and restarts it", async () => {
|
||||
const { applyOperatorAlertEmailToAllBusinesses } = await import("../operator-rollout");
|
||||
await createStack("acme", 'BUSINESS_NAME="Acme"\nDOMAIN="acme.example.com"\n');
|
||||
|
||||
const results = await applyOperatorAlertEmailToAllBusinesses("ops@example.com");
|
||||
|
||||
expect(results).toEqual([{ slug: "acme", ok: true }]);
|
||||
expect(await readFile(path.join(stacksDir, "acme", ".env"), "utf8")).toContain(
|
||||
'OPERATOR_ALERT_EMAIL="ops@example.com"',
|
||||
);
|
||||
expect(composeUpMock).toHaveBeenCalledWith(path.join(stacksDir, "acme"), {
|
||||
forceRecreate: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves a business that already has this address alone — no rewrite, no restart", async () => {
|
||||
const { applyOperatorAlertEmailToAllBusinesses } = await import("../operator-rollout");
|
||||
await createStack("acme", 'BUSINESS_NAME="Acme"\nOPERATOR_ALERT_EMAIL="ops@example.com"\n');
|
||||
|
||||
const results = await applyOperatorAlertEmailToAllBusinesses("ops@example.com");
|
||||
|
||||
expect(results).toEqual([{ slug: "acme", ok: true, unchanged: true }]);
|
||||
expect(composeUpMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("an empty address turns alerts off, and one failing business doesn't stop the rest", async () => {
|
||||
const { applyOperatorAlertEmailToAllBusinesses } = await import("../operator-rollout");
|
||||
await createStack("alpha", 'OPERATOR_ALERT_EMAIL="ops@example.com"\n');
|
||||
await createStack("beta", 'OPERATOR_ALERT_EMAIL="ops@example.com"\n');
|
||||
composeUpMock.mockRejectedValueOnce(new Error("docker unavailable"));
|
||||
|
||||
const results = await applyOperatorAlertEmailToAllBusinesses("");
|
||||
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results.filter((r) => r.ok)).toHaveLength(1);
|
||||
expect(results.find((r) => !r.ok)?.error).toContain("docker unavailable");
|
||||
expect(await readFile(path.join(stacksDir, "beta", ".env"), "utf8")).toContain(
|
||||
'OPERATOR_ALERT_EMAIL=""',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -45,7 +45,6 @@ vi.mock("@/lib/backup", () => ({ performBackup: (...a: unknown[]) => performBack
|
||||
|
||||
import { performRestore, RESTORE_STEPS } from "../restore";
|
||||
|
||||
const PASSPHRASE = "restore-passphrase-1";
|
||||
const DB_PASSWORD = "f".repeat(32);
|
||||
const ARCHIVE = "/stacks/backups/acme/acme__20261017T030000Z__regular.7z";
|
||||
const BACKED_UP_ENV = ['BUSINESS_NAME="Acme Barbers"', 'DOMAIN="acme.example.com"', 'DATABASE_NAME="acme"', 'DATABASE_USER="acme"', `DATABASE_PASSWORD="${DB_PASSWORD}"`, 'AUTH_SECRET="s3cret-from-backup"'].join("\n") + "\n";
|
||||
@@ -71,7 +70,7 @@ describe("performRestore", () => {
|
||||
recreateDatabaseMock.mockReset().mockImplementation(async () => record("recreateDatabase"));
|
||||
runPipelineMock.mockReset().mockImplementation(async (source: CommandSpec) => record(`pipeline:${source.args.at(-1)}`));
|
||||
sevenZipTestMock.mockReset().mockImplementation(async () => record("7zz-test"));
|
||||
sevenZipReadMemberMock.mockReset().mockImplementation(async (_a: string, _p: string, member: string) => {
|
||||
sevenZipReadMemberMock.mockReset().mockImplementation(async (_a: string, member: string) => {
|
||||
record(`read:${member}`);
|
||||
return member === "manifest.json" ? manifest() : BACKED_UP_ENV;
|
||||
});
|
||||
@@ -111,7 +110,7 @@ describe("performRestore", () => {
|
||||
|
||||
it("fresh server: runs the steps in the planned order and the side effects in the planned order", async () => {
|
||||
const { ctx, steps } = makeCtx();
|
||||
const result = await performRestore({ archivePath: ARCHIVE, passphrase: PASSPHRASE, replace: false, ctx });
|
||||
const result = await performRestore({ archivePath: ARCHIVE, replace: false, ctx });
|
||||
|
||||
expect(steps).toEqual([
|
||||
"step:verify", "step:check", "skip:safety", "skip:stop", "step:database", "step:import", "step:config", "step:media", "step:start", "step:health",
|
||||
@@ -133,7 +132,7 @@ describe("performRestore", () => {
|
||||
});
|
||||
|
||||
it("recreates the DB with the password FROM THE BACKED-UP .env, and writes that .env verbatim (0600) plus a regenerated compose.yaml", async () => {
|
||||
await performRestore({ archivePath: ARCHIVE, passphrase: PASSPHRASE, replace: false, ctx: makeCtx().ctx });
|
||||
await performRestore({ archivePath: ARCHIVE, replace: false, ctx: makeCtx().ctx });
|
||||
|
||||
expect(recreateDatabaseMock).toHaveBeenCalledWith("acme", "acme", DB_PASSWORD);
|
||||
const envPath = path.join(stacksDir, "acme", ".env");
|
||||
@@ -145,11 +144,11 @@ describe("performRestore", () => {
|
||||
});
|
||||
|
||||
it("builds the import and media pipelines as argument arrays: 7zz -so → docker, no password in argv", async () => {
|
||||
await performRestore({ archivePath: ARCHIVE, passphrase: PASSPHRASE, replace: false, ctx: makeCtx().ctx });
|
||||
await performRestore({ archivePath: ARCHIVE, replace: false, ctx: makeCtx().ctx });
|
||||
|
||||
const [dumpSource, dumpSink] = runPipelineMock.mock.calls[0] as [CommandSpec, CommandSpec];
|
||||
expect(dumpSource.command).toBe("7zz");
|
||||
expect(dumpSource.args).toEqual(["x", "-so", "-t7z", `-p${PASSPHRASE}`, "-y", "-bd", "-bso0", "-bsp0", ARCHIVE, "db.sql"]);
|
||||
expect(dumpSource.args).toEqual(["x", "-so", "-t7z", "-y", "-bd", "-bso0", "-bsp0", ARCHIVE, "db.sql"]);
|
||||
expect(dumpSink).toMatchObject({ command: "docker" });
|
||||
expect(dumpSink.args).toEqual(["exec", "-i", "-e", "MYSQL_PWD", "next-chair-mysql", "mysql", "-uroot", "--default-character-set=utf8mb4", "acme"]);
|
||||
expect(dumpSink.args.join(" ")).not.toContain("root-pw");
|
||||
@@ -163,7 +162,7 @@ describe("performRestore", () => {
|
||||
it("existing business + replace: safety backup FIRST, then compose down (with volumes), then everything else", async () => {
|
||||
await existingStack();
|
||||
const { ctx, steps } = makeCtx();
|
||||
const result = await performRestore({ archivePath: ARCHIVE, passphrase: PASSPHRASE, replace: true, ctx });
|
||||
const result = await performRestore({ archivePath: ARCHIVE, replace: true, ctx });
|
||||
|
||||
expect(steps).toEqual([
|
||||
"step:verify", "step:check", "step:safety", "step:stop", "step:database", "step:import", "step:config", "step:media", "step:start", "step:health",
|
||||
@@ -179,7 +178,7 @@ describe("performRestore", () => {
|
||||
|
||||
it("existing business without 'replace': refuses before touching anything", async () => {
|
||||
await existingStack();
|
||||
await expect(performRestore({ archivePath: ARCHIVE, passphrase: PASSPHRASE, replace: false, ctx: makeCtx().ctx })).rejects.toThrow(/already exists.*replace/);
|
||||
await expect(performRestore({ archivePath: ARCHIVE, replace: false, ctx: makeCtx().ctx })).rejects.toThrow(/already exists.*replace/);
|
||||
expect(performBackupMock).not.toHaveBeenCalled();
|
||||
expect(recreateDatabaseMock).not.toHaveBeenCalled();
|
||||
expect(calls.some((c) => c.startsWith("composeDown"))).toBe(false);
|
||||
@@ -187,38 +186,37 @@ describe("performRestore", () => {
|
||||
|
||||
it("domain conflict: refuses before any destructive step", async () => {
|
||||
assertDomainAvailableMock.mockRejectedValue(new Error('Domain "acme.example.com" is already in use by "other".'));
|
||||
await expect(performRestore({ archivePath: ARCHIVE, passphrase: PASSPHRASE, replace: false, ctx: makeCtx().ctx })).rejects.toThrow(/already in use/);
|
||||
await expect(performRestore({ archivePath: ARCHIVE, replace: false, ctx: makeCtx().ctx })).rejects.toThrow(/already in use/);
|
||||
expect(recreateDatabaseMock).not.toHaveBeenCalled();
|
||||
expect(runPipelineMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("version guard: a backup from a newer schema than the image is refused before any change", async () => {
|
||||
sevenZipReadMemberMock.mockImplementation(async (_a: string, _p: string, member: string) =>
|
||||
sevenZipReadMemberMock.mockImplementation(async (_a: string, member: string) =>
|
||||
member === "manifest.json" ? manifest({ lastMigration: "20271231000000_from_the_future" }) : BACKED_UP_ENV,
|
||||
);
|
||||
await expect(performRestore({ archivePath: ARCHIVE, passphrase: PASSPHRASE, replace: false, ctx: makeCtx().ctx })).rejects.toThrow(/newer than the running app image/);
|
||||
await expect(performRestore({ archivePath: ARCHIVE, replace: false, ctx: makeCtx().ctx })).rejects.toThrow(/newer than the running app image/);
|
||||
expect(recreateDatabaseMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses an archive whose .env disagrees with its manifest", async () => {
|
||||
sevenZipReadMemberMock.mockImplementation(async (_a: string, _p: string, member: string) =>
|
||||
sevenZipReadMemberMock.mockImplementation(async (_a: string, member: string) =>
|
||||
member === "manifest.json" ? manifest() : BACKED_UP_ENV.replace('DATABASE_NAME="acme"', 'DATABASE_NAME="other"'),
|
||||
);
|
||||
await expect(performRestore({ archivePath: ARCHIVE, passphrase: PASSPHRASE, replace: false, ctx: makeCtx().ctx })).rejects.toThrow(/doesn't match its manifest/);
|
||||
await expect(performRestore({ archivePath: ARCHIVE, replace: false, ctx: makeCtx().ctx })).rejects.toThrow(/doesn't match its manifest/);
|
||||
expect(recreateDatabaseMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("a bad archive or wrong passphrase fails in the first step", async () => {
|
||||
sevenZipTestMock.mockRejectedValue(new Error("7zz exited with code 2: Wrong password"));
|
||||
await expect(performRestore({ archivePath: ARCHIVE, passphrase: PASSPHRASE, replace: false, ctx: makeCtx().ctx })).rejects.toThrow(/Wrong password/);
|
||||
it("a bad or corrupt archive fails in the first step", async () => {
|
||||
sevenZipTestMock.mockRejectedValue(new Error("7zz exited with code 2: Data error"));
|
||||
await expect(performRestore({ archivePath: ARCHIVE, replace: false, ctx: makeCtx().ctx })).rejects.toThrow(/Data error/);
|
||||
expect(assertDomainAvailableMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("never logs the passphrase or any .env secret", async () => {
|
||||
it("never logs any .env secret", async () => {
|
||||
const { ctx, lines } = makeCtx();
|
||||
await performRestore({ archivePath: ARCHIVE, passphrase: PASSPHRASE, replace: false, ctx });
|
||||
await performRestore({ archivePath: ARCHIVE, replace: false, ctx });
|
||||
const all = lines.join("\n");
|
||||
expect(all).not.toContain(PASSPHRASE);
|
||||
expect(all).not.toContain(DB_PASSWORD);
|
||||
expect(all).not.toContain("s3cret-from-backup");
|
||||
});
|
||||
|
||||
@@ -6,7 +6,6 @@ import { backupFileName, ensureBackupDir, pruneBackups, removePartialFiles, SAFE
|
||||
import { containerExists } from "./docker";
|
||||
import { mediaCopyOutCommand, mediaVolumeTarCommand, mysqlQueryCommand, mysqldumpCommand } from "./docker-commands";
|
||||
import { readEnvFile, readEnvValue } from "./env-file";
|
||||
import { readBackupPassphrase } from "./global-settings";
|
||||
import { stagingRoot } from "./manager-paths";
|
||||
import { runCapture, runToFile } from "./process";
|
||||
import { randomHex } from "./random";
|
||||
@@ -93,11 +92,6 @@ export async function performBackup(slug: string, kind: BackupKind, ctx: BackupR
|
||||
const domain = readEnvValue(envContent, "DOMAIN");
|
||||
if (!databaseName || !databaseUser || !domain) throw new Error(`"${slug}"'s .env is missing DATABASE_NAME/DATABASE_USER/DOMAIN.`);
|
||||
|
||||
const passphrase = await readBackupPassphrase();
|
||||
if (!passphrase) {
|
||||
throw new Error("No backup passphrase exists yet. Enable backups for a business (or use Back up now) from the Backups page so one is generated and shown to you.");
|
||||
}
|
||||
|
||||
const outDir = await ensureBackupDir(slug);
|
||||
await removePartialFiles(slug);
|
||||
const finalName = backupFileName(slug, kind, now);
|
||||
@@ -157,24 +151,23 @@ export async function performBackup(slug: string, kind: BackupKind, ctx: BackupR
|
||||
});
|
||||
|
||||
await runStep(ctx, "archive", async () => {
|
||||
ctx.log("Encrypting and compressing…");
|
||||
ctx.log("Compressing…");
|
||||
await rm(partialPath, { force: true });
|
||||
await sevenZipCreate({
|
||||
archivePath: partialPath,
|
||||
cwd: stagingDir,
|
||||
entries: [BACKUP_MEMBERS.manifest, BACKUP_MEMBERS.database, BACKUP_MEMBERS.media, "config"],
|
||||
passphrase,
|
||||
});
|
||||
});
|
||||
|
||||
await runStep(ctx, "verify", async () => {
|
||||
ctx.log("Verifying the archive (7zz t)…");
|
||||
await sevenZipTest(partialPath, passphrase);
|
||||
await sevenZipTest(partialPath);
|
||||
});
|
||||
|
||||
return await runStep(ctx, "finish", async () => {
|
||||
await rename(partialPath, finalPath);
|
||||
await chmod(finalPath, 0o600); // 7zz honours the umask; the archive (encrypted or not) should be owner-only
|
||||
await chmod(finalPath, 0o600); // 7zz honours the umask; the archive holds the business's secrets in the clear, so it must be owner-only
|
||||
const sizeBytes = (await stat(finalPath)).size;
|
||||
let pruned: string[] = [];
|
||||
if (kind === "regular") {
|
||||
|
||||
@@ -109,6 +109,8 @@ export interface BusinessEnvValues {
|
||||
superAdmin?: BusinessEnvSuperAdminValues;
|
||||
/** Global platform Google Calendar OAuth client (see src/lib/global-settings.ts) — same pattern as `email`/`push` above. Omitted falls back to today's blank defaults. */
|
||||
googleCalendar?: BusinessEnvGoogleCalendarValues;
|
||||
/** The operator's alert address (Settings > Monitoring) — the app emails unhandled server errors here. Omitted or blank leaves app-side alerts off. */
|
||||
operatorAlertEmail?: string;
|
||||
}
|
||||
|
||||
/** Same shape as .env.business.example, filled in with generated/collected values instead of placeholders. */
|
||||
@@ -167,6 +169,7 @@ GOOGLE_CALENDAR_CLIENT_SECRET=""`;
|
||||
# screen) — the app itself doesn't read it; its own business name lives
|
||||
# in the database, set via /install.
|
||||
BUSINESS_NAME="${values.name}"
|
||||
OPERATOR_ALERT_EMAIL="${values.operatorAlertEmail ?? ""}"
|
||||
DOMAIN="${values.domain}"
|
||||
DATABASE_HOST="next-chair-mysql"
|
||||
DATABASE_PORT="3306"
|
||||
|
||||
@@ -2,7 +2,6 @@ import { randomBytes, createCipheriv, createDecipheriv } from "node:crypto";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import webpush from "web-push";
|
||||
import { withMutex } from "./singleton";
|
||||
import { stacksRoot } from "./stacks";
|
||||
|
||||
/**
|
||||
@@ -89,19 +88,12 @@ export const DEFAULT_OPERATOR_SETTINGS: OperatorSettings = {
|
||||
healthIntervalMinutes: 3,
|
||||
};
|
||||
|
||||
/** Persisted shape — the passphrase only ever holds the encrypted envelope. One passphrase protects every business's backups, so a single value is enough to restore on a fresh server. */
|
||||
interface StoredBackupSettings {
|
||||
encryptedPassphrase: EncryptedPayload | null;
|
||||
createdAt: string | null;
|
||||
}
|
||||
|
||||
interface StoredGlobalSettings {
|
||||
email: StoredEmailSettings | null;
|
||||
push: StoredPushSettings | null;
|
||||
superAdmin: SuperAdminSettings | null;
|
||||
googleCalendar: StoredGoogleCalendarSettings | null;
|
||||
operator: OperatorSettings | null;
|
||||
backups: StoredBackupSettings | null;
|
||||
}
|
||||
|
||||
export interface LastTestResult {
|
||||
@@ -194,11 +186,10 @@ async function readStoredSettings(): Promise<StoredGlobalSettings> {
|
||||
superAdmin: parsed.superAdmin ?? null,
|
||||
googleCalendar: parsed.googleCalendar ?? null,
|
||||
operator: parsed.operator ?? null,
|
||||
backups: parsed.backups ?? null,
|
||||
};
|
||||
} catch {
|
||||
// No file yet (first run) or unreadable — treat as "nothing configured" rather than throwing, so the Settings page can always render.
|
||||
return { email: null, push: null, superAdmin: null, googleCalendar: null, operator: null, backups: null };
|
||||
return { email: null, push: null, superAdmin: null, googleCalendar: null, operator: null };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -562,52 +553,3 @@ export async function saveOperatorSettings(input: OperatorSettings): Promise<Ope
|
||||
await writeStoredSettings({ ...current, operator: next });
|
||||
return next;
|
||||
}
|
||||
|
||||
// ── Backup passphrase ────────────────────────────────────────────────────
|
||||
|
||||
export async function hasBackupPassphrase(): Promise<boolean> {
|
||||
const { backups } = await readStoredSettings();
|
||||
return Boolean(backups?.encryptedPassphrase);
|
||||
}
|
||||
|
||||
/** Server-side only — the decrypted passphrase used for scheduled/manual archives. Null until one has been generated. */
|
||||
export async function readBackupPassphrase(): Promise<string | null> {
|
||||
const { backups } = await readStoredSettings();
|
||||
return backups?.encryptedPassphrase ? decrypt(backups.encryptedPassphrase) : null;
|
||||
}
|
||||
|
||||
export async function backupPassphraseCreatedAt(): Promise<string | null> {
|
||||
const { backups } = await readStoredSettings();
|
||||
return backups?.createdAt ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the stored passphrase, generating (and encrypting) one on first
|
||||
* use. `created` is true only on that first call — the one moment the UI
|
||||
* shows the plaintext to the operator so they can save it somewhere safe;
|
||||
* afterwards it is never displayed again (a fresh server needs it typed
|
||||
* in at restore time).
|
||||
*/
|
||||
export function ensureBackupPassphrase(): Promise<{ passphrase: string; created: boolean }> {
|
||||
// Mutex: a scheduled backup and a manual one racing on a fresh install must not each mint a different passphrase.
|
||||
return withMutex("backup-passphrase", async () => {
|
||||
const existing = await readBackupPassphrase();
|
||||
if (existing) return { passphrase: existing, created: false };
|
||||
return { passphrase: await storeNewBackupPassphrase(), created: true };
|
||||
});
|
||||
}
|
||||
|
||||
/** Replaces the passphrase (older archives still need the old one). Returns the new plaintext, shown once. */
|
||||
export async function rotateBackupPassphrase(): Promise<string> {
|
||||
return storeNewBackupPassphrase();
|
||||
}
|
||||
|
||||
async function storeNewBackupPassphrase(): Promise<string> {
|
||||
const passphrase = randomBytes(18).toString("base64url");
|
||||
const current = await readStoredSettings();
|
||||
await writeStoredSettings({
|
||||
...current,
|
||||
backups: { encryptedPassphrase: encrypt(passphrase), createdAt: new Date().toISOString() },
|
||||
});
|
||||
return passphrase;
|
||||
}
|
||||
@@ -2,8 +2,6 @@
|
||||
export interface JobStartState {
|
||||
error: string | null;
|
||||
jobId: string | null;
|
||||
/** Plaintext backup passphrase — present only in the single response that just generated it. Shown once, never stored in a job record. */
|
||||
newPassphrase: string | null;
|
||||
}
|
||||
|
||||
export const NO_JOB: JobStartState = { error: null, jobId: null, newPassphrase: null };
|
||||
export const NO_JOB: JobStartState = { error: null, jobId: null };
|
||||
@@ -2,7 +2,7 @@ import { performBackup, BACKUP_STEPS } from "./backup";
|
||||
import type { BackupKind } from "./backup-manifest";
|
||||
import { recordBackupOutcome } from "./backup-settings";
|
||||
import { composeUp } from "./docker";
|
||||
import { readBackupPassphrase, readOperatorSettings } from "./global-settings";
|
||||
import { readOperatorSettings } from "./global-settings";
|
||||
import { setIntentionallyStopped } from "./health-watcher";
|
||||
import { waitForHealth } from "./health";
|
||||
import { startJob, type JobContext, type StartedJob } from "./jobs";
|
||||
@@ -19,12 +19,6 @@ import { isValidSlug, stackDir, stackExists } from "./stacks";
|
||||
* here is awaited by a request.
|
||||
*/
|
||||
|
||||
async function requirePassphrase(): Promise<string> {
|
||||
const passphrase = await readBackupPassphrase();
|
||||
if (!passphrase) throw new Error("No backup passphrase exists yet — open a business's Backups page first so one is generated.");
|
||||
return passphrase;
|
||||
}
|
||||
|
||||
async function alertBackupFailure(slug: string, message: string): Promise<void> {
|
||||
try {
|
||||
const settings = await readOperatorSettings();
|
||||
@@ -37,7 +31,6 @@ async function alertBackupFailure(slug: string, message: string): Promise<void>
|
||||
|
||||
export async function startBackupJob(slug: string, kind: BackupKind = "regular"): Promise<StartedJob> {
|
||||
if (!isValidSlug(slug) || !(await stackExists(slug))) throw new Error(`No such business: ${slug}`);
|
||||
await requirePassphrase();
|
||||
|
||||
return startJob({
|
||||
kind: "backup",
|
||||
@@ -64,7 +57,6 @@ export async function startBackupJob(slug: string, kind: BackupKind = "regular")
|
||||
/** Recreates the container against the shared image tag; optionally takes a safety backup first. Rebuilding the image stays a manual step. */
|
||||
export async function startUpdateJob(slug: string, backupFirst: boolean): Promise<StartedJob> {
|
||||
if (!isValidSlug(slug) || !(await stackExists(slug))) throw new Error(`No such business: ${slug}`);
|
||||
if (backupFirst) await requirePassphrase();
|
||||
|
||||
return startJob({
|
||||
kind: "update",
|
||||
@@ -107,7 +99,6 @@ export async function startUpdateJob(slug: string, backupFirst: boolean): Promis
|
||||
/** Takes an optional final backup, then removes the business. If the backup fails the business is left untouched. */
|
||||
export async function startDeleteJob(slug: string, finalBackup: boolean): Promise<StartedJob> {
|
||||
if (!isValidSlug(slug) || !(await stackExists(slug))) throw new Error(`No such business: ${slug}`);
|
||||
if (finalBackup) await requirePassphrase();
|
||||
|
||||
return startJob({
|
||||
kind: "delete",
|
||||
@@ -133,18 +124,14 @@ export async function startDeleteJob(slug: string, finalBackup: boolean): Promis
|
||||
|
||||
export interface RestoreJobInput {
|
||||
source: RestoreSource;
|
||||
/** Blank → the stored passphrase. */
|
||||
passphrase: string;
|
||||
replace: boolean;
|
||||
}
|
||||
|
||||
export async function startRestoreJob(input: RestoreJobInput): Promise<StartedJob> {
|
||||
const passphrase = input.passphrase.trim() || (await readBackupPassphrase()) || "";
|
||||
if (!passphrase) throw new Error("Enter the backup passphrase.");
|
||||
const archivePath = resolveArchivePath(input.source);
|
||||
|
||||
// The lock is per slug and the slug lives in the archive, so read the manifest first (fast: only the encrypted header is opened).
|
||||
const manifest = await readArchiveManifest(archivePath, passphrase);
|
||||
// The lock is per slug and the slug lives in the archive, so read the manifest first (fast: only that one file is read).
|
||||
const manifest = await readArchiveManifest(archivePath);
|
||||
|
||||
return startJob({
|
||||
kind: "restore",
|
||||
@@ -152,7 +139,7 @@ export async function startRestoreJob(input: RestoreJobInput): Promise<StartedJo
|
||||
title: `Restore ${manifest.slug}`,
|
||||
steps: [...RESTORE_STEPS],
|
||||
run: async (ctx) => {
|
||||
const result = await performRestore({ archivePath, passphrase, replace: input.replace, ctx });
|
||||
const result = await performRestore({ archivePath, replace: input.replace, ctx });
|
||||
await setIntentionallyStopped(result.slug, false);
|
||||
return { slug: result.slug, domain: result.domain, safetyBackup: result.safetyBackup };
|
||||
},
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import path from "node:path";
|
||||
import { composeUp } from "./docker";
|
||||
import { readEnvFile, readEnvValue, upsertEnvValues } from "./env-file";
|
||||
import { listBusinessStacks, stackDir } from "./stacks";
|
||||
|
||||
export interface OperatorRolloutResult {
|
||||
slug: string;
|
||||
ok: boolean;
|
||||
/** True when the business already had this address, so nothing was touched and it wasn't restarted. */
|
||||
unchanged?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gives every business the operator's alert address as OPERATOR_ALERT_EMAIL, so
|
||||
* the app itself can email an unhandled server error (see the main app's
|
||||
* src/instrumentation.ts). Same surgical `upsertEnvValues` + force-recreate as
|
||||
* the other rollouts — but a business that already has this exact value is
|
||||
* skipped, because restarting every business just to re-write what's there
|
||||
* would be a pointless interruption. An empty address turns the app-side
|
||||
* alerts off. Sequential, like the other rollouts.
|
||||
*/
|
||||
export async function applyOperatorAlertEmailToAllBusinesses(
|
||||
alertEmail: string,
|
||||
): Promise<OperatorRolloutResult[]> {
|
||||
const businesses = await listBusinessStacks();
|
||||
const results: OperatorRolloutResult[] = [];
|
||||
|
||||
for (const business of businesses) {
|
||||
try {
|
||||
const envPath = path.join(stackDir(business.slug), ".env");
|
||||
const current =
|
||||
readEnvValue((await readEnvFile(envPath)) ?? "", "OPERATOR_ALERT_EMAIL") ?? "";
|
||||
if (current === alertEmail) {
|
||||
results.push({ slug: business.slug, ok: true, unchanged: true });
|
||||
continue;
|
||||
}
|
||||
await upsertEnvValues(envPath, { OPERATOR_ALERT_EMAIL: alertEmail });
|
||||
await composeUp(stackDir(business.slug), { forceRecreate: true });
|
||||
results.push({ slug: business.slug, ok: true });
|
||||
} catch (error) {
|
||||
results.push({
|
||||
slug: business.slug,
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import { pipeline } from "node:stream/promises";
|
||||
* never execFile, whose maxBuffer would kill a large dump mid-stream.
|
||||
*
|
||||
* Commands are always `{ command, args[] }` (never a shell string), and
|
||||
* secrets travel in `env` (e.g. MYSQL_PWD) or the 7zz passphrase arg —
|
||||
* secrets travel in `env` (e.g. MYSQL_PWD) —
|
||||
* never in anything this module logs: errors carry only the command's
|
||||
* basename and its stderr tail.
|
||||
*/
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
isGoogleCalendarConfigComplete,
|
||||
isPushConfigComplete,
|
||||
isSuperAdminConfigComplete,
|
||||
readOperatorSettings,
|
||||
readSuperAdminSettings,
|
||||
resolveEmailSettings,
|
||||
resolveGoogleCalendarSettings,
|
||||
@@ -231,6 +232,7 @@ export async function provisionBusiness(input: ProvisionBusinessInput): Promise<
|
||||
push,
|
||||
superAdmin,
|
||||
googleCalendar,
|
||||
operatorAlertEmail: (await readOperatorSettings()).alertEmail,
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
+13
-14
@@ -30,7 +30,7 @@ import { stackDir, stackExists } from "./stacks";
|
||||
*/
|
||||
|
||||
export const RESTORE_STEPS = [
|
||||
{ id: "verify", label: "Verify the archive and passphrase" },
|
||||
{ id: "verify", label: "Verify the archive" },
|
||||
{ id: "check", label: "Check for conflicts and schema version" },
|
||||
{ id: "safety", label: "Take a safety backup of the current business" },
|
||||
{ id: "stop", label: "Stop the current stack" },
|
||||
@@ -61,15 +61,15 @@ export interface RestorePlan {
|
||||
schema: { ok: true } | { ok: false; reason: string };
|
||||
}
|
||||
|
||||
/** Fast: opens only the (encrypted) header and reads manifest.json. A wrong passphrase fails here. Does not verify the payload. */
|
||||
export async function readArchiveManifest(archivePath: string, passphrase: string): Promise<BackupManifest> {
|
||||
return parseManifest(await sevenZipReadMember(archivePath, passphrase, BACKUP_MEMBERS.manifest));
|
||||
/** Fast: reads manifest.json from the archive. Does not verify the payload. */
|
||||
export async function readArchiveManifest(archivePath: string): Promise<BackupManifest> {
|
||||
return parseManifest(await sevenZipReadMember(archivePath, BACKUP_MEMBERS.manifest));
|
||||
}
|
||||
|
||||
/** Full verification (`7zz t` over the whole archive) plus the manifest. Throws on a corrupt archive or wrong passphrase. */
|
||||
export async function inspectArchive(archivePath: string, passphrase: string): Promise<BackupManifest> {
|
||||
await sevenZipTest(archivePath, passphrase);
|
||||
return readArchiveManifest(archivePath, passphrase);
|
||||
/** Full verification (`7zz t` over the whole archive) plus the manifest. Throws on a corrupt archive. */
|
||||
export async function inspectArchive(archivePath: string): Promise<BackupManifest> {
|
||||
await sevenZipTest(archivePath);
|
||||
return readArchiveManifest(archivePath);
|
||||
}
|
||||
|
||||
/** Read-only conflict/version report shown on the Restore page before the operator confirms. */
|
||||
@@ -104,17 +104,16 @@ export interface RestoreResult {
|
||||
|
||||
export async function performRestore(params: {
|
||||
archivePath: string;
|
||||
passphrase: string;
|
||||
/** Operator confirmed overwriting an existing business with the same slug. */
|
||||
replace: boolean;
|
||||
ctx: RestoreContext;
|
||||
}): Promise<RestoreResult> {
|
||||
const { archivePath, passphrase, replace, ctx } = params;
|
||||
const { archivePath, replace, ctx } = params;
|
||||
|
||||
const { manifest, envContent } = await ctx.step("verify", async () => {
|
||||
ctx.log("Testing the archive (7zz t)…");
|
||||
const parsed = await inspectArchive(archivePath, passphrase);
|
||||
const env = await sevenZipReadMember(archivePath, passphrase, BACKUP_MEMBERS.env);
|
||||
const parsed = await inspectArchive(archivePath);
|
||||
const env = await sevenZipReadMember(archivePath, BACKUP_MEMBERS.env);
|
||||
ctx.log(`Backup of "${parsed.slug}" (${parsed.domain}) taken ${parsed.createdAt}.`);
|
||||
|
||||
// The .env inside the archive must agree with the manifest — the
|
||||
@@ -169,7 +168,7 @@ export async function performRestore(params: {
|
||||
|
||||
await ctx.step("import", async () => {
|
||||
ctx.log("Importing db.sql (streamed from the archive)…");
|
||||
await runPipeline(sevenZipExtractToStdoutCommand(archivePath, passphrase, BACKUP_MEMBERS.database), mysqlImportCommand(manifest.databaseName));
|
||||
await runPipeline(sevenZipExtractToStdoutCommand(archivePath, BACKUP_MEMBERS.database), mysqlImportCommand(manifest.databaseName));
|
||||
});
|
||||
|
||||
await ctx.step("config", async () => {
|
||||
@@ -183,7 +182,7 @@ export async function performRestore(params: {
|
||||
await ctx.step("media", async () => {
|
||||
// Create (don't start) the container so the named volume exists with compose's labels before it's filled.
|
||||
await composeCreate(dir);
|
||||
await runPipeline(sevenZipExtractToStdoutCommand(archivePath, passphrase, BACKUP_MEMBERS.media), mediaRestoreCommand(slug));
|
||||
await runPipeline(sevenZipExtractToStdoutCommand(archivePath, BACKUP_MEMBERS.media), mediaRestoreCommand(slug));
|
||||
ctx.log("Media restored and handed to uid 1001.");
|
||||
});
|
||||
|
||||
|
||||
+18
-25
@@ -2,55 +2,48 @@ import { runCapture, runQuiet, type CommandSpec } from "./process";
|
||||
|
||||
/**
|
||||
* 7-Zip (`7zz`, from Alpine's `7zip` package — see Dockerfile) wrappers.
|
||||
* Archives are 7z with AES-256 and encrypted headers (-mhe=on), so even
|
||||
* the file names inside are hidden without the passphrase.
|
||||
* Archives are plain (unencrypted) 7z: the operator decided backups don't
|
||||
* need a password. That means an archive contains the business's secrets in
|
||||
* the clear (its .env: database password, auth secret, SMTP password, VAPID
|
||||
* private key) — which is why files are written owner-only (0600) and the
|
||||
* download route is behind the Manager login. Treat a downloaded copy like
|
||||
* the server's own .env files.
|
||||
*
|
||||
* The passphrase is passed as `-p<passphrase>` (7zz has no safer
|
||||
* non-interactive channel). It is generated hex/base64url by the Manager
|
||||
* or typed by the operator, and — like every command here — runs as an
|
||||
* argument array, never through a shell.
|
||||
* Every command here runs as an argument array, never through a shell.
|
||||
*/
|
||||
|
||||
function sevenZipBinary(): string {
|
||||
return process.env.SEVENZIP_BIN ?? "7zz";
|
||||
}
|
||||
|
||||
function assertPassphrase(passphrase: string): void {
|
||||
// An empty `-p` would make 7zz prompt on a terminal that doesn't exist.
|
||||
if (passphrase.length < 8) throw new Error("The backup passphrase must be at least 8 characters.");
|
||||
}
|
||||
|
||||
export function sevenZipCreateCommand(params: { archivePath: string; cwd: string; entries: string[]; passphrase: string }): CommandSpec {
|
||||
assertPassphrase(params.passphrase);
|
||||
export function sevenZipCreateCommand(params: { archivePath: string; cwd: string; entries: string[] }): CommandSpec {
|
||||
return {
|
||||
command: sevenZipBinary(),
|
||||
args: ["a", "-t7z", "-mx=3", "-mhe=on", `-p${params.passphrase}`, "-y", "-bd", params.archivePath, ...params.entries],
|
||||
args: ["a", "-t7z", "-mx=3", "-y", "-bd", params.archivePath, ...params.entries],
|
||||
cwd: params.cwd,
|
||||
};
|
||||
}
|
||||
|
||||
export function sevenZipTestCommand(archivePath: string, passphrase: string): CommandSpec {
|
||||
assertPassphrase(passphrase);
|
||||
return { command: sevenZipBinary(), args: ["t", "-t7z", `-p${passphrase}`, "-y", "-bd", archivePath] };
|
||||
export function sevenZipTestCommand(archivePath: string): CommandSpec {
|
||||
return { command: sevenZipBinary(), args: ["t", "-t7z", "-y", "-bd", archivePath] };
|
||||
}
|
||||
|
||||
/** Streams one archive member to stdout (`-so`) — for piping a dump/tar straight into docker without unpacking to disk. */
|
||||
export function sevenZipExtractToStdoutCommand(archivePath: string, passphrase: string, member: string): CommandSpec {
|
||||
assertPassphrase(passphrase);
|
||||
return { command: sevenZipBinary(), args: ["x", "-so", "-t7z", `-p${passphrase}`, "-y", "-bd", "-bso0", "-bsp0", archivePath, member] };
|
||||
export function sevenZipExtractToStdoutCommand(archivePath: string, member: string): CommandSpec {
|
||||
return { command: sevenZipBinary(), args: ["x", "-so", "-t7z", "-y", "-bd", "-bso0", "-bsp0", archivePath, member] };
|
||||
}
|
||||
|
||||
export async function sevenZipCreate(params: Parameters<typeof sevenZipCreateCommand>[0]): Promise<void> {
|
||||
await runQuiet(sevenZipCreateCommand(params));
|
||||
}
|
||||
|
||||
/** Throws if the archive is corrupt or the passphrase is wrong. */
|
||||
export async function sevenZipTest(archivePath: string, passphrase: string): Promise<void> {
|
||||
await runQuiet(sevenZipTestCommand(archivePath, passphrase));
|
||||
/** Throws if the archive is corrupt. */
|
||||
export async function sevenZipTest(archivePath: string): Promise<void> {
|
||||
await runQuiet(sevenZipTestCommand(archivePath));
|
||||
}
|
||||
|
||||
/** For SMALL members only (manifest.json, .env) — capped at 1 MiB. */
|
||||
export async function sevenZipReadMember(archivePath: string, passphrase: string, member: string): Promise<string> {
|
||||
const { stdout } = await runCapture(sevenZipExtractToStdoutCommand(archivePath, passphrase, member), { maxBytes: 1024 * 1024 });
|
||||
export async function sevenZipReadMember(archivePath: string, member: string): Promise<string> {
|
||||
const { stdout } = await runCapture(sevenZipExtractToStdoutCommand(archivePath, member), { maxBytes: 1024 * 1024 });
|
||||
return stdout;
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE `ApplicationInstallation` ADD COLUMN `lastCronRunAt` DATETIME(3) NULL;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE `BookingRule` ADD COLUMN `noShowBlockThreshold` INTEGER NULL,
|
||||
ADD COLUMN `noShowFlagThreshold` INTEGER NULL;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE `BusinessSettings` ADD COLUMN `whatsappReminderLeadHours` INTEGER NOT NULL DEFAULT 24;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE `CustomerProfile` ADD COLUMN `noShowResetAt` DATETIME(3) NULL;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE `Notification` MODIFY `type` ENUM('BOOKING_CONFIRMED', 'BOOKING_PENDING_APPROVAL', 'BOOKING_APPROVED', 'BOOKING_REMINDER', 'BOOKING_RESCHEDULED', 'BOOKING_CANCELLED', 'BOOKING_BARBER_CHANGED', 'BOOKING_COMPLETED', 'LOYALTY_STAMP_ADDED', 'LOYALTY_REWARD_EARNED', 'LOYALTY_REWARD_REDEEMED', 'LOYALTY_REWARD_EXPIRING', 'STAFF_NEW_APPOINTMENT', 'STAFF_APPOINTMENT_RESCHEDULED', 'STAFF_APPOINTMENT_CANCELLED', 'STAFF_APPOINTMENT_CHECKED_IN', 'STAFF_APPOINTMENT_REMINDER', 'STAFF_SCHEDULE_CHANGED', 'STAFF_INVITATION_RECEIVED', 'OWNER_NEW_BOOKING', 'OWNER_BOOKING_CANCELLED', 'OWNER_NO_SHOW', 'OWNER_STAFF_INVITATION_ACCEPTED', 'OWNER_REPEATED_DELIVERY_FAILURE', 'STAFF_WHATSAPP_REMINDERS_DUE') NOT NULL;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE `NotificationPreference` MODIFY `type` ENUM('BOOKING_CONFIRMED', 'BOOKING_PENDING_APPROVAL', 'BOOKING_APPROVED', 'BOOKING_REMINDER', 'BOOKING_RESCHEDULED', 'BOOKING_CANCELLED', 'BOOKING_BARBER_CHANGED', 'BOOKING_COMPLETED', 'LOYALTY_STAMP_ADDED', 'LOYALTY_REWARD_EARNED', 'LOYALTY_REWARD_REDEEMED', 'LOYALTY_REWARD_EXPIRING', 'STAFF_NEW_APPOINTMENT', 'STAFF_APPOINTMENT_RESCHEDULED', 'STAFF_APPOINTMENT_CANCELLED', 'STAFF_APPOINTMENT_CHECKED_IN', 'STAFF_APPOINTMENT_REMINDER', 'STAFF_SCHEDULE_CHANGED', 'STAFF_INVITATION_RECEIVED', 'OWNER_NEW_BOOKING', 'OWNER_BOOKING_CANCELLED', 'OWNER_NO_SHOW', 'OWNER_STAFF_INVITATION_ACCEPTED', 'OWNER_REPEATED_DELIVERY_FAILURE', 'STAFF_WHATSAPP_REMINDERS_DUE') NOT NULL;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE `StaffCalendarConnection` ADD COLUMN `appCreatedCalendar` BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
@@ -184,6 +184,11 @@ enum NotificationType {
|
||||
/// for the owner when it observes repeated delivery failures, rather
|
||||
/// than a domain workflow triggering it directly. See reminder-worker.ts.
|
||||
OWNER_REPEATED_DELIVERY_FAILURE
|
||||
|
||||
// Barber-facing (cont.)
|
||||
/// A nudge that some appointments' WhatsApp reminders are due to send by
|
||||
/// hand — see whatsapp-reminders.service.ts. Push only by default.
|
||||
STAFF_WHATSAPP_REMINDERS_DUE
|
||||
}
|
||||
|
||||
enum NotificationChannel {
|
||||
@@ -415,6 +420,10 @@ model BusinessSettings {
|
||||
bookingTerms String? @db.Text
|
||||
privacyPolicyUrl String?
|
||||
|
||||
/// How many hours ahead appointments become "due" in the staff WhatsApp
|
||||
/// reminders queue (/staff/reminders). See whatsapp-reminders.service.ts.
|
||||
whatsappReminderLeadHours Int @default(24)
|
||||
|
||||
/// Who the public Terms of Service / Privacy Policy pages name as "we" —
|
||||
/// see src/features/legal/legal-identity.ts. NEXTCHAIR is the default so
|
||||
/// existing businesses keep today's wording.
|
||||
@@ -490,6 +499,14 @@ model BookingRule {
|
||||
/// payments phase (see docs/implementation-plan.md §6, "later phase").
|
||||
requireDeposit Boolean @default(false)
|
||||
|
||||
/// Both null = no-show handling is off. A customer's no-show count is
|
||||
/// derived (see no-show.service.ts), never stored: NO_SHOW appointments
|
||||
/// since their last attended (COMPLETED) one or since an owner reset.
|
||||
/// At/above the flag threshold staff see a warning symbol; at/above the
|
||||
/// block threshold online booking is refused (staff can still book them).
|
||||
noShowFlagThreshold Int?
|
||||
noShowBlockThreshold Int?
|
||||
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
@@ -646,6 +663,10 @@ model ApplicationInstallation {
|
||||
|
||||
failureReason String? @db.Text
|
||||
|
||||
/// Stamped by /api/cron/process-notifications on every authorised run, so
|
||||
/// /api/health can tell "the cron stopped" from "nothing to send".
|
||||
lastCronRunAt DateTime?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
@@ -808,6 +829,10 @@ model CustomerProfile {
|
||||
phone String?
|
||||
isGuest Boolean @default(true)
|
||||
|
||||
/// An owner's "forgive" — no-shows before this moment no longer count
|
||||
/// towards the flag/block thresholds. See no-show.service.ts.
|
||||
noShowResetAt DateTime?
|
||||
|
||||
preferredBarberId String?
|
||||
preferredBarber StaffProfile? @relation("PreferredBarber", fields: [preferredBarberId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@ -954,6 +979,11 @@ model StaffCalendarConnection {
|
||||
|
||||
selectedCalendarId String
|
||||
selectedCalendarName String
|
||||
/// True when the connection was made with the narrow calendar.app.created
|
||||
/// scope: selectedCalendarId is a secondary calendar this app created, and
|
||||
/// there is no calendar picker (the scope can't list the user's calendars).
|
||||
/// False = a pre-existing connection with the broader scopes.
|
||||
appCreatedCalendar Boolean @default(false)
|
||||
|
||||
connectedAt DateTime @default(now())
|
||||
lastSyncAt DateTime?
|
||||
|
||||
@@ -16,6 +16,8 @@ import { resolveRewardThreshold } from "@/services/loyalty/reward-threshold";
|
||||
import { getAppointmentById } from "@/services/scheduling/appointment.service";
|
||||
import { assertAppointmentInBusiness, StaffPermissionDeniedError } from "@/features/staff/authorization";
|
||||
import { AppointmentStatusActions } from "@/features/staff/components/appointment-status-actions";
|
||||
import { NoShowBadge } from "@/features/staff/components/no-show-badge";
|
||||
import { getNoShowStandings } from "@/services/customer/no-show.service";
|
||||
import { AddStampButton } from "@/features/staff/components/add-stamp-button";
|
||||
import { BookFollowUpButton } from "@/features/staff/components/book-follow-up-button";
|
||||
import { ownerAppointmentActions } from "@/features/owner/actions/appointment-action-set";
|
||||
@@ -64,6 +66,10 @@ export default async function OwnerAppointmentDetailsPage({
|
||||
throw error;
|
||||
}
|
||||
|
||||
const noShow = (await getNoShowStandings(prisma, { businessId: context.businessId, customerIds: [appointment.customerId] })).get(
|
||||
appointment.customerId,
|
||||
);
|
||||
|
||||
const [notes, loyaltyTransactions, loyalty, cancelledByUser] = await Promise.all([
|
||||
prisma.customerNote.findMany({
|
||||
where: { customerId: appointment.customerId },
|
||||
@@ -112,6 +118,7 @@ export default async function OwnerAppointmentDetailsPage({
|
||||
<Link href={`/staff/clients/${appointment.customerId}`} className="block text-lg font-bold underline">
|
||||
{appointment.customer.firstName} {appointment.customer.lastName}
|
||||
</Link>
|
||||
{noShow && <NoShowBadge className="ml-2 align-middle" count={noShow.count} flagged={noShow.flagged} blocked={noShow.blocked} />}
|
||||
<p className="text-muted text-sm">
|
||||
{appointment.customer.isGuest ? "Guest customer" : "Registered customer"}
|
||||
</p>
|
||||
|
||||
@@ -29,6 +29,7 @@ import { ViewSwitch } from "@/features/staff/components/view-switch";
|
||||
import { OwnerBlockTimeButton } from "@/features/owner/components/owner-block-time-button";
|
||||
import { StaffFilterPills } from "@/features/owner/components/staff-filter-pills";
|
||||
import type { StaffFilterOption } from "@/features/owner/components/staff-filter-pills";
|
||||
import { getNoShowStandings } from "@/services/customer/no-show.service";
|
||||
import { InlineNotificationBell } from "@/features/notifications/components/notification-bell";
|
||||
import type { AppointmentStatus } from "@/types/domain";
|
||||
|
||||
@@ -117,6 +118,11 @@ export default async function OwnerCalendarPage({
|
||||
customerIds: [...new Set(appointments.map((a) => a.customerId))],
|
||||
});
|
||||
|
||||
const noShowStandings = await getNoShowStandings(prisma, {
|
||||
businessId: context.businessId,
|
||||
customerIds: appointments.map((a) => a.customerId),
|
||||
});
|
||||
|
||||
const currentTimeMinutes = date === today ? minutesFromMidnight(new Date(), businessTimezone) : null;
|
||||
|
||||
const staffToRender = selectedStaff ? [selectedStaff] : allStaff;
|
||||
@@ -162,6 +168,7 @@ export default async function OwnerCalendarPage({
|
||||
status: STATUS_MAP[a.status] ?? "confirmed",
|
||||
hasNotes: !!a.notes,
|
||||
hasAvailableReward: rewardFlags.get(a.customerId) ?? false,
|
||||
noShowFlagged: noShowStandings.get(a.customerId)?.flagged ?? false,
|
||||
}));
|
||||
|
||||
return { workingWindow, blocks, wholeDayOff, staffAppointments };
|
||||
@@ -235,7 +242,7 @@ export default async function OwnerCalendarPage({
|
||||
</div>
|
||||
|
||||
<div className="mt-3 px-6">
|
||||
<RemindersLink businessId={context.businessId} timezone={businessTimezone} />
|
||||
<RemindersLink businessId={context.businessId} />
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
@@ -283,6 +290,7 @@ export default async function OwnerCalendarPage({
|
||||
)}
|
||||
hasNotes={!!a.notes}
|
||||
hasAvailableReward={rewardFlags.get(a.customerId) ?? false}
|
||||
noShowFlagged={noShowStandings.get(a.customerId)?.flagged ?? false}
|
||||
actions={
|
||||
<Link
|
||||
href={`/owner/appointments/${a.id}`}
|
||||
|
||||
@@ -41,6 +41,8 @@ export default async function OwnerBookingRulesPage() {
|
||||
customerCanChooseStaff: rule.customerCanChooseStaff,
|
||||
allowGuestBooking: rule.allowGuestBooking,
|
||||
requireApproval: rule.requireApproval,
|
||||
noShowFlagThreshold: rule.noShowFlagThreshold,
|
||||
noShowBlockThreshold: rule.noShowBlockThreshold,
|
||||
}}
|
||||
/>
|
||||
</SettingsPageShell>
|
||||
|
||||
@@ -55,6 +55,7 @@ export default async function OwnerBusinessDetailsPage() {
|
||||
cancellationPolicy: business.settings?.cancellationPolicy ?? "",
|
||||
legalNameDisplay: business.settings?.legalNameDisplay ?? "NEXTCHAIR",
|
||||
legalContactEmail: business.settings?.legalContactEmail ?? "NEXTCHAIR",
|
||||
whatsappReminderLeadHours: business.settings?.whatsappReminderLeadHours ?? 24,
|
||||
}}
|
||||
/>
|
||||
</SettingsPageShell>
|
||||
|
||||
@@ -41,8 +41,6 @@ export default async function OwnerLoyaltyConfigPage() {
|
||||
rewardLabel: programme.rewardLabel ?? undefined,
|
||||
maxRewardValue: programme.maxRewardValue?.toString(),
|
||||
freeServiceId: programme.freeServiceId ?? undefined,
|
||||
autoApplyReward: programme.autoApplyReward,
|
||||
requiresStaffApproval: programme.requiresStaffApproval,
|
||||
stampsExpireAfterDays: programme.stampsExpireAfterDays ?? undefined,
|
||||
rewardExpiresAfterDays: programme.rewardExpiresAfterDays ?? undefined,
|
||||
earnOnCancelled: programme.earnOnCancelled,
|
||||
@@ -63,8 +61,6 @@ export default async function OwnerLoyaltyConfigPage() {
|
||||
stampsRequired: 10,
|
||||
rewardPosition: RewardPositionMode.AFTER_ALL_STAMPS,
|
||||
rewardType: "PERCENTAGE" as const,
|
||||
autoApplyReward: true,
|
||||
requiresStaffApproval: false,
|
||||
earnOnCancelled: false,
|
||||
earnOnNoShow: false,
|
||||
cardFont: LoyaltyCardFont.SANS,
|
||||
|
||||
@@ -13,6 +13,8 @@ import { resolveRewardThreshold } from "@/services/loyalty/reward-threshold";
|
||||
import { getAppointmentById } from "@/services/scheduling/appointment.service";
|
||||
import { assertOwnAppointment, StaffPermissionDeniedError } from "@/features/staff/authorization";
|
||||
import { AppointmentStatusActions } from "@/features/staff/components/appointment-status-actions";
|
||||
import { NoShowBadge } from "@/features/staff/components/no-show-badge";
|
||||
import { getNoShowStandings } from "@/services/customer/no-show.service";
|
||||
import { AddStampButton } from "@/features/staff/components/add-stamp-button";
|
||||
import { BookFollowUpButton } from "@/features/staff/components/book-follow-up-button";
|
||||
import { RedeemRewardButton } from "@/features/owner/components/redeem-reward-button";
|
||||
@@ -51,6 +53,10 @@ export default async function StaffAppointmentDetailsPage({
|
||||
throw error;
|
||||
}
|
||||
|
||||
const noShow = (await getNoShowStandings(prisma, { businessId: staff.businessId, customerIds: [appointment.customerId] })).get(
|
||||
appointment.customerId,
|
||||
);
|
||||
|
||||
const [notes, loyaltyTransactions, loyalty, cancelledByUser] = await Promise.all([
|
||||
prisma.customerNote.findMany({
|
||||
where: { customerId: appointment.customerId },
|
||||
@@ -101,6 +107,7 @@ export default async function StaffAppointmentDetailsPage({
|
||||
>
|
||||
{appointment.customer.firstName} {appointment.customer.lastName}
|
||||
</Link>
|
||||
{noShow && <NoShowBadge className="ml-2 align-middle" count={noShow.count} flagged={noShow.flagged} blocked={noShow.blocked} />}
|
||||
<p className="text-muted text-sm">
|
||||
{appointment.customer.isGuest ? "Guest customer" : "Registered customer"}
|
||||
</p>
|
||||
|
||||
@@ -18,6 +18,9 @@ import { getActingBusinessContext } from "@/lib/business/get-acting-business-con
|
||||
import { getLoyaltyHistory, getLoyaltySummary } from "@/services/loyalty/loyalty.service";
|
||||
import { resolveRewardThreshold } from "@/services/loyalty/reward-threshold";
|
||||
import { listAppointments } from "@/services/scheduling/appointment.service";
|
||||
import { NoShowBadge } from "@/features/staff/components/no-show-badge";
|
||||
import { ResetNoShowsButton } from "@/features/staff/components/reset-no-shows-button";
|
||||
import { countNoShows, evaluateStanding, getNoShowThresholds } from "@/services/customer/no-show.service";
|
||||
import { AddStampButton } from "@/features/staff/components/add-stamp-button";
|
||||
import { CorrectStampButton } from "@/features/staff/components/correct-stamp-button";
|
||||
import { CustomerNotes } from "@/features/staff/components/customer-notes";
|
||||
@@ -89,6 +92,13 @@ export default async function StaffCustomerDetailsPage({
|
||||
getLoyaltySummary(prisma, { customerId: id, businessId: context.businessId }),
|
||||
]);
|
||||
|
||||
// Derived from the history already loaded above — no extra query beyond the two rule numbers.
|
||||
const noShowStanding = evaluateStanding(
|
||||
countNoShows([...past, ...cancelled], customer.noShowResetAt),
|
||||
await getNoShowThresholds(prisma, context.businessId),
|
||||
);
|
||||
const canResetNoShows = await hasPermission(context.userId, Permission.MANAGE_ALL_APPOINTMENTS);
|
||||
|
||||
const loyaltyTransactions = loyalty?.accountId
|
||||
? await getLoyaltyHistory(prisma, { loyaltyAccountId: loyalty.accountId, limit: 10 })
|
||||
: [];
|
||||
@@ -174,8 +184,23 @@ export default async function StaffCustomerDetailsPage({
|
||||
<p className="text-muted text-xs">
|
||||
{customer.isGuest ? "Guest customer" : "Registered customer"}
|
||||
</p>
|
||||
<NoShowBadge
|
||||
className="mt-1"
|
||||
count={noShowStanding.count}
|
||||
flagged={noShowStanding.flagged}
|
||||
blocked={noShowStanding.blocked}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{noShowStanding.count > 0 && (
|
||||
<div className="border-line space-y-2 border-t pt-4 text-sm">
|
||||
<p className="text-muted">
|
||||
{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."}
|
||||
</p>
|
||||
{canResetNoShows && <ResetNoShowsButton customerId={customer.id} />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-line space-y-1 border-t pt-4 text-sm">
|
||||
<p className="text-muted">{customer.email}</p>
|
||||
|
||||
@@ -12,6 +12,8 @@ import { getUnreadNotificationCount } from "@/services/notifications/notificatio
|
||||
import { clientDirectoryWhere } from "@/services/customer/client-directory";
|
||||
import { matchesCustomerSearch } from "@/services/customer/customer.service";
|
||||
import { getLoyaltySummariesForCustomers } from "@/services/loyalty/loyalty.service";
|
||||
import { NoShowBadge } from "@/features/staff/components/no-show-badge";
|
||||
import { countNoShows, evaluateStanding, getNoShowThresholds } from "@/services/customer/no-show.service";
|
||||
import { InlineNotificationBell } from "@/features/notifications/components/notification-bell";
|
||||
import { cn } from "@/lib/utils/cn";
|
||||
|
||||
@@ -49,6 +51,7 @@ export default async function StaffClientsPage({
|
||||
lastName: true,
|
||||
email: true,
|
||||
phone: true,
|
||||
noShowResetAt: true,
|
||||
appointments: {
|
||||
where: { businessId: context.businessId },
|
||||
orderBy: { startAt: "desc" },
|
||||
@@ -57,6 +60,8 @@ export default async function StaffClientsPage({
|
||||
},
|
||||
});
|
||||
|
||||
const noShowThresholds = await getNoShowThresholds(prisma, context.businessId);
|
||||
|
||||
const loyaltySummaries = await getLoyaltySummariesForCustomers(prisma, {
|
||||
businessId: context.businessId,
|
||||
customerIds: customers.map((c) => c.id),
|
||||
@@ -166,6 +171,10 @@ export default async function StaffClientsPage({
|
||||
(a) => UPCOMING_STATUSES.includes(a.status) && a.startAt > now,
|
||||
);
|
||||
const loyalty = loyaltySummaries.get(customer.id);
|
||||
const noShow = evaluateStanding(
|
||||
countNoShows(customer.appointments, customer.noShowResetAt),
|
||||
noShowThresholds,
|
||||
);
|
||||
|
||||
return (
|
||||
<Link
|
||||
@@ -178,8 +187,11 @@ export default async function StaffClientsPage({
|
||||
{customer.lastName.charAt(0)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-bold">
|
||||
<p className="flex items-center gap-2 text-sm font-bold">
|
||||
<span className="truncate">
|
||||
{customer.firstName} {customer.lastName}
|
||||
</span>
|
||||
<NoShowBadge count={noShow.count} flagged={noShow.flagged} blocked={noShow.blocked} />
|
||||
</p>
|
||||
<p className="text-muted truncate text-xs">
|
||||
{customer.phone ?? customer.email}
|
||||
|
||||
@@ -24,6 +24,7 @@ import { RemindersLink } from "@/features/staff/components/reminders-link";
|
||||
import type { CalendarAppointment, CalendarBlock, CalendarColumn } from "@/features/staff/components/day-calendar";
|
||||
import { ViewSwitch } from "@/features/staff/components/view-switch";
|
||||
import { BlockTimeButton } from "@/features/staff/components/block-time-button";
|
||||
import { getNoShowStandings } from "@/services/customer/no-show.service";
|
||||
import { InlineNotificationBell } from "@/features/notifications/components/notification-bell";
|
||||
import type { AppointmentStatus } from "@/types/domain";
|
||||
|
||||
@@ -87,6 +88,11 @@ export default async function StaffCalendarPage({
|
||||
customerIds: [...new Set(appointments.map((a) => a.customerId))],
|
||||
});
|
||||
|
||||
const noShowStandings = await getNoShowStandings(prisma, {
|
||||
businessId: staff.businessId,
|
||||
customerIds: appointments.map((a) => a.customerId),
|
||||
});
|
||||
|
||||
const workingWindow =
|
||||
workingHours &&
|
||||
!workingHours.isOff &&
|
||||
@@ -123,6 +129,7 @@ export default async function StaffCalendarPage({
|
||||
status: STATUS_MAP[a.status] ?? "confirmed",
|
||||
hasNotes: !!a.notes,
|
||||
hasAvailableReward: rewardFlags.get(a.customerId) ?? false,
|
||||
noShowFlagged: noShowStandings.get(a.customerId)?.flagged ?? false,
|
||||
}));
|
||||
|
||||
const currentTimeMinutes =
|
||||
@@ -180,7 +187,7 @@ export default async function StaffCalendarPage({
|
||||
</div>
|
||||
|
||||
<div className="mt-3 px-6">
|
||||
<RemindersLink businessId={staff.businessId} timezone={staff.business.timezone} staffId={staff.id} />
|
||||
<RemindersLink businessId={staff.businessId} staffId={staff.id} />
|
||||
</div>
|
||||
|
||||
<div className="mt-4 lg:flex lg:items-start lg:gap-6 lg:px-6">
|
||||
@@ -213,6 +220,7 @@ export default async function StaffCalendarPage({
|
||||
formattedTime={formatRange(a.startMinutes, a.endMinutes)}
|
||||
hasNotes={a.hasNotes}
|
||||
hasAvailableReward={a.hasAvailableReward}
|
||||
noShowFlagged={a.noShowFlagged}
|
||||
actions={
|
||||
<Link
|
||||
href={`/staff/appointments/${a.id}`}
|
||||
|
||||
@@ -10,8 +10,12 @@ import { hasPermission } from "@/lib/auth/permissions";
|
||||
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
|
||||
import { getBusinessCountry } from "@/lib/business/get-business-country";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { addDays, todayIn } from "@/lib/time/business-time";
|
||||
import { listReminderCandidates } from "@/services/scheduling/whatsapp-reminders.service";
|
||||
import { addDays, calendarDateOf, todayIn } from "@/lib/time/business-time";
|
||||
import {
|
||||
getWhatsAppLeadHours,
|
||||
listDueReminderCandidates,
|
||||
listReminderCandidates,
|
||||
} from "@/services/scheduling/whatsapp-reminders.service";
|
||||
import { DateStrip } from "@/features/staff/components/date-strip";
|
||||
import { WhatsAppSendLink } from "@/features/staff/components/whatsapp-send-link";
|
||||
import { reminderWhatsAppUrl } from "@/features/staff/lib/whatsapp-reminder-link";
|
||||
@@ -29,29 +33,46 @@ export default async function WhatsAppRemindersPage({
|
||||
|
||||
const timezone = context.businessTimezone;
|
||||
const { date: dateParam } = await searchParams;
|
||||
// Tomorrow by default — the day this exists to prepare for.
|
||||
const date = dateParam && /^\d{4}-\d{2}-\d{2}$/.test(dateParam) ? dateParam : addDays(todayIn(timezone), 1);
|
||||
// Default view is "due now": what should be sent right away, soonest first. A date in the URL
|
||||
// switches to browsing one calendar day ahead.
|
||||
const browsingDate = dateParam && /^\d{4}-\d{2}-\d{2}$/.test(dateParam) ? dateParam : null;
|
||||
const dueNow = browsingDate === null;
|
||||
const date = browsingDate ?? addDays(todayIn(timezone), 1);
|
||||
const leadHours = await getWhatsAppLeadHours(prisma, context.businessId);
|
||||
|
||||
// A barber works their own list; an owner (anyone who can see all
|
||||
// appointments) works the whole shop's, with the barber named on each row.
|
||||
const canSeeEveryone = await hasPermission(context.userId, Permission.VIEW_ALL_STAFF_APPOINTMENTS);
|
||||
const staffFilter = canSeeEveryone ? undefined : (context.staffId ?? undefined);
|
||||
const [appointments, country] = await Promise.all([
|
||||
canSeeEveryone || context.staffId
|
||||
? listReminderCandidates(prisma, {
|
||||
? dueNow
|
||||
? listDueReminderCandidates(prisma, { businessId: context.businessId, leadHours, staffId: staffFilter })
|
||||
: listReminderCandidates(prisma, {
|
||||
businessId: context.businessId,
|
||||
date,
|
||||
businessTimezone: timezone,
|
||||
staffId: canSeeEveryone ? undefined : (context.staffId ?? undefined),
|
||||
staffId: staffFilter,
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
getBusinessCountry(context.businessId),
|
||||
]);
|
||||
|
||||
const today = todayIn(timezone);
|
||||
const tomorrow = addDays(today, 1);
|
||||
const rows = appointments.map((appointment) => {
|
||||
const startDate = calendarDateOf(appointment.startAt, timezone);
|
||||
const dayLabel =
|
||||
startDate === today
|
||||
? "Today"
|
||||
: startDate === tomorrow
|
||||
? "Tomorrow"
|
||||
: appointment.startAt.toLocaleDateString("en-GB", { weekday: "short", day: "numeric", month: "short", timeZone: timezone });
|
||||
const serviceNames = appointment.services.map((s) => s.service.name).join(" + ");
|
||||
const staffName = appointment.staff.user.name ?? "your barber";
|
||||
return {
|
||||
id: appointment.id,
|
||||
dayLabel,
|
||||
time: appointment.startAt.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit", timeZone: timezone }),
|
||||
customerName: `${appointment.customer.firstName} ${appointment.customer.lastName}`.trim(),
|
||||
customerId: appointment.customerId,
|
||||
@@ -76,12 +97,33 @@ export default async function WhatsAppRemindersPage({
|
||||
return (
|
||||
<div className="pb-12">
|
||||
<TopNav title="WhatsApp Reminders" onBack sticky className="bg-stone" />
|
||||
<DateStrip selectedDate={date} businessTimezone={timezone} basePath="/staff/reminders" />
|
||||
{!dueNow && <DateStrip selectedDate={date} businessTimezone={timezone} basePath="/staff/reminders" />}
|
||||
|
||||
<PageContainer size="narrow" className="space-y-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
{dueNow ? (
|
||||
<p className="text-muted text-sm">
|
||||
Due now: starting within the next <strong className="text-ink">{leadHours} hours</strong>
|
||||
</p>
|
||||
) : (
|
||||
<Link href="/staff/reminders" className="text-sm font-bold underline">
|
||||
← Due now
|
||||
</Link>
|
||||
)}
|
||||
{dueNow && (
|
||||
<Link
|
||||
href={`/staff/reminders?date=${addDays(todayIn(timezone), 1)}`}
|
||||
className="text-muted shrink-0 text-xs font-bold underline"
|
||||
>
|
||||
Browse by day
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{rows.length > 0 && (
|
||||
<p className="text-muted text-sm">
|
||||
<strong className="text-ink">{toSend}</strong> to send · {sentCount} sent
|
||||
<strong className="text-ink">{toSend}</strong> to send
|
||||
{!dueNow && ` · ${sentCount} sent`}
|
||||
{noPhoneCount > 0 && ` · ${noPhoneCount} with no usable phone number`}
|
||||
</p>
|
||||
)}
|
||||
@@ -89,14 +131,30 @@ export default async function WhatsAppRemindersPage({
|
||||
{rows.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={MessageCircle}
|
||||
title="No confirmed appointments this day"
|
||||
description="Confirmed bookings show up here, ready to remind."
|
||||
title={dueNow ? "You're all caught up" : "No confirmed appointments this day"}
|
||||
description={
|
||||
dueNow
|
||||
? `Nothing is due to remind right now. Reminders appear here ${leadHours} hours before each appointment.`
|
||||
: "Confirmed bookings show up here, ready to remind."
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<ul className="space-y-3">
|
||||
{rows.map((row) => (
|
||||
<li key={row.id} className="border-line flex items-center gap-4 rounded-md border bg-white p-4">
|
||||
<p className="w-14 shrink-0 text-base font-bold tabular-nums">{row.time}</p>
|
||||
{rows.map((row, index) => (
|
||||
<li
|
||||
key={row.id}
|
||||
className={`flex items-center gap-4 rounded-md border bg-white p-4 ${
|
||||
dueNow && index === 0 ? "border-accent ring-accent/30 ring-2" : "border-line"
|
||||
}`}
|
||||
>
|
||||
<div className="w-20 shrink-0">
|
||||
<p className="text-base font-bold tabular-nums">{row.time}</p>
|
||||
{dueNow && (
|
||||
<p className="text-muted text-[10px] font-bold tracking-widest uppercase">
|
||||
{index === 0 ? `Next · ${row.dayLabel}` : row.dayLabel}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<Link href={`/staff/clients/${row.customerId}`} className="block truncate text-sm font-bold">
|
||||
{row.customerName}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
];
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<string, unknown> = {};
|
||||
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,
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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 && (
|
||||
<UserX className="text-warning-foreground h-3.5 w-3.5 shrink-0" aria-label="Has missed appointments" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-muted text-xs">
|
||||
{personLabel} {person.name}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<StepActionR
|
||||
fieldErrors: guestParsed.error.flatten().fieldErrors,
|
||||
};
|
||||
}
|
||||
// Same rule createBooking enforces at the final commit — told here so the customer isn't
|
||||
// sent through the review step only to be refused at the end.
|
||||
const guestStanding = await getNoShowStanding(prisma, {
|
||||
businessId: parsed.data.businessId,
|
||||
email: guestParsed.data.email,
|
||||
phone: guestParsed.data.phone,
|
||||
});
|
||||
if (guestStanding.blocked) return { success: false, error: getFriendlyMessage("CUSTOMER_BOOKING_BLOCKED") };
|
||||
await writeDraft({
|
||||
...draft,
|
||||
guest: guestParsed.data,
|
||||
@@ -177,6 +186,11 @@ export async function saveDetailsStepAction(input: unknown): Promise<StepActionR
|
||||
policyAcknowledged: true,
|
||||
});
|
||||
} else {
|
||||
const profile = await prisma.customerProfile.findUnique({ where: { userId: session.user.id }, select: { id: true } });
|
||||
if (profile) {
|
||||
const standing = await getNoShowStanding(prisma, { businessId: parsed.data.businessId, customerId: profile.id });
|
||||
if (standing.blocked) return { success: false, error: getFriendlyMessage("CUSTOMER_BOOKING_BLOCKED") };
|
||||
}
|
||||
await writeDraft({
|
||||
...draft,
|
||||
guest: undefined,
|
||||
|
||||
@@ -13,6 +13,8 @@ const FRIENDLY_MESSAGES: Record<BookingValidationErrorCode, string> = {
|
||||
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.",
|
||||
|
||||
@@ -72,6 +72,10 @@ export async function listAvailableCalendarsAction(): Promise<ListCalendarsResul
|
||||
if (!connection || connection.revokedAt) {
|
||||
return { success: false, error: "Connect Google Calendar first." };
|
||||
}
|
||||
// A connection made with the narrow scope can only reach the calendar this app created.
|
||||
if (connection.appCreatedCalendar) {
|
||||
return { success: false, error: "Events are added to the calendar NextChair created in your Google account." };
|
||||
}
|
||||
|
||||
const provider = new GoogleCalendarProvider();
|
||||
try {
|
||||
|
||||
@@ -22,6 +22,11 @@ export interface ConnectionState {
|
||||
* button, because syncing has silently stopped and nothing else says so.
|
||||
*/
|
||||
needsReconnect?: boolean;
|
||||
/**
|
||||
* Connected with the narrow scope: events go to a calendar this app created, and there is
|
||||
* nothing to choose (the scope can't list the person's calendars).
|
||||
*/
|
||||
appCreatedCalendar?: boolean;
|
||||
}
|
||||
|
||||
export function CalendarConnectionPanel({ initial, configured }: { initial: ConnectionState; configured: boolean }) {
|
||||
@@ -86,8 +91,9 @@ export function CalendarConnectionPanel({ initial, configured }: { initial: Conn
|
||||
</div>
|
||||
)}
|
||||
<p className="text-muted mb-4 text-xs">
|
||||
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.
|
||||
</p>
|
||||
{message && <p className="text-danger mb-3 text-xs font-medium">{message}</p>}
|
||||
{configured ? (
|
||||
@@ -108,7 +114,11 @@ export function CalendarConnectionPanel({ initial, configured }: { initial: Conn
|
||||
<p className="mb-1 text-sm font-bold">Google Calendar</p>
|
||||
<div className="text-muted mb-4 space-y-1 text-xs">
|
||||
<p>Connected as {state.accountEmail}</p>
|
||||
<p>Syncing to “{state.selectedCalendarName}”</p>
|
||||
<p>
|
||||
{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."}
|
||||
</p>
|
||||
{state.lastSyncAt && <p>Last synced {new Date(state.lastSyncAt).toLocaleString()}</p>}
|
||||
{state.lastSyncError && <p className="text-danger">Last sync error: {state.lastSyncError}</p>}
|
||||
</div>
|
||||
@@ -135,9 +145,11 @@ export function CalendarConnectionPanel({ initial, configured }: { initial: Conn
|
||||
<Button size="sm" isLoading={isPending} onClick={sync}>
|
||||
Sync Now
|
||||
</Button>
|
||||
{!state.appCreatedCalendar && (
|
||||
<Button size="sm" variant="secondary" isLoading={isPending} onClick={loadCalendars}>
|
||||
Change Calendar
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" variant="destructive" isLoading={isPending} onClick={disconnect}>
|
||||
Disconnect
|
||||
</Button>
|
||||
|
||||
@@ -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,10 +184,14 @@ export async function ownerRescheduleAppointmentAction(params: {
|
||||
entityId: params.appointmentId,
|
||||
metadata: { fromStaffId: previousStaffId, toStaffId: params.staffId },
|
||||
});
|
||||
// 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);
|
||||
return { success: true };
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -126,6 +126,33 @@ export function BookingRulesForm({ initial }: { initial: BookingRuleInput }) {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">No-shows</h3>
|
||||
<div className="rounded-md border-line space-y-4 border bg-white p-6">
|
||||
<p className="text-muted text-xs leading-relaxed">
|
||||
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.
|
||||
</p>
|
||||
<Input
|
||||
label="Show a warning symbol at (no-shows)"
|
||||
hint="e.g. 2 — the symbol appears on their calendar entries, appointments and client page."
|
||||
type="number"
|
||||
min={1}
|
||||
value={form.noShowFlagThreshold ?? ""}
|
||||
onChange={(e) => set("noShowFlagThreshold", e.target.value ? Number(e.target.value) : null)}
|
||||
/>
|
||||
<Input
|
||||
label="Block online booking at (no-shows)"
|
||||
hint="e.g. 4 — they're asked to contact you instead. Must be the same as or higher than the warning number."
|
||||
type="number"
|
||||
min={1}
|
||||
value={form.noShowBlockThreshold ?? ""}
|
||||
onChange={(e) => set("noShowBlockThreshold", e.target.value ? Number(e.target.value) : null)}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Customer Options</h3>
|
||||
<div className="rounded-md border-line space-y-5 border bg-white p-6">
|
||||
|
||||
@@ -39,6 +39,7 @@ export interface BusinessDetailsInitial {
|
||||
cancellationPolicy: string;
|
||||
legalNameDisplay: LegalNameDisplay;
|
||||
legalContactEmail: LegalContactEmail;
|
||||
whatsappReminderLeadHours: number;
|
||||
}
|
||||
|
||||
export function BusinessDetailsForm({ initial }: { initial: BusinessDetailsInitial }) {
|
||||
@@ -92,6 +93,7 @@ export function BusinessDetailsForm({ initial }: { initial: BusinessDetailsIniti
|
||||
cancellationPolicy: form.cancellationPolicy || undefined,
|
||||
legalNameDisplay: form.legalNameDisplay,
|
||||
legalContactEmail: form.legalContactEmail,
|
||||
whatsappReminderLeadHours: form.whatsappReminderLeadHours,
|
||||
});
|
||||
if (!settingsResult.success) {
|
||||
setError(settingsResult.error ?? "Couldn't save business policies.");
|
||||
@@ -213,6 +215,22 @@ export function BusinessDetailsForm({ initial }: { initial: BusinessDetailsIniti
|
||||
value={form.cancellationPolicy}
|
||||
onChange={(e) => set("cancellationPolicy", e.target.value)}
|
||||
/>
|
||||
<div className="border-line space-y-3 border-t pt-4">
|
||||
<Select
|
||||
label="WhatsApp reminders are due"
|
||||
value={String(form.whatsappReminderLeadHours)}
|
||||
onChange={(e) => set("whatsappReminderLeadHours", Number(e.target.value))}
|
||||
>
|
||||
{[12, 24, 36, 48, 72].map((hours) => (
|
||||
<option key={hours} value={hours}>
|
||||
{hours} hours before an appointment
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<p className="text-muted ml-1 text-xs leading-relaxed">
|
||||
Staff see these on the Reminders list and get a push when some are ready to send.
|
||||
</p>
|
||||
</div>
|
||||
<div className="border-line space-y-4 border-t pt-4">
|
||||
<p className="text-muted ml-1 text-xs leading-relaxed">
|
||||
Your site's{" "}
|
||||
|
||||
@@ -190,18 +190,6 @@ export function LoyaltyConfigForm({
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Rules & Expiry</h3>
|
||||
<div className="rounded-md border-line space-y-5 border bg-white p-6">
|
||||
<Switch
|
||||
label="Apply reward automatically"
|
||||
description="Redemption is always staff-initiated this phase — this setting is saved but has no automatic effect yet."
|
||||
checked={config.autoApplyReward}
|
||||
onCheckedChange={(v) => set("autoApplyReward", v)}
|
||||
/>
|
||||
<Switch
|
||||
label="Require staff approval to redeem"
|
||||
description="Redemption already requires a permitted staff member — this is informational only this phase."
|
||||
checked={config.requiresStaffApproval}
|
||||
onCheckedChange={(v) => set("requiresStaffApproval", v)}
|
||||
/>
|
||||
<Switch label="Earn stamps on cancelled appointments" checked={config.earnOnCancelled} onCheckedChange={(v) => set("earnOnCancelled", v)} />
|
||||
<Switch label="Earn stamps on no-shows" checked={config.earnOnNoShow} onCheckedChange={(v) => set("earnOnNoShow", v)} />
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
|
||||
@@ -155,13 +155,14 @@ export async function rescheduleStaffAppointmentAction(params: {
|
||||
staffId?: string;
|
||||
startAt: string;
|
||||
}): Promise<StaffAppointmentActionResult> {
|
||||
const { appointment, error } = await loadOwnAppointment(params.appointmentId);
|
||||
if (!appointment) return { success: false, error: error ?? "Not found." };
|
||||
const { staff, appointment, error } = await loadOwnAppointment(params.appointmentId);
|
||||
if (!appointment || !staff) return { success: false, error: error ?? "Not found." };
|
||||
|
||||
const result = await rescheduleBooking(prisma, {
|
||||
appointmentId: params.appointmentId,
|
||||
staffId: params.staffId,
|
||||
startAt: new Date(params.startAt),
|
||||
actorUserId: staff.user.id,
|
||||
});
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't reschedule this appointment." };
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { Permission } from "@/generated/prisma/enums";
|
||||
import { hasPermission } from "@/lib/auth/permissions";
|
||||
import { writeAuditLog } from "@/lib/audit/write-audit-log";
|
||||
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
|
||||
import { clientDirectoryWhere } from "@/services/customer/client-directory";
|
||||
import { resetNoShows } from "@/services/customer/no-show.service";
|
||||
|
||||
export interface NoShowActionResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* "Forgive" a customer's no-shows: they stop counting towards the flag and block
|
||||
* thresholds from now on (history is untouched). Needs MANAGE_ALL_APPOINTMENTS —
|
||||
* it lifts a block the owner configured, so it isn't open to every barber.
|
||||
*/
|
||||
export async function resetNoShowsAction(customerId: string): Promise<NoShowActionResult> {
|
||||
const context = await getActingBusinessContext();
|
||||
if (!context) return { success: false, error: "You must be signed in as staff." };
|
||||
if (!(await hasPermission(context.userId, Permission.MANAGE_ALL_APPOINTMENTS))) {
|
||||
return { success: false, error: "You don't have permission to reset no-shows." };
|
||||
}
|
||||
|
||||
const inDirectory =
|
||||
(await prisma.customerProfile.count({
|
||||
where: { AND: [{ id: customerId }, clientDirectoryWhere(context.businessId)] },
|
||||
})) > 0;
|
||||
if (!inDirectory) return { success: false, error: "Client not found." };
|
||||
|
||||
await resetNoShows(prisma, { customerId });
|
||||
await writeAuditLog(prisma, {
|
||||
actorUserId: context.userId,
|
||||
action: "CUSTOMER_NO_SHOWS_RESET",
|
||||
entityType: "CustomerProfile",
|
||||
entityId: customerId,
|
||||
});
|
||||
revalidatePath(`/staff/clients/${customerId}`);
|
||||
revalidatePath("/staff/clients");
|
||||
return { success: true };
|
||||
}
|
||||
@@ -41,6 +41,8 @@ export interface CalendarAppointment {
|
||||
status: AppointmentStatus;
|
||||
hasNotes: boolean;
|
||||
hasAvailableReward: boolean;
|
||||
/** Customer has reached the no-show flag threshold (see no-show.service.ts). */
|
||||
noShowFlagged?: boolean;
|
||||
}
|
||||
|
||||
export interface CalendarBlock {
|
||||
@@ -413,6 +415,9 @@ function EventContent({ event }: { event: CalendarEvent }) {
|
||||
{appointment.hasAvailableReward && (
|
||||
<Gift className="text-accent-loyalty h-2.5 w-2.5 shrink-0" aria-label="Reward available" />
|
||||
)}
|
||||
{appointment.noShowFlagged && (
|
||||
<UserX className="text-warning-foreground h-2.5 w-2.5 shrink-0" aria-label="Has missed appointments" />
|
||||
)}
|
||||
</div>
|
||||
{height >= 44 && (
|
||||
<p className="truncate text-[11px] opacity-80">
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { UserX } from "lucide-react";
|
||||
import { cn } from "@/lib/utils/cn";
|
||||
|
||||
/**
|
||||
* The symbol shown against a customer who has reached the no-show flag (amber) or
|
||||
* block (red) threshold — see no-show.service.ts. Renders nothing for a customer
|
||||
* who is neither, so callers can drop it in unconditionally.
|
||||
*/
|
||||
export function NoShowBadge({
|
||||
count,
|
||||
flagged,
|
||||
blocked,
|
||||
className,
|
||||
}: {
|
||||
count: number;
|
||||
flagged: boolean;
|
||||
blocked: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
if (!flagged && !blocked) return null;
|
||||
const label = blocked
|
||||
? `Online booking blocked after ${count} no-show${count === 1 ? "" : "s"}`
|
||||
: `${count} no-show${count === 1 ? "" : "s"} since their last visit`;
|
||||
return (
|
||||
<span
|
||||
title={label}
|
||||
aria-label={label}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-bold tracking-wide whitespace-nowrap",
|
||||
blocked ? "bg-danger/10 text-danger" : "bg-warning/15 text-warning-foreground",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<UserX className="h-3 w-3" aria-hidden="true" />
|
||||
{blocked ? `Blocked · ${count}` : count}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -3,36 +3,28 @@ import Link from "next/link";
|
||||
import { getBusinessCountry } from "@/lib/business/get-business-country";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { toWhatsAppNumber } from "@/lib/messaging/whatsapp";
|
||||
import { addDays, todayIn } from "@/lib/time/business-time";
|
||||
import { listReminderCandidates } from "@/services/scheduling/whatsapp-reminders.service";
|
||||
import { getWhatsAppLeadHours, listDueReminderCandidates } from "@/services/scheduling/whatsapp-reminders.service";
|
||||
|
||||
/**
|
||||
* Entry point to the reminders list, shown on the calendar pages with the
|
||||
* number still to send for tomorrow — so the job announces itself instead of
|
||||
* relying on someone remembering to look.
|
||||
* number due to send right now (starting within the business's reminder
|
||||
* lead time) — so the job announces itself instead of relying on someone
|
||||
* remembering to look.
|
||||
*/
|
||||
export async function RemindersLink({
|
||||
businessId,
|
||||
timezone,
|
||||
staffId,
|
||||
}: {
|
||||
businessId: string;
|
||||
timezone: string;
|
||||
/** Narrow to one barber's bookings; omit for the whole shop. */
|
||||
staffId?: string;
|
||||
}) {
|
||||
const leadHours = await getWhatsAppLeadHours(prisma, businessId);
|
||||
const [appointments, country] = await Promise.all([
|
||||
listReminderCandidates(prisma, {
|
||||
businessId,
|
||||
date: addDays(todayIn(timezone), 1),
|
||||
businessTimezone: timezone,
|
||||
staffId,
|
||||
}),
|
||||
listDueReminderCandidates(prisma, { businessId, leadHours, staffId }),
|
||||
getBusinessCountry(businessId),
|
||||
]);
|
||||
const toSend = appointments.filter(
|
||||
(a) => !a.whatsappReminderSentAt && toWhatsAppNumber(a.customer.phone, country),
|
||||
).length;
|
||||
const toSend = appointments.filter((a) => toWhatsAppNumber(a.customer.phone, country)).length;
|
||||
|
||||
return (
|
||||
<Link
|
||||
@@ -42,7 +34,7 @@ export async function RemindersLink({
|
||||
<MessageCircle className="text-accent h-4 w-4 shrink-0" aria-hidden="true" />
|
||||
<span className="flex-1 text-xs font-bold">
|
||||
WhatsApp reminders
|
||||
{toSend > 0 ? <span className="text-accent-foreground"> · {toSend} to send tomorrow</span> : null}
|
||||
{toSend > 0 ? <span className="text-accent-foreground"> · {toSend} to send now</span> : null}
|
||||
</span>
|
||||
<ChevronRight className="text-muted h-4 w-4 shrink-0" aria-hidden="true" />
|
||||
</Link>
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useTransition } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { resetNoShowsAction } from "../actions/no-show";
|
||||
|
||||
/** Two taps on purpose: it lifts a block the owner set, so it shouldn't happen by accident. */
|
||||
export function ResetNoShowsButton({ customerId }: { customerId: string }) {
|
||||
const router = useRouter();
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const reset = () => {
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
const result = await resetNoShowsAction(customerId);
|
||||
if (!result.success) {
|
||||
setError(result.error ?? "Couldn't reset no-shows.");
|
||||
return;
|
||||
}
|
||||
setConfirming(false);
|
||||
router.refresh();
|
||||
});
|
||||
};
|
||||
|
||||
if (!confirming) {
|
||||
return (
|
||||
<Button variant="secondary" size="sm" onClick={() => setConfirming(true)}>
|
||||
Reset no-shows
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<p className="text-muted text-xs">
|
||||
Their no-shows stop counting from now. Their history is kept.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setConfirming(false)}
|
||||
disabled={isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="sm" onClick={reset} isLoading={isPending}>
|
||||
Yes, reset
|
||||
</Button>
|
||||
</div>
|
||||
{error && (
|
||||
<p role="alert" className="text-danger text-xs">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { Instrumentation } from "next";
|
||||
|
||||
/**
|
||||
* Server-side error reporting: every unhandled error in a route, page or
|
||||
* server action reaches this hook with the digest the visitor sees on the
|
||||
* error page. See src/lib/monitoring/report-error.ts for what is logged and
|
||||
* emailed to the operator (OPERATOR_ALERT_EMAIL) and what never is.
|
||||
*/
|
||||
export const onRequestError: Instrumentation.onRequestError = async (error, request, context) => {
|
||||
if (process.env.NEXT_RUNTIME !== "nodejs") return;
|
||||
try {
|
||||
const { reportServerError } = await import("@/lib/monitoring/report-error");
|
||||
await reportServerError(
|
||||
error,
|
||||
{ path: request.path, method: request.method },
|
||||
{ routePath: context.routePath },
|
||||
);
|
||||
} catch {
|
||||
// Reporting must never be the thing that breaks a request.
|
||||
}
|
||||
};
|
||||
@@ -2,6 +2,7 @@ import { randomInt } from "node:crypto";
|
||||
import bcrypt from "bcryptjs";
|
||||
import type { Prisma, PrismaClient } from "@/generated/prisma/client";
|
||||
import { checkRateLimit } from "@/lib/security/rate-limit";
|
||||
import { resolveSenderName } from "@/services/notifications/sender-identity";
|
||||
// Type-only — the barrel this comes from has a `server-only` guard that
|
||||
// throws under Vitest's plain Node runtime (see reminder-worker.ts's own
|
||||
// established pattern: never call getEmailProvider() from inside a
|
||||
@@ -152,12 +153,12 @@ export async function requestOtp(db: Db, options: RequestOtpOptions): Promise<Re
|
||||
});
|
||||
}
|
||||
|
||||
const rendered = renderOtpEmail({ senderName: options.senderName ?? "NextChair", code, expiresInMinutes: 10 });
|
||||
const rendered = renderOtpEmail({ senderName: resolveSenderName(options.senderName), code, expiresInMinutes: 10 });
|
||||
// Never logged — the code must not reach application logs, only this
|
||||
// one email (see the brief's "do not include the OTP in logs").
|
||||
await options.emailProvider.sendEmail({
|
||||
to: { email },
|
||||
sender: { name: options.senderName ?? "NextChair" },
|
||||
sender: { name: resolveSenderName(options.senderName) },
|
||||
subject: rendered.subject,
|
||||
html: rendered.html,
|
||||
text: rendered.text,
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createAlertLimiter } from "../alert-limiter";
|
||||
|
||||
describe("createAlertLimiter", () => {
|
||||
const hour = 3_600_000;
|
||||
|
||||
it("sends the first alert and holds the same one back for the cooldown", () => {
|
||||
const limiter = createAlertLimiter({ cooldownMs: hour, maxPerDay: 20 });
|
||||
expect(limiter.shouldSend("boom", 0)).toBe(true);
|
||||
expect(limiter.shouldSend("boom", 30 * 60_000)).toBe(false);
|
||||
expect(limiter.shouldSend("boom", hour)).toBe(true);
|
||||
});
|
||||
|
||||
it("treats different errors independently", () => {
|
||||
const limiter = createAlertLimiter({ cooldownMs: hour, maxPerDay: 20 });
|
||||
expect(limiter.shouldSend("a", 0)).toBe(true);
|
||||
expect(limiter.shouldSend("b", 1)).toBe(true);
|
||||
});
|
||||
|
||||
it("caps how many alerts one day can produce, then starts over the next day", () => {
|
||||
const limiter = createAlertLimiter({ cooldownMs: 0, maxPerDay: 3 });
|
||||
expect([1, 2, 3, 4, 5].map((n) => limiter.shouldSend(`e${n}`, n))).toEqual([
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
]);
|
||||
expect(limiter.shouldSend("e6", 25 * hour)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("server-only", () => ({}));
|
||||
vi.mock("@/services/notifications/providers", () => ({ getEmailProvider: vi.fn() }));
|
||||
|
||||
import { buildErrorAlert, isIgnorableError } from "../report-error";
|
||||
|
||||
describe("isIgnorableError", () => {
|
||||
it("ignores redirects, not-found and dropped connections, but not real failures", () => {
|
||||
expect(isIgnorableError(new Error("NEXT_REDIRECT"))).toBe(true);
|
||||
expect(isIgnorableError(new Error("NEXT_NOT_FOUND"))).toBe(true);
|
||||
expect(isIgnorableError(new Error("socket hang up"))).toBe(true);
|
||||
expect(isIgnorableError(new Error("Cannot read properties of undefined"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildErrorAlert", () => {
|
||||
const request = { path: "/owner/calendar?token=secret-value&date=2026-10-20", method: "GET" };
|
||||
|
||||
it("names the business and route and never includes the query string", () => {
|
||||
const alert = buildErrorAlert({
|
||||
business: "Blake Heritage",
|
||||
error: new Error("db exploded"),
|
||||
request,
|
||||
});
|
||||
expect(alert.subject).toBe("[Blake Heritage] Server error on /owner/calendar");
|
||||
expect(alert.text).toContain("db exploded");
|
||||
expect(alert.text).not.toContain("secret-value");
|
||||
});
|
||||
|
||||
it("dedupes on the digest when there is one, otherwise on route and message", () => {
|
||||
const withDigest = Object.assign(new Error("x"), { digest: "abc123" });
|
||||
expect(buildErrorAlert({ business: "B", error: withDigest, request }).key).toBe("abc123");
|
||||
expect(buildErrorAlert({ business: "B", error: new Error("x"), request }).key).toBe(
|
||||
"/owner/calendar:x",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Keeps operator alert emails useful: the same problem is reported once per
|
||||
* cooldown (not once per request while it's broken), and a runaway failure
|
||||
* can't send more than a fixed number of emails a day. In-memory and
|
||||
* per-process, like the app's other rate limiter — a restart resets it, which
|
||||
* at worst re-sends one alert.
|
||||
*/
|
||||
|
||||
export interface AlertLimiterOptions {
|
||||
cooldownMs: number;
|
||||
maxPerDay: number;
|
||||
}
|
||||
|
||||
export function createAlertLimiter(options: AlertLimiterOptions) {
|
||||
const lastSent = new Map<string, number>();
|
||||
let dayStart = 0;
|
||||
let sentToday = 0;
|
||||
|
||||
return {
|
||||
/** True when an alert with this key should go out now — and records it as sent. */
|
||||
shouldSend(key: string, now: number = Date.now()): boolean {
|
||||
if (now - dayStart >= 24 * 3_600_000) {
|
||||
dayStart = now;
|
||||
sentToday = 0;
|
||||
}
|
||||
if (sentToday >= options.maxPerDay) return false;
|
||||
const previous = lastSent.get(key);
|
||||
if (previous !== undefined && now - previous < options.cooldownMs) return false;
|
||||
|
||||
lastSent.set(key, now);
|
||||
sentToday += 1;
|
||||
// Bound memory: drop entries that have long since stopped mattering.
|
||||
if (lastSent.size > 500) {
|
||||
for (const [k, t] of lastSent) if (now - t >= options.cooldownMs) lastSent.delete(k);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import "server-only";
|
||||
import { createAlertLimiter } from "./alert-limiter";
|
||||
import { getEmailProvider } from "@/services/notifications/providers";
|
||||
|
||||
/**
|
||||
* Server-side error reporting for Next's `onRequestError` hook (see
|
||||
* src/instrumentation.ts). Every error is logged with its digest — the id
|
||||
* the browser's error page can show — and, when OPERATOR_ALERT_EMAIL is set
|
||||
* for this deployment, emailed to the operator, de-duplicated so one broken
|
||||
* page produces one email an hour, not one per visitor.
|
||||
*
|
||||
* The email carries only: which business, the route, the method, the error
|
||||
* message and the first lines of its stack. Never request headers, cookies,
|
||||
* bodies or the query string, which can hold tokens and personal data.
|
||||
*/
|
||||
|
||||
const limiter = createAlertLimiter({ cooldownMs: 60 * 60_000, maxPerDay: 20 });
|
||||
|
||||
export interface ReportedRequest {
|
||||
path: string;
|
||||
method: string;
|
||||
}
|
||||
|
||||
/** Errors that are control flow or a visitor going away, not something to wake anyone for. */
|
||||
export function isIgnorableError(error: unknown): boolean {
|
||||
const text = error instanceof Error ? `${error.name} ${error.message}` : String(error);
|
||||
return /NEXT_REDIRECT|NEXT_NOT_FOUND|NEXT_HTTP_ERROR_FALLBACK|aborted|ECONNRESET|socket hang up/i.test(
|
||||
text,
|
||||
);
|
||||
}
|
||||
|
||||
export function buildErrorAlert(params: {
|
||||
business: string;
|
||||
error: unknown;
|
||||
request: ReportedRequest;
|
||||
routePath?: string;
|
||||
}): { subject: string; text: string; key: string } {
|
||||
const error = params.error instanceof Error ? params.error : new Error(String(params.error));
|
||||
const digestValue = (error as { digest?: unknown }).digest;
|
||||
const digest = typeof digestValue === "string" ? digestValue : undefined;
|
||||
const route = params.routePath ?? params.request.path.split("?")[0] ?? "";
|
||||
const stack = (error.stack ?? "").split("\n").slice(0, 12).join("\n");
|
||||
|
||||
return {
|
||||
subject: `[${params.business}] Server error on ${route}`,
|
||||
key: digest ?? `${route}:${error.message}`.slice(0, 200),
|
||||
text: [
|
||||
`A server error happened on ${params.business}.`,
|
||||
"",
|
||||
`Route: ${route}`,
|
||||
`Method: ${params.request.method}`,
|
||||
`Error: ${error.message}`,
|
||||
...(digest ? [`Digest: ${digest} (also shown to the visitor on the error page)`] : []),
|
||||
"",
|
||||
"Stack (first lines):",
|
||||
stack,
|
||||
"",
|
||||
"You'll get at most one email per hour for the same error.",
|
||||
].join("\n"),
|
||||
};
|
||||
}
|
||||
|
||||
export async function reportServerError(
|
||||
error: unknown,
|
||||
request: ReportedRequest,
|
||||
context: { routePath?: string },
|
||||
): Promise<void> {
|
||||
if (isIgnorableError(error)) return;
|
||||
|
||||
const business = process.env.BUSINESS_NAME?.trim() || process.env.DOMAIN?.trim() || "NextChair";
|
||||
const alert = buildErrorAlert({ business, error, request, routePath: context.routePath });
|
||||
console.error(
|
||||
`[server-error] ${alert.subject} — ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
|
||||
const to = process.env.OPERATOR_ALERT_EMAIL?.trim();
|
||||
if (!to || !limiter.shouldSend(alert.key)) return;
|
||||
|
||||
try {
|
||||
await getEmailProvider().sendEmail({
|
||||
to: { email: to },
|
||||
sender: { name: `${business} alerts` },
|
||||
subject: alert.subject,
|
||||
html: `<pre style="font:13px/1.5 monospace;white-space:pre-wrap;">${alert.text.replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" })[c]!)}</pre>`,
|
||||
text: alert.text,
|
||||
});
|
||||
} catch {
|
||||
// An alert that can't be sent must never turn into a second error.
|
||||
}
|
||||
}
|
||||
@@ -70,6 +70,8 @@ export interface BusinessSettingsInput {
|
||||
legalNameDisplay?: LegalNameDisplay;
|
||||
/** Whose inbox those pages give for legal/privacy requests. */
|
||||
legalContactEmail?: LegalContactEmail;
|
||||
/** Hours ahead the staff WhatsApp reminders queue treats an appointment as due — see whatsapp-reminders.service.ts. */
|
||||
whatsappReminderLeadHours?: number;
|
||||
/** Reviews visibility policy — see src/services/reviews. Applied on top of each Review's own isHidden flag. */
|
||||
reviewsMinRating?: number | null;
|
||||
reviewsExcludeRatingOnly?: boolean;
|
||||
|
||||
@@ -5,7 +5,10 @@ import { CalendarProviderError } from "../types";
|
||||
describe("mapGoogleError", () => {
|
||||
it("treats the token endpoint's invalid_grant as a dead connection, not a bad event", () => {
|
||||
// Google sends HTTP 400 for this — the case that used to read "rejected this event".
|
||||
const error = mapGoogleError(400, { error: "invalid_grant", error_description: "Token has been expired or revoked." });
|
||||
const error = mapGoogleError(400, {
|
||||
error: "invalid_grant",
|
||||
error_description: "Token has been expired or revoked.",
|
||||
});
|
||||
|
||||
expect(error.code).toBe("TOKEN_REVOKED");
|
||||
expect(error.retryable).toBe(false);
|
||||
@@ -25,7 +28,12 @@ describe("mapGoogleError", () => {
|
||||
});
|
||||
|
||||
it("still reports a genuinely bad event as one, now with Google's reason", () => {
|
||||
const error = mapGoogleError(400, { error: { message: "The specified time range is empty.", errors: [{ reason: "timeRangeEmpty" }] } });
|
||||
const error = mapGoogleError(400, {
|
||||
error: {
|
||||
message: "The specified time range is empty.",
|
||||
errors: [{ reason: "timeRangeEmpty" }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(error.code).toBe("INVALID_EVENT");
|
||||
expect(error.retryable).toBe(false);
|
||||
@@ -38,12 +46,21 @@ describe("mapGoogleError", () => {
|
||||
});
|
||||
|
||||
it("keeps the other mappings", () => {
|
||||
expect(mapGoogleError(401, { error: { errors: [{ reason: "authError" }] } }).code).toBe("TOKEN_EXPIRED");
|
||||
expect(mapGoogleError(403, { error: { errors: [{ reason: "insufficientPermissions" }] } }).code).toBe("CALENDAR_NOT_WRITABLE");
|
||||
expect(mapGoogleError(403, { error: { errors: [{ reason: "rateLimitExceeded" }] } }).code).toBe("RATE_LIMITED");
|
||||
expect(mapGoogleError(401, { error: { errors: [{ reason: "authError" }] } }).code).toBe(
|
||||
"TOKEN_EXPIRED",
|
||||
);
|
||||
expect(
|
||||
mapGoogleError(403, { error: { errors: [{ reason: "insufficientPermissions" }] } }).code,
|
||||
).toBe("CALENDAR_NOT_WRITABLE");
|
||||
expect(mapGoogleError(403, { error: { errors: [{ reason: "rateLimitExceeded" }] } }).code).toBe(
|
||||
"RATE_LIMITED",
|
||||
);
|
||||
expect(mapGoogleError(429, undefined).retryable).toBe(true);
|
||||
expect(mapGoogleError(404, undefined).code).toBe("CALENDAR_NOT_FOUND");
|
||||
expect(mapGoogleError(503, undefined)).toMatchObject({ code: "PROVIDER_OUTAGE", retryable: true });
|
||||
expect(mapGoogleError(503, undefined)).toMatchObject({
|
||||
code: "PROVIDER_OUTAGE",
|
||||
retryable: true,
|
||||
});
|
||||
expect(mapGoogleError(418, undefined).code).toBe("UNKNOWN");
|
||||
});
|
||||
|
||||
@@ -52,13 +69,19 @@ describe("mapGoogleError", () => {
|
||||
});
|
||||
|
||||
it("never puts the response body or any token in the user-facing message", () => {
|
||||
const error = mapGoogleError(400, { error: "invalid_grant", error_description: "secret-detail-xyz" });
|
||||
const error = mapGoogleError(400, {
|
||||
error: "invalid_grant",
|
||||
error_description: "secret-detail-xyz",
|
||||
});
|
||||
expect(error.message).not.toContain("secret-detail-xyz");
|
||||
});
|
||||
});
|
||||
|
||||
describe("GoogleCalendarProvider against a failing Google", () => {
|
||||
const original = { id: process.env.GOOGLE_CALENDAR_CLIENT_ID, secret: process.env.GOOGLE_CALENDAR_CLIENT_SECRET };
|
||||
const original = {
|
||||
id: process.env.GOOGLE_CALENDAR_CLIENT_ID,
|
||||
secret: process.env.GOOGLE_CALENDAR_CLIENT_SECRET,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.GOOGLE_CALENDAR_CLIENT_ID = "test-client-id";
|
||||
@@ -72,22 +95,126 @@ describe("GoogleCalendarProvider against a failing Google", () => {
|
||||
});
|
||||
|
||||
const respondWith = (status: number, body: unknown) =>
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify(body), { status })));
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue(new Response(JSON.stringify(body), { status })),
|
||||
);
|
||||
|
||||
it("an expired refresh token surfaces as TOKEN_REVOKED when refreshing", async () => {
|
||||
respondWith(400, { error: "invalid_grant", error_description: "Token has been expired or revoked." });
|
||||
respondWith(400, {
|
||||
error: "invalid_grant",
|
||||
error_description: "Token has been expired or revoked.",
|
||||
});
|
||||
|
||||
const failure = await new GoogleCalendarProvider().refreshAccessToken("old-refresh-token").catch((e: unknown) => e);
|
||||
const failure = await new GoogleCalendarProvider()
|
||||
.refreshAccessToken("old-refresh-token")
|
||||
.catch((e: unknown) => e);
|
||||
|
||||
expect(failure).toBeInstanceOf(CalendarProviderError);
|
||||
expect(failure).toMatchObject({ code: "TOKEN_REVOKED", retryable: false });
|
||||
});
|
||||
|
||||
it("deleting an event Google already removed (410) is not an error", async () => {
|
||||
respondWith(410, { error: { message: "Resource has been deleted", errors: [{ reason: "deleted" }] } });
|
||||
respondWith(410, {
|
||||
error: { message: "Resource has been deleted", errors: [{ reason: "deleted" }] },
|
||||
});
|
||||
|
||||
await expect(
|
||||
new GoogleCalendarProvider().deleteEvent({ accessToken: "t", calendarId: "primary", providerEventId: "gone" }),
|
||||
new GoogleCalendarProvider().deleteEvent({
|
||||
accessToken: "t",
|
||||
calendarId: "primary",
|
||||
providerEventId: "gone",
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("GoogleCalendarProvider scopes and the app-created calendar", () => {
|
||||
const original = {
|
||||
id: process.env.GOOGLE_CALENDAR_CLIENT_ID,
|
||||
secret: process.env.GOOGLE_CALENDAR_CLIENT_SECRET,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.GOOGLE_CALENDAR_CLIENT_ID = "test-client-id";
|
||||
process.env.GOOGLE_CALENDAR_CLIENT_SECRET = "test-client-secret";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
process.env.GOOGLE_CALENDAR_CLIENT_ID = original.id;
|
||||
process.env.GOOGLE_CALENDAR_CLIENT_SECRET = original.secret;
|
||||
});
|
||||
|
||||
it("asks Google for the one narrow calendar scope — never the broad ones that trigger the unverified-app warning", () => {
|
||||
const url = new URL(
|
||||
new GoogleCalendarProvider().buildAuthorizationUrl({
|
||||
redirectUri: "https://x.test/cb",
|
||||
state: "s",
|
||||
}),
|
||||
);
|
||||
const scopes = (url.searchParams.get("scope") ?? "").split(" ");
|
||||
|
||||
expect(scopes).toContain("https://www.googleapis.com/auth/calendar.app.created");
|
||||
expect(scopes.filter((s) => s.includes("/auth/calendar"))).toEqual([
|
||||
"https://www.googleapis.com/auth/calendar.app.created",
|
||||
]);
|
||||
expect(scopes).not.toContain("https://www.googleapis.com/auth/calendar.events");
|
||||
expect(scopes).not.toContain("https://www.googleapis.com/auth/calendar.readonly");
|
||||
});
|
||||
|
||||
it("creates a secondary calendar with the business's name and time zone", async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
new Response(JSON.stringify({ id: "cal-123", summary: "Blake Heritage bookings" }), {
|
||||
status: 200,
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const calendar = await new GoogleCalendarProvider().createCalendar({
|
||||
accessToken: "token",
|
||||
name: "Blake Heritage bookings",
|
||||
timeZone: "Europe/London",
|
||||
});
|
||||
|
||||
expect(calendar).toEqual({
|
||||
id: "cal-123",
|
||||
name: "Blake Heritage bookings",
|
||||
writable: true,
|
||||
primary: false,
|
||||
});
|
||||
const [url, init] = fetchMock.mock.calls[0]!;
|
||||
expect(url).toBe("https://www.googleapis.com/calendar/v3/calendars");
|
||||
expect(init.method).toBe("POST");
|
||||
expect(JSON.parse(init.body as string)).toEqual({
|
||||
summary: "Blake Heritage bookings",
|
||||
timeZone: "Europe/London",
|
||||
});
|
||||
});
|
||||
|
||||
it("calendarExists says yes for a calendar that's there, no for one that was deleted, and rethrows anything else", async () => {
|
||||
const provider = new GoogleCalendarProvider();
|
||||
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("{}", { status: 200 })));
|
||||
expect(await provider.calendarExists({ accessToken: "t", calendarId: "cal-123" })).toBe(true);
|
||||
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
new Response(JSON.stringify({ error: { errors: [{ reason: "notFound" }] } }), {
|
||||
status: 404,
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(await provider.calendarExists({ accessToken: "t", calendarId: "cal-123" })).toBe(false);
|
||||
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("{}", { status: 503 })));
|
||||
await expect(
|
||||
provider.calendarExists({ accessToken: "t", calendarId: "cal-123" }),
|
||||
).rejects.toMatchObject({ code: "PROVIDER_OUTAGE" });
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,12 @@ export interface CalendarProvider {
|
||||
/** Only ever returns calendars the account can write to — see RemoteCalendarInfo.writable. */
|
||||
listWritableCalendars(accessToken: string): Promise<RemoteCalendarInfo[]>;
|
||||
|
||||
/** Creates a secondary calendar owned by this app in the person's account. */
|
||||
createCalendar(params: { accessToken: string; name: string; timeZone: string }): Promise<RemoteCalendarInfo>;
|
||||
|
||||
/** True while a calendar this app created earlier still exists. */
|
||||
calendarExists(params: { accessToken: string; calendarId: string }): Promise<boolean>;
|
||||
|
||||
createEvent(params: { accessToken: string; calendarId: string; event: CalendarEventInput }): Promise<{ providerEventId: string }>;
|
||||
|
||||
updateEvent(params: { accessToken: string; calendarId: string; providerEventId: string; event: CalendarEventInput }): Promise<void>;
|
||||
|
||||
@@ -8,17 +8,22 @@ import type { CalendarEventInput, ExchangedTokens, RefreshedAccessToken, RemoteC
|
||||
* existing pattern of building directly against a documented REST
|
||||
* contract rather than adding a heavy client library (see
|
||||
* brevo-email-provider.ts's own header for the same reasoning applied to
|
||||
* email). Minimum scopes: `calendar.events` (create/update/delete events)
|
||||
* and `calendar.readonly` (list calendars to choose a destination) —
|
||||
* deliberately not the full `calendar` scope, which also grants calendar
|
||||
* *settings* management this app never needs.
|
||||
* email). One calendar scope only: `calendar.app.created` — create a
|
||||
* secondary calendar and read/write events on calendars THIS APP created,
|
||||
* and nothing else. It cannot see or touch a person's existing calendars or
|
||||
* events, which is what keeps the consent screen narrow (the broader
|
||||
* `calendar.events` / `calendar.readonly` pair is what made Google show its
|
||||
* "hasn't verified this app" warning). The trade-off is by design: events go
|
||||
* to a dedicated "<Business> bookings" calendar this app creates in the
|
||||
* person's Google account, not into an existing one — see createCalendar.
|
||||
*
|
||||
* Unverified against a live Google account in this environment — no
|
||||
* OAuth credentials were available to test against. See
|
||||
* docs/phase-8-summary.md, "Unverified external-provider behaviour."
|
||||
*/
|
||||
|
||||
const SCOPES = ["https://www.googleapis.com/auth/calendar.events", "https://www.googleapis.com/auth/calendar.readonly", "email"].join(" ");
|
||||
export const CALENDAR_SCOPES = ["https://www.googleapis.com/auth/calendar.app.created", "email"];
|
||||
const SCOPES = CALENDAR_SCOPES.join(" ");
|
||||
|
||||
export interface GoogleErrorBody {
|
||||
error?: string | { message?: string; errors?: { reason?: string }[] };
|
||||
@@ -216,6 +221,30 @@ export class GoogleCalendarProvider implements CalendarProvider {
|
||||
.map((item) => ({ id: item.id, name: item.summary, writable: true, primary: !!item.primary }));
|
||||
}
|
||||
|
||||
/** Creates the secondary calendar this app writes events to. Needs only `calendar.app.created`. */
|
||||
async createCalendar(params: { accessToken: string; name: string; timeZone: string }): Promise<RemoteCalendarInfo> {
|
||||
const response = await googleFetch("https://www.googleapis.com/calendar/v3/calendars", {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${params.accessToken}`, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ summary: params.name, timeZone: params.timeZone }),
|
||||
});
|
||||
const created: { id: string; summary?: string } = await response.json();
|
||||
return { id: created.id, name: created.summary ?? params.name, writable: true, primary: false };
|
||||
}
|
||||
|
||||
/** Whether a calendar this app created earlier still exists — a reconnect reuses it instead of creating a second one. */
|
||||
async calendarExists(params: { accessToken: string; calendarId: string }): Promise<boolean> {
|
||||
try {
|
||||
await googleFetch(`https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(params.calendarId)}`, {
|
||||
headers: { Authorization: `Bearer ${params.accessToken}` },
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error instanceof CalendarProviderError && error.code === "CALENDAR_NOT_FOUND") return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async createEvent(params: { accessToken: string; calendarId: string; event: CalendarEventInput }): Promise<{ providerEventId: string }> {
|
||||
const response = await googleFetch(
|
||||
`https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(params.calendarId)}/events`,
|
||||
|
||||
@@ -37,6 +37,16 @@ export class MockCalendarProvider implements CalendarProvider {
|
||||
return this.calendars;
|
||||
}
|
||||
|
||||
async createCalendar(params: { name: string }): Promise<RemoteCalendarInfo> {
|
||||
const calendar = { id: `mock-calendar-${this.calendars.length + 1}`, name: params.name, writable: true, primary: false };
|
||||
this.calendars.push(calendar);
|
||||
return calendar;
|
||||
}
|
||||
|
||||
async calendarExists(params: { calendarId: string }): Promise<boolean> {
|
||||
return this.calendars.some((c) => c.id === params.calendarId);
|
||||
}
|
||||
|
||||
async createEvent(params: { event: CalendarEventInput }): Promise<{ providerEventId: string }> {
|
||||
const id = `mock-event-${this.nextEventId++}`;
|
||||
this.events.set(id, params.event);
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { AppointmentStatus, Weekday } from "@/generated/prisma/enums";
|
||||
import { addDays, todayIn, wallClockToInstant, weekdayOf } from "@/lib/time/business-time";
|
||||
import type { CalendarDate } from "@/lib/time/business-time";
|
||||
import { createBooking } from "@/services/scheduling/booking.service";
|
||||
import {
|
||||
cleanupTestBusiness,
|
||||
createTestAppointment,
|
||||
createTestBusiness,
|
||||
createTestCustomer,
|
||||
createTestGuestCustomer,
|
||||
createTestStaff,
|
||||
prisma,
|
||||
} from "@/services/scheduling/__tests__/fixtures";
|
||||
import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures";
|
||||
import { getNoShowStanding, getNoShowStandings, resetNoShows } from "../no-show.service";
|
||||
|
||||
function nextMonday(timezone: string): CalendarDate {
|
||||
let date = addDays(todayIn(timezone), 7);
|
||||
while (weekdayOf(date) !== Weekday.MONDAY) date = addDays(date, 1);
|
||||
return date;
|
||||
}
|
||||
|
||||
describe("no-show flag and block (integration)", () => {
|
||||
let business: TestBusiness;
|
||||
let staffId: string;
|
||||
let staffUserId: string;
|
||||
let monday: CalendarDate;
|
||||
const guestProfileIds: string[] = [];
|
||||
|
||||
beforeEach(async () => {
|
||||
business = await createTestBusiness();
|
||||
({ staffId, userId: staffUserId } = await createTestStaff(business));
|
||||
monday = nextMonday(business.timezone);
|
||||
guestProfileIds.length = 0;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await cleanupTestBusiness(business);
|
||||
await prisma.customerProfile
|
||||
.deleteMany({ where: { id: { in: guestProfileIds } } })
|
||||
.catch(() => {});
|
||||
});
|
||||
|
||||
const setThresholds = (flag: number | null, block: number | null) =>
|
||||
prisma.bookingRule.update({
|
||||
where: { businessId: business.businessId },
|
||||
data: { noShowFlagThreshold: flag, noShowBlockThreshold: block },
|
||||
});
|
||||
|
||||
/** Past appointments with a given outcome, oldest first, one per day. */
|
||||
async function addHistory(customerId: string, statuses: AppointmentStatus[]) {
|
||||
for (const [i, status] of statuses.entries()) {
|
||||
const { appointmentId } = await createTestAppointment(business, {
|
||||
customerId,
|
||||
staffId,
|
||||
startAt: new Date(Date.now() - (statuses.length - i + 1) * 24 * 60 * 60_000),
|
||||
});
|
||||
await prisma.appointment.update({ where: { id: appointmentId }, data: { status } });
|
||||
}
|
||||
}
|
||||
|
||||
const bookOnline = (customerId: string, hour = 10) =>
|
||||
createBooking(prisma, {
|
||||
businessId: business.businessId,
|
||||
customerId,
|
||||
staffId,
|
||||
serviceIds: [business.haircutServiceId],
|
||||
startAt: wallClockToInstant(monday, hour * 60, business.timezone),
|
||||
});
|
||||
|
||||
it("does nothing while the feature is off, however many no-shows", async () => {
|
||||
const { customerId } = await createTestCustomer(business);
|
||||
await addHistory(customerId, ["NO_SHOW", "NO_SHOW", "NO_SHOW"]);
|
||||
|
||||
expect(
|
||||
(await getNoShowStanding(prisma, { businessId: business.businessId, customerId })).blocked,
|
||||
).toBe(false);
|
||||
expect((await bookOnline(customerId)).success).toBe(true);
|
||||
});
|
||||
|
||||
it("flags at the flag number and blocks online booking at the block number", async () => {
|
||||
await setThresholds(2, 3);
|
||||
const { customerId } = await createTestCustomer(business);
|
||||
|
||||
await addHistory(customerId, ["NO_SHOW"]);
|
||||
expect(
|
||||
await getNoShowStanding(prisma, { businessId: business.businessId, customerId }),
|
||||
).toMatchObject({
|
||||
count: 1,
|
||||
flagged: false,
|
||||
blocked: false,
|
||||
});
|
||||
|
||||
await addHistory(customerId, ["NO_SHOW"]);
|
||||
expect(
|
||||
await getNoShowStanding(prisma, { businessId: business.businessId, customerId }),
|
||||
).toMatchObject({
|
||||
count: 2,
|
||||
flagged: true,
|
||||
blocked: false,
|
||||
});
|
||||
expect((await bookOnline(customerId)).success).toBe(true);
|
||||
|
||||
await addHistory(customerId, ["NO_SHOW"]);
|
||||
const blocked = await bookOnline(customerId, 14);
|
||||
expect(blocked.success).toBe(false);
|
||||
if (!blocked.success)
|
||||
expect(blocked.validation.issues.map((i) => i.code)).toContain("CUSTOMER_BOOKING_BLOCKED");
|
||||
});
|
||||
|
||||
it("lets staff and owners still book a blocked customer in", async () => {
|
||||
await setThresholds(null, 1);
|
||||
const { customerId } = await createTestCustomer(business);
|
||||
await addHistory(customerId, ["NO_SHOW"]);
|
||||
|
||||
expect((await bookOnline(customerId)).success).toBe(false);
|
||||
const byStaff = await createBooking(prisma, {
|
||||
businessId: business.businessId,
|
||||
customerId,
|
||||
staffId,
|
||||
serviceIds: [business.haircutServiceId],
|
||||
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
|
||||
createdByUserId: staffUserId,
|
||||
});
|
||||
expect(byStaff.success).toBe(true);
|
||||
});
|
||||
|
||||
it("resets when they attend: a completed appointment after the no-shows clears the count", async () => {
|
||||
await setThresholds(1, 2);
|
||||
const { customerId } = await createTestCustomer(business);
|
||||
await addHistory(customerId, ["NO_SHOW", "NO_SHOW"]);
|
||||
expect(
|
||||
(await getNoShowStanding(prisma, { businessId: business.businessId, customerId })).blocked,
|
||||
).toBe(true);
|
||||
|
||||
await addHistory(customerId, ["COMPLETED"]);
|
||||
expect(
|
||||
await getNoShowStanding(prisma, { businessId: business.businessId, customerId }),
|
||||
).toMatchObject({
|
||||
count: 0,
|
||||
flagged: false,
|
||||
blocked: false,
|
||||
});
|
||||
expect((await bookOnline(customerId)).success).toBe(true);
|
||||
});
|
||||
|
||||
it("an owner reset forgives past no-shows but not later ones", async () => {
|
||||
await setThresholds(null, 2);
|
||||
const { customerId } = await createTestCustomer(business);
|
||||
await addHistory(customerId, ["NO_SHOW", "NO_SHOW"]);
|
||||
expect(
|
||||
(await getNoShowStanding(prisma, { businessId: business.businessId, customerId })).blocked,
|
||||
).toBe(true);
|
||||
|
||||
await resetNoShows(prisma, { customerId, now: new Date(Date.now() - 60_000) });
|
||||
expect(
|
||||
(await getNoShowStanding(prisma, { businessId: business.businessId, customerId })).count,
|
||||
).toBe(0);
|
||||
|
||||
// A no-show that happened after the reset counts again.
|
||||
const { appointmentId } = await createTestAppointment(business, {
|
||||
customerId,
|
||||
staffId,
|
||||
startAt: new Date(Date.now() - 30_000),
|
||||
});
|
||||
await prisma.appointment.update({ where: { id: appointmentId }, data: { status: "NO_SHOW" } });
|
||||
expect(
|
||||
(await getNoShowStanding(prisma, { businessId: business.businessId, customerId })).count,
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it("a blocked guest can't dodge the block with a fresh profile that reuses their email or phone", async () => {
|
||||
await setThresholds(null, 2);
|
||||
const original = await createTestGuestCustomer();
|
||||
guestProfileIds.push(original.customerId);
|
||||
await addHistory(original.customerId, ["NO_SHOW", "NO_SHOW"]);
|
||||
|
||||
const sameEmail = await createTestGuestCustomer({ email: original.email.toUpperCase() });
|
||||
const samePhone = await createTestGuestCustomer({ phone: original.phone });
|
||||
guestProfileIds.push(sameEmail.customerId, samePhone.customerId);
|
||||
const stranger = await createTestGuestCustomer();
|
||||
guestProfileIds.push(stranger.customerId);
|
||||
|
||||
expect(
|
||||
(
|
||||
await getNoShowStanding(prisma, {
|
||||
businessId: business.businessId,
|
||||
customerId: sameEmail.customerId,
|
||||
})
|
||||
).blocked,
|
||||
).toBe(true);
|
||||
expect(
|
||||
(
|
||||
await getNoShowStanding(prisma, {
|
||||
businessId: business.businessId,
|
||||
customerId: samePhone.customerId,
|
||||
})
|
||||
).blocked,
|
||||
).toBe(true);
|
||||
expect(
|
||||
(
|
||||
await getNoShowStanding(prisma, {
|
||||
businessId: business.businessId,
|
||||
email: original.email,
|
||||
phone: null,
|
||||
})
|
||||
).blocked,
|
||||
).toBe(true);
|
||||
expect(
|
||||
(
|
||||
await getNoShowStanding(prisma, {
|
||||
businessId: business.businessId,
|
||||
customerId: stranger.customerId,
|
||||
})
|
||||
).blocked,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("standings for a whole day's customers only include people who have missed appointments", async () => {
|
||||
await setThresholds(1, null);
|
||||
const a = await createTestCustomer(business);
|
||||
const b = await createTestCustomer(business);
|
||||
await addHistory(a.customerId, ["NO_SHOW"]);
|
||||
await addHistory(b.customerId, ["COMPLETED"]);
|
||||
|
||||
const standings = await getNoShowStandings(prisma, {
|
||||
businessId: business.businessId,
|
||||
customerIds: [a.customerId, b.customerId],
|
||||
});
|
||||
expect([...standings.keys()]).toEqual([a.customerId]);
|
||||
expect(standings.get(a.customerId)).toMatchObject({ flagged: true, blocked: false });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { countNoShows, evaluateStanding, normalizeEmail } from "../no-show.service";
|
||||
|
||||
const at = (iso: string) => new Date(iso);
|
||||
const noShow = (iso: string) => ({ status: "NO_SHOW", startAt: at(iso) });
|
||||
const completed = (iso: string) => ({ status: "COMPLETED", startAt: at(iso) });
|
||||
|
||||
describe("countNoShows", () => {
|
||||
it("counts every no-show when the customer has never attended", () => {
|
||||
expect(
|
||||
countNoShows([noShow("2026-01-01T10:00:00Z"), noShow("2026-02-01T10:00:00Z")], null),
|
||||
).toBe(2);
|
||||
});
|
||||
|
||||
it("resets after an attended appointment", () => {
|
||||
const history = [
|
||||
noShow("2026-01-01T10:00:00Z"),
|
||||
noShow("2026-02-01T10:00:00Z"),
|
||||
completed("2026-03-01T10:00:00Z"),
|
||||
];
|
||||
expect(countNoShows(history, null)).toBe(0);
|
||||
});
|
||||
|
||||
it("counts only the no-shows after the latest attended appointment", () => {
|
||||
const history = [
|
||||
noShow("2026-01-01T10:00:00Z"),
|
||||
completed("2026-02-01T10:00:00Z"),
|
||||
noShow("2026-03-01T10:00:00Z"),
|
||||
noShow("2026-04-01T10:00:00Z"),
|
||||
];
|
||||
expect(countNoShows(history, null)).toBe(2);
|
||||
});
|
||||
|
||||
it("ignores order of the input", () => {
|
||||
const history = [
|
||||
noShow("2026-04-01T10:00:00Z"),
|
||||
completed("2026-02-01T10:00:00Z"),
|
||||
noShow("2026-01-01T10:00:00Z"),
|
||||
];
|
||||
expect(countNoShows(history, null)).toBe(1);
|
||||
});
|
||||
|
||||
it("an owner reset forgives everything before it, but not later no-shows", () => {
|
||||
const history = [
|
||||
noShow("2026-01-01T10:00:00Z"),
|
||||
noShow("2026-02-01T10:00:00Z"),
|
||||
noShow("2026-04-01T10:00:00Z"),
|
||||
];
|
||||
expect(countNoShows(history, at("2026-03-01T00:00:00Z"))).toBe(1);
|
||||
});
|
||||
|
||||
it("ignores appointments that are neither no-shows nor attended", () => {
|
||||
expect(countNoShows([{ status: "CANCELLED", startAt: at("2026-01-01T10:00:00Z") }], null)).toBe(
|
||||
0,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("evaluateStanding", () => {
|
||||
it("is all clear when the feature is off", () => {
|
||||
expect(evaluateStanding(9, { flagThreshold: null, blockThreshold: null })).toMatchObject({
|
||||
flagged: false,
|
||||
blocked: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("flags at the flag threshold and blocks at the block threshold", () => {
|
||||
const thresholds = { flagThreshold: 2, blockThreshold: 4 };
|
||||
expect(evaluateStanding(1, thresholds)).toMatchObject({ flagged: false, blocked: false });
|
||||
expect(evaluateStanding(2, thresholds)).toMatchObject({ flagged: true, blocked: false });
|
||||
expect(evaluateStanding(3, thresholds)).toMatchObject({ flagged: true, blocked: false });
|
||||
expect(evaluateStanding(4, thresholds)).toMatchObject({ flagged: true, blocked: true });
|
||||
});
|
||||
|
||||
it("supports a block with no flag, and a flag with no block", () => {
|
||||
expect(evaluateStanding(3, { flagThreshold: null, blockThreshold: 3 })).toMatchObject({
|
||||
flagged: true,
|
||||
blocked: true,
|
||||
});
|
||||
expect(evaluateStanding(5, { flagThreshold: 2, blockThreshold: null })).toMatchObject({
|
||||
flagged: true,
|
||||
blocked: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeEmail", () => {
|
||||
it("lowercases and trims so the same address always matches", () => {
|
||||
expect(normalizeEmail(" Alex@Example.COM ")).toBe("alex@example.com");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
import { AppointmentStatus } from "@/generated/prisma/enums";
|
||||
|
||||
/**
|
||||
* No-show handling: a symbol against a customer at N no-shows, and a block
|
||||
* on online booking at M. The count is DERIVED from appointment history,
|
||||
* never stored — a no-show is an Appointment with status NO_SHOW, and the
|
||||
* count is how many of those happened after the customer's most recent
|
||||
* COMPLETED (attended) appointment, or after an owner's reset
|
||||
* (CustomerProfile.noShowResetAt), whichever is later. That is what makes
|
||||
* "this resets if they show" free: there is no counter to forget to clear,
|
||||
* and it survives guest-profile merges.
|
||||
*
|
||||
* Caveat inherent to the design: "attended" means staff marked the
|
||||
* appointment COMPLETED, so a no-show streak only resets when they do.
|
||||
*/
|
||||
|
||||
export interface NoShowThresholds {
|
||||
flagThreshold: number | null;
|
||||
blockThreshold: number | null;
|
||||
}
|
||||
|
||||
export interface NoShowStanding extends NoShowThresholds {
|
||||
count: number;
|
||||
flagged: boolean;
|
||||
blocked: boolean;
|
||||
}
|
||||
|
||||
interface HistoryEntry {
|
||||
status: string;
|
||||
startAt: Date;
|
||||
}
|
||||
|
||||
/** No-shows since the latest of (last completed appointment, reset). Pure — callers pass what they already loaded. */
|
||||
export function countNoShows(history: HistoryEntry[], resetAt: Date | null): number {
|
||||
let cutoff = resetAt ? resetAt.getTime() : Number.NEGATIVE_INFINITY;
|
||||
for (const entry of history) {
|
||||
if (entry.status === AppointmentStatus.COMPLETED)
|
||||
cutoff = Math.max(cutoff, entry.startAt.getTime());
|
||||
}
|
||||
return history.filter(
|
||||
(e) => e.status === AppointmentStatus.NO_SHOW && e.startAt.getTime() > cutoff,
|
||||
).length;
|
||||
}
|
||||
|
||||
/** Thresholds are null (off) or a positive integer; at/above counts, so a threshold of 2 flags on the second no-show. */
|
||||
export function evaluateStanding(count: number, thresholds: NoShowThresholds): NoShowStanding {
|
||||
const { flagThreshold, blockThreshold } = thresholds;
|
||||
const blocked = blockThreshold !== null && count >= blockThreshold;
|
||||
// A blocked customer is always also flagged, even if the owner set the block threshold below the flag one.
|
||||
const flagged = blocked || (flagThreshold !== null && count >= flagThreshold);
|
||||
return { count, flagThreshold, blockThreshold, flagged, blocked };
|
||||
}
|
||||
|
||||
export async function getNoShowThresholds(
|
||||
prisma: PrismaClient,
|
||||
businessId: string,
|
||||
): Promise<NoShowThresholds> {
|
||||
const rule = await prisma.bookingRule.findUnique({
|
||||
where: { businessId },
|
||||
select: { noShowFlagThreshold: true, noShowBlockThreshold: true },
|
||||
});
|
||||
return {
|
||||
flagThreshold: rule?.noShowFlagThreshold ?? null,
|
||||
blockThreshold: rule?.noShowBlockThreshold ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function isNoShowHandlingOn(thresholds: NoShowThresholds): boolean {
|
||||
return thresholds.flagThreshold !== null || thresholds.blockThreshold !== null;
|
||||
}
|
||||
|
||||
/** Guest profiles are keyed by email + phone, so these are the two things a person keeps constant. */
|
||||
export function normalizeEmail(email: string): string {
|
||||
return email.trim().toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Every profile that is evidently the same person as the one asked about:
|
||||
* the profile itself, plus any sharing its email or its phone. Without
|
||||
* this a blocked guest could book again by changing one of the two.
|
||||
*/
|
||||
async function relatedProfiles(
|
||||
prisma: PrismaClient,
|
||||
params: { customerId?: string; email?: string; phone?: string | null },
|
||||
) {
|
||||
const known = params.customerId
|
||||
? await prisma.customerProfile.findUnique({
|
||||
where: { id: params.customerId },
|
||||
select: { id: true, email: true, phone: true },
|
||||
})
|
||||
: null;
|
||||
const emails = new Set<string>();
|
||||
const phones = new Set<string>();
|
||||
if (known?.email) emails.add(normalizeEmail(known.email));
|
||||
if (known?.phone) phones.add(known.phone);
|
||||
if (params.email) emails.add(normalizeEmail(params.email));
|
||||
if (params.phone) phones.add(params.phone);
|
||||
|
||||
const or = [
|
||||
...(params.customerId ? [{ id: params.customerId }] : []),
|
||||
...[...emails].map((email) => ({ email })),
|
||||
...[...phones].map((phone) => ({ phone })),
|
||||
];
|
||||
if (or.length === 0) return [];
|
||||
return prisma.customerProfile.findMany({
|
||||
where: { OR: or },
|
||||
select: { id: true, noShowResetAt: true },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The standing used to ENFORCE the block: counts across every profile that
|
||||
* shares this customer's email or phone. Returns an "all clear" standing
|
||||
* without touching appointment data when the feature is off.
|
||||
*/
|
||||
export async function getNoShowStanding(
|
||||
prisma: PrismaClient,
|
||||
params: { businessId: string; customerId?: string; email?: string; phone?: string | null },
|
||||
): Promise<NoShowStanding> {
|
||||
const thresholds = await getNoShowThresholds(prisma, params.businessId);
|
||||
if (!isNoShowHandlingOn(thresholds)) return evaluateStanding(0, thresholds);
|
||||
|
||||
const profiles = await relatedProfiles(prisma, params);
|
||||
if (profiles.length === 0) return evaluateStanding(0, thresholds);
|
||||
|
||||
const history = await prisma.appointment.findMany({
|
||||
where: {
|
||||
businessId: params.businessId,
|
||||
customerId: { in: profiles.map((p) => p.id) },
|
||||
status: { in: [AppointmentStatus.NO_SHOW, AppointmentStatus.COMPLETED] },
|
||||
},
|
||||
select: { status: true, startAt: true },
|
||||
});
|
||||
const resetAt = profiles.reduce<Date | null>(
|
||||
(latest, p) =>
|
||||
p.noShowResetAt && (!latest || p.noShowResetAt > latest) ? p.noShowResetAt : latest,
|
||||
null,
|
||||
);
|
||||
return evaluateStanding(countNoShows(history, resetAt), thresholds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Standings for a batch of profiles at once (the clients list, a day's
|
||||
* calendar). Per profile, not cross-profile: display only, and one query
|
||||
* for the whole batch matters more there than catching a renamed guest.
|
||||
*/
|
||||
export async function getNoShowStandings(
|
||||
prisma: PrismaClient,
|
||||
params: { businessId: string; customerIds: string[] },
|
||||
): Promise<Map<string, NoShowStanding>> {
|
||||
const standings = new Map<string, NoShowStanding>();
|
||||
const thresholds = await getNoShowThresholds(prisma, params.businessId);
|
||||
if (!isNoShowHandlingOn(thresholds) || params.customerIds.length === 0) return standings;
|
||||
|
||||
const ids = [...new Set(params.customerIds)];
|
||||
const [profiles, history] = await Promise.all([
|
||||
prisma.customerProfile.findMany({
|
||||
where: { id: { in: ids } },
|
||||
select: { id: true, noShowResetAt: true },
|
||||
}),
|
||||
prisma.appointment.findMany({
|
||||
where: {
|
||||
businessId: params.businessId,
|
||||
customerId: { in: ids },
|
||||
status: { in: [AppointmentStatus.NO_SHOW, AppointmentStatus.COMPLETED] },
|
||||
},
|
||||
select: { customerId: true, status: true, startAt: true },
|
||||
}),
|
||||
]);
|
||||
const resetById = new Map(profiles.map((p) => [p.id, p.noShowResetAt]));
|
||||
const historyById = new Map<string, HistoryEntry[]>();
|
||||
for (const entry of history) {
|
||||
const list = historyById.get(entry.customerId) ?? [];
|
||||
list.push(entry);
|
||||
historyById.set(entry.customerId, list);
|
||||
}
|
||||
for (const id of ids) {
|
||||
const standing = evaluateStanding(
|
||||
countNoShows(historyById.get(id) ?? [], resetById.get(id) ?? null),
|
||||
thresholds,
|
||||
);
|
||||
if (standing.count > 0) standings.set(id, standing);
|
||||
}
|
||||
return standings;
|
||||
}
|
||||
|
||||
/** Owner "forgive": every profile that is evidently this person starts again from zero. */
|
||||
export async function resetNoShows(
|
||||
prisma: PrismaClient,
|
||||
params: { customerId: string; now?: Date },
|
||||
): Promise<number> {
|
||||
const profiles = await relatedProfiles(prisma, { customerId: params.customerId });
|
||||
const { count } = await prisma.customerProfile.updateMany({
|
||||
where: { id: { in: profiles.map((p) => p.id) } },
|
||||
data: { noShowResetAt: params.now ?? new Date() },
|
||||
});
|
||||
return count;
|
||||
}
|
||||
@@ -9,8 +9,6 @@ const base: ProgrammeConfigInput = {
|
||||
rewardPosition: RewardPositionMode.AFTER_ALL_STAMPS,
|
||||
rewardType: "PERCENTAGE",
|
||||
rewardValue: "50",
|
||||
autoApplyReward: true,
|
||||
requiresStaffApproval: false,
|
||||
earnOnCancelled: false,
|
||||
earnOnNoShow: false,
|
||||
cardFont: LoyaltyCardFont.SANS,
|
||||
|
||||
@@ -36,8 +36,6 @@ describe("LoyaltyService — owner configuration and redemption (integration)",
|
||||
rewardPosition: RewardPositionMode.AFTER_ALL_STAMPS,
|
||||
rewardType: "PERCENTAGE",
|
||||
rewardValue: "25",
|
||||
autoApplyReward: true,
|
||||
requiresStaffApproval: false,
|
||||
earnOnCancelled: false,
|
||||
earnOnNoShow: false,
|
||||
cardFont: LoyaltyCardFont.SANS,
|
||||
@@ -58,8 +56,6 @@ describe("LoyaltyService — owner configuration and redemption (integration)",
|
||||
rewardPosition: RewardPositionMode.AFTER_ALL_STAMPS,
|
||||
rewardType: "PERCENTAGE",
|
||||
rewardValue: "40",
|
||||
autoApplyReward: true,
|
||||
requiresStaffApproval: false,
|
||||
earnOnCancelled: false,
|
||||
earnOnNoShow: false,
|
||||
cardFont: LoyaltyCardFont.SANS,
|
||||
@@ -87,8 +83,6 @@ describe("LoyaltyService — owner configuration and redemption (integration)",
|
||||
rewardPosition: RewardPositionMode.AFTER_ALL_STAMPS,
|
||||
rewardType: "FREE_SERVICE",
|
||||
freeServiceId: inactiveService.id,
|
||||
autoApplyReward: true,
|
||||
requiresStaffApproval: false,
|
||||
earnOnCancelled: false,
|
||||
earnOnNoShow: false,
|
||||
cardFont: LoyaltyCardFont.SANS,
|
||||
@@ -342,21 +336,18 @@ describe("LoyaltyService — owner configuration and redemption (integration)",
|
||||
expect(count).toBe(1);
|
||||
});
|
||||
|
||||
it("sends exactly one more warning when the reward is down to its last day — two in total", async () => {
|
||||
it("never sends a second warning when the reward is down to its last day — one heads-up is enough", async () => {
|
||||
const { reward } = await setUpAccountWithReward(new Date(Date.now() + 5 * 24 * 60 * 60 * 1000));
|
||||
|
||||
await expireOverdueRewards(prisma); // 5 days out → the "7d" warning
|
||||
await expireOverdueRewards(prisma, new Date(Date.now() + 4.5 * 24 * 60 * 60 * 1000)); // 12h left → the "1d" warning
|
||||
await expireOverdueRewards(prisma, new Date(Date.now() + 4.8 * 24 * 60 * 60 * 1000)); // still the last day — no third
|
||||
await expireOverdueRewards(prisma); // 5 days out → the one warning
|
||||
await expireOverdueRewards(prisma, new Date(Date.now() + 4.5 * 24 * 60 * 60 * 1000)); // 12h left — no second
|
||||
await expireOverdueRewards(prisma, new Date(Date.now() + 4.8 * 24 * 60 * 60 * 1000)); // still the last day — no second
|
||||
|
||||
const notifications = await prisma.notification.findMany({
|
||||
where: { type: "LOYALTY_REWARD_EXPIRING", loyaltyRewardId: reward.id },
|
||||
select: { idempotencyKey: true },
|
||||
});
|
||||
expect(notifications.map((n) => n.idempotencyKey).sort()).toEqual([
|
||||
`notify:loyalty-reward-expiring:${reward.id}:1d`,
|
||||
`notify:loyalty-reward-expiring:${reward.id}:7d`,
|
||||
]);
|
||||
expect(notifications.map((n) => n.idempotencyKey)).toEqual([`notify:loyalty-reward-expiring:${reward.id}:7d`]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -549,8 +549,6 @@ export interface ProgrammeConfigInput {
|
||||
rewardLabel?: string;
|
||||
maxRewardValue?: string;
|
||||
freeServiceId?: string;
|
||||
autoApplyReward: boolean;
|
||||
requiresStaffApproval: boolean;
|
||||
stampsExpireAfterDays?: number;
|
||||
rewardExpiresAfterDays?: number;
|
||||
earnOnCancelled: boolean;
|
||||
@@ -630,8 +628,6 @@ export async function updateProgrammeConfig(
|
||||
rewardLabel: params.input.rewardLabel?.trim() || null,
|
||||
maxRewardValue: params.input.maxRewardValue || null,
|
||||
freeServiceId: params.input.rewardType === "FREE_SERVICE" ? (params.input.freeServiceId ?? null) : null,
|
||||
autoApplyReward: params.input.autoApplyReward,
|
||||
requiresStaffApproval: params.input.requiresStaffApproval,
|
||||
stampsExpireAfterDays: params.input.stampsExpireAfterDays ?? null,
|
||||
rewardExpiresAfterDays: params.input.rewardExpiresAfterDays ?? null,
|
||||
earnOnCancelled: params.input.earnOnCancelled,
|
||||
@@ -864,9 +860,9 @@ const REWARD_EXPIRING_SOON_WINDOW_DAYS = 7;
|
||||
* expiresAt has passed to EXPIRED (redeemReward also defends against this
|
||||
* directly, but the sweep is what makes "Expired" actually show up in
|
||||
* history/UI instead of staying stuck at AVAILABLE forever), then warn
|
||||
* about rewards still AVAILABLE and expiring within the next week — twice
|
||||
* per reward at most (7 days out, then the last day; see
|
||||
* notifyLoyaltyRewardExpiring), not on every sweep.
|
||||
* about rewards still AVAILABLE and expiring within the next week — once
|
||||
* per reward (see notifyLoyaltyRewardExpiring), not on every sweep. A second
|
||||
* "last day" email was cut: one heads-up is enough, and it arrives a week out.
|
||||
*/
|
||||
export async function expireOverdueRewards(prisma: PrismaClient, now: Date = new Date()): Promise<void> {
|
||||
await prisma.loyaltyReward.updateMany({
|
||||
@@ -890,7 +886,7 @@ export async function expireOverdueRewards(prisma: PrismaClient, now: Date = new
|
||||
rewardId: reward.id,
|
||||
rewardLabel: reward.label,
|
||||
expiresAt: reward.expiresAt,
|
||||
bucket: reward.expiresAt.getTime() - now.getTime() <= 86_400_000 ? "1d" : "7d",
|
||||
bucket: "7d",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -231,15 +231,9 @@ describe("NotificationService (integration)", () => {
|
||||
expect(staffNotification.deliveries.map((d) => d.channel)).toContain(NotificationChannel.IN_APP);
|
||||
});
|
||||
|
||||
it("STAFF_NEW_APPOINTMENT, STAFF_APPOINTMENT_RESCHEDULED, OWNER_NEW_BOOKING, and OWNER_NO_SHOW default to off with no preference row", async () => {
|
||||
it("OWNER_NEW_BOOKING and OWNER_NO_SHOW default to off on every channel with no preference row", async () => {
|
||||
const { userId } = await createTestCustomer(business);
|
||||
const disabledByDefault = [
|
||||
NotificationType.STAFF_NEW_APPOINTMENT,
|
||||
NotificationType.STAFF_APPOINTMENT_RESCHEDULED,
|
||||
NotificationType.OWNER_NEW_BOOKING,
|
||||
NotificationType.OWNER_NO_SHOW,
|
||||
];
|
||||
for (const type of disabledByDefault) {
|
||||
for (const type of [NotificationType.OWNER_NEW_BOOKING, NotificationType.OWNER_NO_SHOW]) {
|
||||
const { notification } = await notify(prisma, {
|
||||
businessId: business.businessId,
|
||||
type,
|
||||
@@ -251,6 +245,19 @@ describe("NotificationService (integration)", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("STAFF_NEW_APPOINTMENT and STAFF_APPOINTMENT_RESCHEDULED never email by default, and a stored EMAIL preference opts in", async () => {
|
||||
const { userId } = await createTestCustomer(business);
|
||||
for (const type of [NotificationType.STAFF_NEW_APPOINTMENT, NotificationType.STAFF_APPOINTMENT_RESCHEDULED]) {
|
||||
const params = { businessId: business.businessId, type, recipientUserId: userId, bodyText: "x" };
|
||||
const { notification: defaults } = await notify(prisma, { ...params, idempotencyKey: `test:${userId}:default:${type}` });
|
||||
expect(defaults.deliveries.map((d) => d.channel)).not.toContain(NotificationChannel.EMAIL);
|
||||
|
||||
await setNotificationPreference(prisma, { userId, type, channel: NotificationChannel.EMAIL, isEnabled: true });
|
||||
const { notification: optedIn } = await notify(prisma, { ...params, idempotencyKey: `test:${userId}:optin:${type}` });
|
||||
expect(optedIn.deliveries.map((d) => d.channel)).toContain(NotificationChannel.EMAIL);
|
||||
}
|
||||
});
|
||||
|
||||
it("STAFF_APPOINTMENT_REMINDER defaults to IN_APP on and EMAIL off, and a stored EMAIL preference opts in", async () => {
|
||||
const { userId } = await createTestCustomer(business);
|
||||
const params = {
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { NotificationType } from "@/generated/prisma/enums";
|
||||
import {
|
||||
cleanupTestBusiness,
|
||||
createTestBusiness,
|
||||
createTestCustomer,
|
||||
prisma,
|
||||
} from "@/services/scheduling/__tests__/fixtures";
|
||||
import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures";
|
||||
import { notify } from "../notification.service";
|
||||
import { getQueueHealth } from "../queue-health";
|
||||
import { MockEmailProvider } from "../providers/mock-email-provider";
|
||||
import { processDueNotificationDeliveries } from "../reminder-worker";
|
||||
|
||||
describe("notification queue health (integration)", () => {
|
||||
let business: TestBusiness;
|
||||
let n = 0;
|
||||
|
||||
beforeEach(async () => {
|
||||
business = await createTestBusiness();
|
||||
});
|
||||
afterEach(async () => {
|
||||
await cleanupTestBusiness(business);
|
||||
});
|
||||
|
||||
/** One EMAIL delivery for a registered customer, backdated by `minutesOld`. */
|
||||
async function queueEmail(minutesOld: number) {
|
||||
const { customerId, userId } = await createTestCustomer(business);
|
||||
const { notification } = await notify(prisma, {
|
||||
businessId: business.businessId,
|
||||
type: NotificationType.BOOKING_CONFIRMED,
|
||||
recipientUserId: userId,
|
||||
recipientCustomerId: customerId,
|
||||
bodyText: "hi",
|
||||
idempotencyKey: `qh:${Date.now()}:${n++}`,
|
||||
});
|
||||
const delivery = notification.deliveries.find((d) => d.channel === "EMAIL")!;
|
||||
await prisma.notificationDelivery.update({
|
||||
where: { id: delivery.id },
|
||||
data: { createdAt: new Date(Date.now() - minutesOld * 60_000) },
|
||||
});
|
||||
return delivery.id;
|
||||
}
|
||||
|
||||
// The test database is shared by every business a run has created, so assert on differences.
|
||||
it("counts only deliveries that have been waiting more than ten minutes", async () => {
|
||||
const before = await getQueueHealth(prisma);
|
||||
await queueEmail(2); // fresh: not stale
|
||||
await queueEmail(30); // stale
|
||||
const after = await getQueueHealth(prisma);
|
||||
|
||||
expect(after.stalePendingCount - before.stalePendingCount).toBe(1);
|
||||
expect(after.oldestPendingAgeSeconds).toBeGreaterThanOrEqual(29 * 60);
|
||||
});
|
||||
|
||||
it("puts a delivery stuck in PROCESSING back in the queue, and sends it", async () => {
|
||||
const id = await queueEmail(60);
|
||||
await prisma.notificationDelivery.update({
|
||||
where: { id },
|
||||
data: { status: "PROCESSING", lastAttemptedAt: new Date(Date.now() - 30 * 60_000) },
|
||||
});
|
||||
expect((await getQueueHealth(prisma)).stuckProcessingCount).toBeGreaterThanOrEqual(1);
|
||||
|
||||
const provider = new MockEmailProvider();
|
||||
await processDueNotificationDeliveries(prisma, {
|
||||
emailProvider: provider,
|
||||
businessId: business.businessId,
|
||||
});
|
||||
|
||||
const delivery = await prisma.notificationDelivery.findUniqueOrThrow({ where: { id } });
|
||||
expect(delivery.status).toBe("SENT");
|
||||
expect(provider.sentEmails).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("leaves a delivery that is mid-send right now alone", async () => {
|
||||
const id = await queueEmail(1);
|
||||
await prisma.notificationDelivery.update({
|
||||
where: { id },
|
||||
data: { status: "PROCESSING", lastAttemptedAt: new Date(Date.now() - 30_000) },
|
||||
});
|
||||
|
||||
await processDueNotificationDeliveries(prisma, {
|
||||
emailProvider: new MockEmailProvider(),
|
||||
businessId: business.businessId,
|
||||
});
|
||||
|
||||
expect((await prisma.notificationDelivery.findUniqueOrThrow({ where: { id } })).status).toBe(
|
||||
"PROCESSING",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { assessQueueHealth } from "../queue-health";
|
||||
import type { QueueHealth } from "../queue-health";
|
||||
import { isSystemicFailureReason } from "../failure-reasons";
|
||||
|
||||
const now = new Date("2026-10-20T12:00:00Z");
|
||||
const minutesAgo = (m: number) => new Date(now.getTime() - m * 60_000);
|
||||
const calm: QueueHealth = {
|
||||
stalePendingCount: 0,
|
||||
oldestPendingAgeSeconds: null,
|
||||
stuckProcessingCount: 0,
|
||||
failedLast24h: 0,
|
||||
sentLast24h: 10,
|
||||
};
|
||||
|
||||
const assess = (overrides: Partial<Parameters<typeof assessQueueHealth>[0]> = {}) =>
|
||||
assessQueueHealth({
|
||||
installed: true,
|
||||
installedAt: minutesAgo(60 * 24 * 30),
|
||||
lastCronRunAt: minutesAgo(1),
|
||||
queue: calm,
|
||||
now,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("assessQueueHealth", () => {
|
||||
it("is healthy when the cron ran a minute ago and nothing is backed up", () => {
|
||||
expect(assess()).toEqual({ status: "healthy", degradedReasons: [] });
|
||||
});
|
||||
|
||||
it("is degraded when the cron hasn't run for ten minutes, and says how long", () => {
|
||||
const result = assess({ lastCronRunAt: minutesAgo(45) });
|
||||
expect(result.status).toBe("degraded");
|
||||
expect(result.degradedReasons[0]).toContain("45 minutes");
|
||||
});
|
||||
|
||||
it("is degraded when the cron has never run on an established install", () => {
|
||||
const result = assess({ lastCronRunAt: null });
|
||||
expect(result.status).toBe("degraded");
|
||||
expect(result.degradedReasons[0]).toContain("never run");
|
||||
});
|
||||
|
||||
it("gives a brand-new install ten minutes before 'the cron never ran' counts", () => {
|
||||
expect(assess({ lastCronRunAt: null, installedAt: minutesAgo(3) }).status).toBe("healthy");
|
||||
expect(assess({ lastCronRunAt: null, installedAt: minutesAgo(30) }).status).toBe("degraded");
|
||||
});
|
||||
|
||||
it("says nothing about the cron before the app is installed", () => {
|
||||
expect(assess({ installed: false, installedAt: null, lastCronRunAt: null }).status).toBe(
|
||||
"healthy",
|
||||
);
|
||||
});
|
||||
|
||||
it("is degraded when notifications are waiting or stuck", () => {
|
||||
expect(
|
||||
assess({ queue: { ...calm, stalePendingCount: 4, oldestPendingAgeSeconds: 900 } })
|
||||
.degradedReasons[0],
|
||||
).toContain("4 notification");
|
||||
expect(assess({ queue: { ...calm, stuckProcessingCount: 1 } }).status).toBe("degraded");
|
||||
});
|
||||
|
||||
it("treats a few failures among successes as normal, but all-failing as a setup problem", () => {
|
||||
expect(assess({ queue: { ...calm, failedLast24h: 5, sentLast24h: 20 } }).status).toBe(
|
||||
"healthy",
|
||||
);
|
||||
expect(assess({ queue: { ...calm, failedLast24h: 5, sentLast24h: 0 } }).status).toBe(
|
||||
"degraded",
|
||||
);
|
||||
expect(assess({ queue: { ...calm, failedLast24h: 2, sentLast24h: 0 } }).status).toBe("healthy");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSystemicFailureReason", () => {
|
||||
it("recognises setup problems: bad SMTP login, auth failures, missing keys", () => {
|
||||
for (const reason of [
|
||||
"Invalid login: 535 5.7.8 Authentication failed",
|
||||
"Web Push is not configured — VAPID_PUBLIC_KEY must be set",
|
||||
"401 Unauthorized",
|
||||
"Invalid API key",
|
||||
]) {
|
||||
expect(isSystemicFailureReason(reason)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not treat one unreachable recipient as a setup problem", () => {
|
||||
for (const reason of [
|
||||
"550 5.1.1 Mailbox not found",
|
||||
"No email address on file for this recipient.",
|
||||
"Connection timed out",
|
||||
]) {
|
||||
expect(isSystemicFailureReason(reason)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -53,13 +53,33 @@ describe("defaultPreferenceIsEnabled", () => {
|
||||
expect(defaultPreferenceIsEnabled(type, NotificationChannel.EMAIL)).toBe(false);
|
||||
});
|
||||
|
||||
it("types that default off do so on every channel", () => {
|
||||
it("routine staff events are push-only by default: email off, push on", () => {
|
||||
for (const type of [
|
||||
NotificationType.STAFF_NEW_APPOINTMENT,
|
||||
NotificationType.STAFF_APPOINTMENT_RESCHEDULED,
|
||||
NotificationType.OWNER_NEW_BOOKING,
|
||||
NotificationType.OWNER_NO_SHOW,
|
||||
NotificationType.STAFF_APPOINTMENT_REMINDER,
|
||||
NotificationType.STAFF_WHATSAPP_REMINDERS_DUE,
|
||||
]) {
|
||||
expect(defaultPreferenceIsEnabled(type, NotificationChannel.EMAIL)).toBe(false);
|
||||
expect(defaultPreferenceIsEnabled(type, NotificationChannel.PUSH)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("important staff and owner events keep email on by default", () => {
|
||||
for (const type of [
|
||||
NotificationType.STAFF_APPOINTMENT_CANCELLED,
|
||||
NotificationType.STAFF_SCHEDULE_CHANGED,
|
||||
NotificationType.STAFF_INVITATION_RECEIVED,
|
||||
NotificationType.OWNER_BOOKING_CANCELLED,
|
||||
NotificationType.OWNER_STAFF_INVITATION_ACCEPTED,
|
||||
NotificationType.OWNER_REPEATED_DELIVERY_FAILURE,
|
||||
]) {
|
||||
expect(defaultPreferenceIsEnabled(type, NotificationChannel.EMAIL)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("owner-facing types that would be noise for a multi-barber shop default off on every channel", () => {
|
||||
for (const type of [NotificationType.OWNER_NEW_BOOKING, NotificationType.OWNER_NO_SHOW]) {
|
||||
for (const channel of channels) expect(defaultPreferenceIsEnabled(type, channel)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Failures that say the SETUP is broken (a wrong SMTP login, bad push keys, a
|
||||
* missing config) rather than one recipient being unreachable (a mistyped
|
||||
* address). The first of these is worth telling the owner about at once; a
|
||||
* recipient-specific one has to repeat before it is.
|
||||
*/
|
||||
export function isSystemicFailureReason(reason: string): boolean {
|
||||
return /\b(auth(entication)?|invalid login|credential|unauthori[sz]ed|forbidden|api[- ]?key|vapid|not configured|535|534|530)\b/i.test(
|
||||
reason,
|
||||
);
|
||||
}
|
||||
@@ -153,7 +153,7 @@ export class BrevoEmailProvider implements EmailProvider {
|
||||
// display name comes from the caller.
|
||||
from: { address: this.fromAddress, name: params.sender.name },
|
||||
to: { address: params.to.email, name: params.to.name },
|
||||
replyTo: this.replyTo,
|
||||
replyTo: params.replyTo || this.replyTo,
|
||||
subject: params.subject,
|
||||
html: params.html,
|
||||
text: params.text,
|
||||
|
||||
@@ -32,6 +32,12 @@ export interface SendEmailParams {
|
||||
/** The resolved business name (or fallback) to show as the visible sender — see render-email.ts. */
|
||||
sender: EmailSender;
|
||||
subject: string;
|
||||
/**
|
||||
* Where a customer's reply should go — the business's own email, so answering
|
||||
* "can I move my appointment?" reaches the shop rather than a shared inbox.
|
||||
* Falls back to the provider's own configured reply-to (EMAIL_REPLY_TO), if any.
|
||||
*/
|
||||
replyTo?: string;
|
||||
html: string;
|
||||
/** Plain-text fallback — required, not derived from `html`, so a broken tag can't silently produce an empty fallback. */
|
||||
text: string;
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
import { NotificationChannel, NotificationDeliveryStatus } from "@/generated/prisma/enums";
|
||||
|
||||
/**
|
||||
* What GET /api/health reports about the notification queue, and the rules
|
||||
* that turn it into "healthy" or "degraded". The Manager polls this per
|
||||
* business and emails the operator when it turns degraded (see
|
||||
* manager/src/lib/health.ts, which reads exactly these field names).
|
||||
*
|
||||
* Why a "degraded" state at all: email, push and WhatsApp nudges are all
|
||||
* delivered by a background tick (/api/cron/process-notifications). If that
|
||||
* tick silently stops, nothing errors — messages just never go out — so the
|
||||
* app has to say so itself.
|
||||
*/
|
||||
|
||||
export const STALE_AFTER_MINUTES = 10;
|
||||
|
||||
export interface QueueHealth {
|
||||
/** Email/push deliveries that were due more than STALE_AFTER_MINUTES ago and still haven't been sent. */
|
||||
stalePendingCount: number;
|
||||
oldestPendingAgeSeconds: number | null;
|
||||
/** Deliveries claimed by a worker that then never finished (it crashed mid-send). */
|
||||
stuckProcessingCount: number;
|
||||
failedLast24h: number;
|
||||
sentLast24h: number;
|
||||
}
|
||||
|
||||
const QUEUED_CHANNELS = [NotificationChannel.EMAIL, NotificationChannel.PUSH];
|
||||
|
||||
export async function getQueueHealth(
|
||||
prisma: PrismaClient,
|
||||
now: Date = new Date(),
|
||||
): Promise<QueueHealth> {
|
||||
const cutoff = new Date(now.getTime() - STALE_AFTER_MINUTES * 60_000);
|
||||
const dayAgo = new Date(now.getTime() - 24 * 3_600_000);
|
||||
|
||||
const [pending, scheduled, stuck, failed, sent] = await Promise.all([
|
||||
// Created and never picked up. PENDING rows have no scheduledFor unless they're waiting out a retry backoff.
|
||||
prisma.notificationDelivery.aggregate({
|
||||
where: {
|
||||
channel: { in: QUEUED_CHANNELS },
|
||||
status: NotificationDeliveryStatus.PENDING,
|
||||
OR: [{ scheduledFor: null, createdAt: { lt: cutoff } }, { scheduledFor: { lt: cutoff } }],
|
||||
},
|
||||
_count: true,
|
||||
_min: { createdAt: true, scheduledFor: true },
|
||||
}),
|
||||
// Reminders whose time has come.
|
||||
prisma.notificationDelivery.aggregate({
|
||||
where: {
|
||||
channel: { in: QUEUED_CHANNELS },
|
||||
status: NotificationDeliveryStatus.SCHEDULED,
|
||||
scheduledFor: { lt: cutoff },
|
||||
},
|
||||
_count: true,
|
||||
_min: { scheduledFor: true },
|
||||
}),
|
||||
prisma.notificationDelivery.count({
|
||||
where: {
|
||||
channel: { in: QUEUED_CHANNELS },
|
||||
status: NotificationDeliveryStatus.PROCESSING,
|
||||
lastAttemptedAt: { lt: cutoff },
|
||||
},
|
||||
}),
|
||||
prisma.notificationDelivery.count({
|
||||
where: {
|
||||
channel: { in: QUEUED_CHANNELS },
|
||||
status: NotificationDeliveryStatus.FAILED,
|
||||
lastAttemptedAt: { gte: dayAgo },
|
||||
},
|
||||
}),
|
||||
prisma.notificationDelivery.count({
|
||||
where: {
|
||||
channel: { in: QUEUED_CHANNELS },
|
||||
status: NotificationDeliveryStatus.SENT,
|
||||
sentAt: { gte: dayAgo },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const dueTimes = [
|
||||
pending._min.scheduledFor ?? pending._min.createdAt,
|
||||
scheduled._min.scheduledFor,
|
||||
].filter((d): d is Date => d instanceof Date);
|
||||
const oldest = dueTimes.length > 0 ? Math.min(...dueTimes.map((d) => d.getTime())) : null;
|
||||
|
||||
return {
|
||||
stalePendingCount: pending._count + scheduled._count,
|
||||
oldestPendingAgeSeconds:
|
||||
oldest === null ? null : Math.max(0, Math.round((now.getTime() - oldest) / 1000)),
|
||||
stuckProcessingCount: stuck,
|
||||
failedLast24h: failed,
|
||||
sentLast24h: sent,
|
||||
};
|
||||
}
|
||||
|
||||
export interface HealthAssessment {
|
||||
status: "healthy" | "degraded";
|
||||
degradedReasons: string[];
|
||||
}
|
||||
|
||||
/** Pure. `installedAt`/`lastCronRunAt` null = never. A brand-new install gets a grace period before "the cron never ran" counts. */
|
||||
export function assessQueueHealth(input: {
|
||||
installed: boolean;
|
||||
installedAt: Date | null;
|
||||
lastCronRunAt: Date | null;
|
||||
queue: QueueHealth;
|
||||
now?: Date;
|
||||
}): HealthAssessment {
|
||||
const now = input.now ?? new Date();
|
||||
const reasons: string[] = [];
|
||||
const minutesAgo = (d: Date) => Math.round((now.getTime() - d.getTime()) / 60_000);
|
||||
|
||||
if (input.installed) {
|
||||
const reference = input.lastCronRunAt ?? input.installedAt;
|
||||
if (reference && minutesAgo(reference) >= STALE_AFTER_MINUTES) {
|
||||
reasons.push(
|
||||
input.lastCronRunAt
|
||||
? `The notification processor hasn't run for ${minutesAgo(input.lastCronRunAt)} minutes — emails and push notifications are not being sent.`
|
||||
: "The notification processor has never run — emails and push notifications are not being sent.",
|
||||
);
|
||||
}
|
||||
}
|
||||
if (input.queue.stalePendingCount > 0) {
|
||||
reasons.push(
|
||||
`${input.queue.stalePendingCount} notification(s) have been waiting more than ${STALE_AFTER_MINUTES} minutes to send.`,
|
||||
);
|
||||
}
|
||||
if (input.queue.stuckProcessingCount > 0) {
|
||||
reasons.push(`${input.queue.stuckProcessingCount} notification(s) got stuck while sending.`);
|
||||
}
|
||||
// A handful of failures is normal (a mistyped address). Every one failing is a configuration problem: bad SMTP login, bad push keys.
|
||||
if (input.queue.failedLast24h >= 3 && input.queue.sentLast24h === 0) {
|
||||
reasons.push(
|
||||
`All ${input.queue.failedLast24h} notification deliveries in the last 24 hours failed — check the email and push settings.`,
|
||||
);
|
||||
}
|
||||
|
||||
return { status: reasons.length > 0 ? "degraded" : "healthy", degradedReasons: reasons };
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Prisma, PrismaClient } from "@/generated/prisma/client";
|
||||
import { NotificationChannel, NotificationDeliveryStatus, NotificationType } from "@/generated/prisma/enums";
|
||||
import { isSystemicFailureReason } from "./failure-reasons";
|
||||
import { notifySafely, resolveActivePushSubscriptions, resolveEmailAddress } from "./notification.service";
|
||||
import type { EmailProvider } from "./providers/email-provider";
|
||||
import type { PushProvider } from "./providers/push-provider";
|
||||
@@ -39,6 +40,9 @@ const BACKOFF_MINUTES = [1, 5, 30] as const;
|
||||
/** Failed deliveries for one business within a single processing run at or above this count raises OWNER_REPEATED_DELIVERY_FAILURE — see NotificationType in schema.prisma. */
|
||||
const REPEATED_FAILURE_THRESHOLD = 3;
|
||||
|
||||
/** A delivery claimed this long ago that never finished belongs to a worker that died mid-send. */
|
||||
const STUCK_PROCESSING_AFTER_MINUTES = 10;
|
||||
|
||||
export interface ProcessNotificationsResult {
|
||||
claimed: number;
|
||||
sent: number;
|
||||
@@ -130,6 +134,17 @@ export async function processDueNotificationDeliveries(
|
||||
const now = params.now ?? new Date();
|
||||
const batchSize = params.batchSize ?? 25;
|
||||
|
||||
// A worker that crashed mid-send leaves its delivery PROCESSING forever, and nothing else
|
||||
// ever picks it up. Put those back in the queue (a send takes seconds; 10 minutes is a corpse).
|
||||
await prisma.notificationDelivery.updateMany({
|
||||
where: {
|
||||
channel: { in: [NotificationChannel.EMAIL, NotificationChannel.PUSH] },
|
||||
status: NotificationDeliveryStatus.PROCESSING,
|
||||
lastAttemptedAt: { lt: new Date(now.getTime() - STUCK_PROCESSING_AFTER_MINUTES * 60_000) },
|
||||
},
|
||||
data: { status: NotificationDeliveryStatus.PENDING },
|
||||
});
|
||||
|
||||
const due = await prisma.notificationDelivery.findMany({
|
||||
where: {
|
||||
channel: { in: [NotificationChannel.EMAIL, NotificationChannel.PUSH] },
|
||||
@@ -152,6 +167,8 @@ export async function processDueNotificationDeliveries(
|
||||
inAppReleased: 0,
|
||||
};
|
||||
const failuresByBusiness = new Map<string, number>();
|
||||
const systemicFailureBusinesses = new Set<string>();
|
||||
|
||||
|
||||
const inApp = await releaseDueInAppReminders(prisma, now);
|
||||
result.inAppReleased = inApp.released;
|
||||
@@ -230,11 +247,12 @@ export async function processDueNotificationDeliveries(
|
||||
delivery.notification.businessId,
|
||||
(failuresByBusiness.get(delivery.notification.businessId) ?? 0) + 1,
|
||||
);
|
||||
if (isSystemicFailureReason(outcome.reason)) systemicFailureBusinesses.add(delivery.notification.businessId);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [businessId, count] of failuresByBusiness) {
|
||||
if (count >= REPEATED_FAILURE_THRESHOLD) {
|
||||
if (count >= REPEATED_FAILURE_THRESHOLD || systemicFailureBusinesses.has(businessId)) {
|
||||
await raiseRepeatedFailureNotice(prisma, businessId, now);
|
||||
}
|
||||
}
|
||||
@@ -266,6 +284,7 @@ async function processEmailDelivery(prisma: PrismaClient, delivery: DueDelivery,
|
||||
const sendResult = await emailProvider.sendEmail({
|
||||
to: { email: recipientEmail },
|
||||
sender: email.sender,
|
||||
replyTo: email.replyTo,
|
||||
subject: email.subject,
|
||||
html: email.html,
|
||||
text: email.text,
|
||||
@@ -353,7 +372,7 @@ async function raiseRepeatedFailureNotice(prisma: PrismaClient, businessId: stri
|
||||
recipientUserId: ownerUserId,
|
||||
subject: "Some notifications failed to send",
|
||||
bodyText: "Several notifications failed to send today. Check your notification settings, Brevo configuration, and push setup.",
|
||||
deepLinkPath: "/owner/notifications",
|
||||
deepLinkPath: "/staff/settings/notifications",
|
||||
idempotencyKey: `notify:owner-repeated-delivery-failure:${businessId}:${dayStamp}`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Prisma, PrismaClient } from "@/generated/prisma/client";
|
||||
import { NotificationType } from "@/generated/prisma/enums";
|
||||
import { recipientKindOf } from "./types";
|
||||
import {
|
||||
renderBookingCancelledEmail,
|
||||
renderBookingConfirmedEmail,
|
||||
@@ -21,6 +22,44 @@ import type { EmailSender } from "./providers/email-provider";
|
||||
export interface RenderedEmailWithSender extends RenderedEmail {
|
||||
/** Resolved fresh on every call from the notification's own business — see resolveSenderName. */
|
||||
sender: EmailSender;
|
||||
/** The business's own email, so a customer's reply reaches the shop. Undefined when the business hasn't set one. */
|
||||
replyTo?: string;
|
||||
}
|
||||
|
||||
/** Button text per notification type for emails that use the generic template. */
|
||||
const GENERIC_ACTION_LABEL: Partial<Record<NotificationType, string>> = {
|
||||
BOOKING_APPROVED: "View Appointment",
|
||||
BOOKING_BARBER_CHANGED: "View Appointment",
|
||||
LOYALTY_REWARD_EXPIRING: "View Loyalty Card",
|
||||
STAFF_NEW_APPOINTMENT: "View Appointment",
|
||||
STAFF_APPOINTMENT_RESCHEDULED: "View Appointment",
|
||||
STAFF_APPOINTMENT_CANCELLED: "View Appointment",
|
||||
STAFF_APPOINTMENT_REMINDER: "View Appointment",
|
||||
STAFF_SCHEDULE_CHANGED: "View Schedule",
|
||||
STAFF_INVITATION_RECEIVED: "Accept Invitation",
|
||||
STAFF_WHATSAPP_REMINDERS_DUE: "Open Reminders",
|
||||
OWNER_NEW_BOOKING: "View Booking",
|
||||
OWNER_BOOKING_CANCELLED: "View Booking",
|
||||
OWNER_NO_SHOW: "View Booking",
|
||||
OWNER_STAFF_INVITATION_ACCEPTED: "View Team",
|
||||
OWNER_REPEATED_DELIVERY_FAILURE: "Check Notification Settings",
|
||||
};
|
||||
|
||||
/** Where this recipient manages their notification choices — guests have no account, so no link. */
|
||||
function preferencesPathFor(notification: { type: NotificationType; recipientUserId: string | null }): string | undefined {
|
||||
if (!notification.recipientUserId) return undefined;
|
||||
return recipientKindOf(notification.type) === "CUSTOMER" ? "/settings/notifications" : "/staff/settings/notifications";
|
||||
}
|
||||
|
||||
/** The .ics download for an appointment, carrying the same guest token as the manage link so it works without signing in. */
|
||||
function calendarPathFor(appointmentId: string, manageAppointmentPath: string): string {
|
||||
let token: string | null = null;
|
||||
try {
|
||||
token = new URL(manageAppointmentPath, "http://placeholder.invalid").searchParams.get("token");
|
||||
} catch {
|
||||
token = null;
|
||||
}
|
||||
return `/api/appointments/${appointmentId}/calendar.ics${token ? `?token=${encodeURIComponent(token)}` : ""}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -61,6 +100,8 @@ export async function renderEmailForNotification(
|
||||
if (!business) return null;
|
||||
|
||||
const sender: EmailSender = { name: resolveSenderName(business.name) };
|
||||
const replyTo = business.email?.trim() || undefined;
|
||||
const format = { timezone: business.timezone, currency: business.currency, locale: business.locale };
|
||||
|
||||
const businessInfo = {
|
||||
name: business.name,
|
||||
@@ -70,11 +111,12 @@ export async function renderEmailForNotification(
|
||||
: undefined,
|
||||
phone: business.phone ?? undefined,
|
||||
cancellationPolicy: business.settings?.cancellationPolicy ?? undefined,
|
||||
themeColor: business.themeColor ?? undefined,
|
||||
};
|
||||
|
||||
const recipientName = await resolveRecipientName(prisma, notification);
|
||||
const rendered = await renderBody();
|
||||
return { ...rendered, sender };
|
||||
return { ...rendered, sender, replyTo };
|
||||
|
||||
async function renderBody(): Promise<RenderedEmail> {
|
||||
switch (notification.type) {
|
||||
@@ -83,11 +125,19 @@ export async function renderEmailForNotification(
|
||||
case NotificationType.BOOKING_RESCHEDULED:
|
||||
case NotificationType.BOOKING_CANCELLED:
|
||||
case NotificationType.BOOKING_REMINDER: {
|
||||
const data = await buildAppointmentEmailData(prisma, notification, {
|
||||
const manageAppointmentPath = notification.deepLinkPath ?? "/appointments";
|
||||
const data = await buildAppointmentEmailData(
|
||||
prisma,
|
||||
notification,
|
||||
{
|
||||
business: businessInfo,
|
||||
recipientName,
|
||||
manageAppointmentPath: notification.deepLinkPath ?? "/appointments",
|
||||
});
|
||||
manageAppointmentPath,
|
||||
calendarPath: notification.appointmentId ? calendarPathFor(notification.appointmentId, manageAppointmentPath) : undefined,
|
||||
preferencesPath: preferencesPathFor(notification),
|
||||
},
|
||||
format,
|
||||
);
|
||||
if (!data) return renderFallback();
|
||||
if (notification.type === NotificationType.BOOKING_CONFIRMED)
|
||||
return renderBookingConfirmedEmail(data);
|
||||
@@ -123,6 +173,8 @@ export async function renderEmailForNotification(
|
||||
subject: notification.subject ?? businessInfo.name,
|
||||
bodyText: notification.bodyText,
|
||||
deepLinkPath: notification.deepLinkPath ?? undefined,
|
||||
actionLabel: GENERIC_ACTION_LABEL[notification.type],
|
||||
preferencesPath: preferencesPathFor(notification),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -152,7 +204,11 @@ async function resolveRecipientName(
|
||||
async function buildAppointmentEmailData(
|
||||
prisma: PrismaClient,
|
||||
notification: { appointmentId: string | null },
|
||||
base: Pick<AppointmentEmailData, "business" | "recipientName" | "manageAppointmentPath">,
|
||||
base: Pick<
|
||||
AppointmentEmailData,
|
||||
"business" | "recipientName" | "manageAppointmentPath" | "calendarPath" | "preferencesPath"
|
||||
>,
|
||||
format: { timezone: string; currency: string; locale: string },
|
||||
): Promise<AppointmentEmailData | null> {
|
||||
if (!notification.appointmentId) return null;
|
||||
const appointment = await prisma.appointment.findUnique({
|
||||
@@ -174,17 +230,21 @@ async function buildAppointmentEmailData(
|
||||
...base,
|
||||
staffName: appointment.staff.user.name ?? "your barber",
|
||||
serviceNames: appointment.services.map((s: { service: { name: string } }) => s.service.name),
|
||||
// Always in the business's own timezone: the server runs in UTC, so formatting without
|
||||
// one showed a 10:00 summer appointment as 09:00 in its own confirmation email.
|
||||
dateLabel: appointment.startAt.toLocaleDateString("en-GB", {
|
||||
weekday: "short",
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
timeZone: format.timezone,
|
||||
}),
|
||||
timeLabel: appointment.startAt.toLocaleTimeString("en-GB", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
timeZone: format.timezone,
|
||||
}),
|
||||
durationLabel: `${totalMinutes} min`,
|
||||
totalLabel: `£${totalPrice.toFixed(2)}`,
|
||||
totalLabel: formatMoney(totalPrice, format),
|
||||
appointmentRef: appointment.id.slice(-8),
|
||||
};
|
||||
}
|
||||
@@ -214,3 +274,12 @@ async function buildLoyaltyRewardEmailData(
|
||||
loyaltyCardPath: "/home",
|
||||
};
|
||||
}
|
||||
|
||||
/** The business's own currency and locale — a hard-coded "£" was wrong for any other currency. */
|
||||
export function formatMoney(amount: number, format: { currency: string; locale: string }): string {
|
||||
try {
|
||||
return new Intl.NumberFormat(format.locale, { style: "currency", currency: format.currency }).format(amount);
|
||||
} catch {
|
||||
return `${format.currency} ${amount.toFixed(2)}`;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { escapeHtml } from "../layout";
|
||||
import { escapeHtml, normalizeAccentColor, readableTextOn, renderEmailLayout } from "../layout";
|
||||
import {
|
||||
renderBookingCancelledEmail,
|
||||
renderBookingConfirmedEmail,
|
||||
@@ -9,9 +9,11 @@ import {
|
||||
} from "../booking-emails";
|
||||
import type { AppointmentEmailData } from "../booking-emails";
|
||||
import { renderGenericNotificationEmail } from "../generic-email";
|
||||
import { renderLoyaltyRewardEarnedEmail, renderLoyaltyRewardRedeemedEmail } from "../loyalty-emails";
|
||||
import {
|
||||
renderLoyaltyRewardEarnedEmail,
|
||||
renderLoyaltyRewardRedeemedEmail,
|
||||
} from "../loyalty-emails";
|
||||
import type { LoyaltyRewardEmailData } from "../loyalty-emails";
|
||||
import { renderStaffInvitationEmail, renderStaffScheduleChangedEmail } from "../staff-emails";
|
||||
|
||||
describe("escapeHtml", () => {
|
||||
it("escapes all five HTML-significant characters", () => {
|
||||
@@ -21,7 +23,11 @@ describe("escapeHtml", () => {
|
||||
});
|
||||
});
|
||||
|
||||
const business = { name: "Blake Heritage Barbershop", address: "1 High St, London", phone: "020 1234 5678" };
|
||||
const business = {
|
||||
name: "Blake Heritage Barbershop",
|
||||
address: "1 High St, London",
|
||||
phone: "020 1234 5678",
|
||||
};
|
||||
|
||||
const appointmentData: AppointmentEmailData = {
|
||||
business,
|
||||
@@ -66,7 +72,9 @@ describe("booking email templates", () => {
|
||||
});
|
||||
|
||||
it("renderBookingCancelledEmail escapes a user-supplied cancellation reason", () => {
|
||||
const email = renderBookingCancelledEmail(appointmentData, { reason: `<img src=x onerror="alert(1)">` });
|
||||
const email = renderBookingCancelledEmail(appointmentData, {
|
||||
reason: `<img src=x onerror="alert(1)">`,
|
||||
});
|
||||
expect(email.html).not.toContain("<img src=x");
|
||||
expect(email.html).toContain("<img");
|
||||
});
|
||||
@@ -98,32 +106,6 @@ describe("loyalty email templates", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("staff email templates", () => {
|
||||
it("renderStaffInvitationEmail includes the accept link and role", () => {
|
||||
const email = renderStaffInvitationEmail({
|
||||
business,
|
||||
staffName: "Jamie Wallace",
|
||||
roleName: "Barber",
|
||||
invitedByName: "Marcus Reed",
|
||||
acceptInvitePath: "/invite/accept?token=abc",
|
||||
});
|
||||
expect(email.html).toContain("Barber");
|
||||
expect(email.html).toContain("/invite/accept?token=abc");
|
||||
});
|
||||
|
||||
it("renderStaffScheduleChangedEmail names who made the change", () => {
|
||||
const email = renderStaffScheduleChangedEmail({
|
||||
business,
|
||||
staffName: "Leo Vance",
|
||||
changedByName: "Marcus Reed",
|
||||
whatChanged: "your working hours",
|
||||
viewSchedulePath: "/staff/settings/working-hours",
|
||||
});
|
||||
expect(email.html).toContain("Marcus Reed");
|
||||
expect(email.html).toContain("your working hours");
|
||||
});
|
||||
});
|
||||
|
||||
describe("generic fallback template", () => {
|
||||
it("renders the notification's own subject/bodyText when no bespoke template exists", () => {
|
||||
const email = renderGenericNotificationEmail({
|
||||
@@ -148,3 +130,97 @@ describe("generic fallback template", () => {
|
||||
expect(email.html).not.toContain("View Details");
|
||||
});
|
||||
});
|
||||
|
||||
describe("email layout", () => {
|
||||
const layout = (overrides: Partial<Parameters<typeof renderEmailLayout>[0]> = {}) =>
|
||||
renderEmailLayout({
|
||||
businessName: "Blake Heritage",
|
||||
headline: "Hello",
|
||||
bodyHtml: "<p>Body</p>",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it("is a mobile-friendly, dark-mode-aware document", () => {
|
||||
const html = layout();
|
||||
expect(html).toContain('<html lang="en">');
|
||||
expect(html).toContain('name="viewport"');
|
||||
expect(html).toContain('name="color-scheme" content="light dark"');
|
||||
expect(html).toContain("prefers-color-scheme: dark");
|
||||
expect(html).toContain("<title>Hello</title>");
|
||||
});
|
||||
|
||||
it("shows the preheader as hidden inbox preview text, escaped", () => {
|
||||
const html = layout({ preheader: "Mon 3 Aug <at> 16:30" });
|
||||
expect(html).toContain("display:none");
|
||||
expect(html).toContain("Mon 3 Aug <at> 16:30");
|
||||
});
|
||||
|
||||
it("renders details in a card and escapes their values", () => {
|
||||
const html = layout({ details: [{ label: "Barber", value: "Marcus <Reed>" }] });
|
||||
expect(html).toContain("Barber");
|
||||
expect(html).toContain("Marcus <Reed>");
|
||||
expect(html).not.toContain("Marcus <Reed>");
|
||||
});
|
||||
|
||||
it("uses the business colour for the button with readable text on it", () => {
|
||||
const dark = layout({
|
||||
actionLabel: "Go",
|
||||
actionUrl: "https://x.test/a",
|
||||
accentColor: "#1f3440",
|
||||
});
|
||||
expect(dark).toContain("background:#1f3440");
|
||||
expect(dark).toContain("color:#ffffff");
|
||||
const light = layout({
|
||||
actionLabel: "Go",
|
||||
actionUrl: "https://x.test/a",
|
||||
accentColor: "#e9c98f",
|
||||
});
|
||||
expect(light).toContain("background:#e9c98f");
|
||||
expect(light).toContain("color:#111111");
|
||||
});
|
||||
|
||||
it("never lets a non-hex colour into an inline style", () => {
|
||||
expect(normalizeAccentColor("red;background:url(x)")).toBe("#171717");
|
||||
expect(normalizeAccentColor("#ABCDEF")).toBe("#abcdef");
|
||||
expect(normalizeAccentColor(undefined)).toBe("#171717");
|
||||
expect(readableTextOn("#ffffff")).toBe("#111111");
|
||||
expect(readableTextOn("#000000")).toBe("#ffffff");
|
||||
});
|
||||
|
||||
it("lists secondary and footer links in both the HTML and the plain-text version", () => {
|
||||
const email = renderBookingConfirmedEmail({
|
||||
...appointmentData,
|
||||
calendarPath: "/api/appointments/abc123/calendar.ics?token=raw",
|
||||
preferencesPath: "/settings/notifications",
|
||||
});
|
||||
expect(email.html).toContain("Add to calendar");
|
||||
expect(email.html).toContain("View on map");
|
||||
expect(email.html).toContain("Manage notification preferences");
|
||||
expect(email.text).toContain("Add to calendar:");
|
||||
expect(email.text).toContain("google.com/maps");
|
||||
expect(email.text).toContain("/settings/notifications");
|
||||
});
|
||||
|
||||
it("leaves out the calendar and map links on a cancelled booking, and the preferences link for a guest", () => {
|
||||
const email = renderBookingCancelledEmail({
|
||||
...appointmentData,
|
||||
calendarPath: "/api/appointments/abc123/calendar.ics",
|
||||
});
|
||||
expect(email.html).not.toContain("Add to calendar");
|
||||
expect(email.html).not.toContain("View on map");
|
||||
expect(email.html).not.toContain("Manage notification preferences");
|
||||
});
|
||||
|
||||
it("uses a fitting button label for the generic template when one is given", () => {
|
||||
const email = renderGenericNotificationEmail({
|
||||
business,
|
||||
recipientName: "Jamie",
|
||||
subject: "You've been invited",
|
||||
bodyText: "Marcus invited you.",
|
||||
deepLinkPath: "/invite/accept?token=abc",
|
||||
actionLabel: "Accept Invitation",
|
||||
});
|
||||
expect(email.html).toContain("Accept Invitation");
|
||||
expect(email.text).toContain("Accept Invitation:");
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { detailRow, paragraph, renderEmailLayout, renderPlainTextLayout } from "./layout";
|
||||
import { absoluteUrl, footerNoteHtml, footerNoteText } from "./shared";
|
||||
import { paragraph, renderEmailLayout, renderPlainTextLayout } from "./layout";
|
||||
import type { EmailDetail, EmailLink } from "./layout";
|
||||
import { absoluteUrl, footerNoteHtml, footerNoteText, mapUrl } from "./shared";
|
||||
import type { BusinessEmailInfo, RenderedEmail } from "./shared";
|
||||
|
||||
export interface AppointmentEmailData {
|
||||
@@ -12,31 +13,53 @@ export interface AppointmentEmailData {
|
||||
durationLabel: string;
|
||||
totalLabel: string;
|
||||
appointmentRef: string;
|
||||
/** Relative path — e.g. "/appointments/xyz?token=..." for a guest, "/appointments" for a registered customer. */
|
||||
/** Relative path — e.g. "/appointments/xyz?token=[REDACTED] for a guest, "/appointments" for a registered customer. */
|
||||
manageAppointmentPath: string;
|
||||
/** Relative path of the .ics download for this appointment (same guest token as the manage link) — omit to leave the "Add to calendar" link out. */
|
||||
calendarPath?: string;
|
||||
/** Relative path of this recipient's notification settings — omit for guests, who have no account to manage. */
|
||||
preferencesPath?: string;
|
||||
}
|
||||
|
||||
function renderAppointmentEmail(
|
||||
data: AppointmentEmailData,
|
||||
copy: { subject: string; headline: string; intro: string; extraNote?: string; actionLabel?: string },
|
||||
copy: {
|
||||
subject: string;
|
||||
headline: string;
|
||||
intro: string;
|
||||
extraNote?: string;
|
||||
actionLabel?: string;
|
||||
/** Whether the appointment is still going ahead — a cancelled one gets no calendar link. */
|
||||
live?: boolean;
|
||||
},
|
||||
): RenderedEmail {
|
||||
const actionUrl = absoluteUrl(data.manageAppointmentPath);
|
||||
const actionLabel = copy.actionLabel ?? "View Appointment";
|
||||
|
||||
const detailsHtml = [
|
||||
detailRow("Service", data.serviceNames.join(", ")),
|
||||
detailRow("Barber", data.staffName),
|
||||
detailRow("Date", data.dateLabel),
|
||||
detailRow("Time", data.timeLabel),
|
||||
detailRow("Duration", data.durationLabel),
|
||||
detailRow("Total", data.totalLabel),
|
||||
detailRow("Reference", data.appointmentRef),
|
||||
].join("");
|
||||
const details: EmailDetail[] = [
|
||||
{ label: "Service", value: data.serviceNames.join(", ") },
|
||||
{ label: "Barber", value: data.staffName },
|
||||
{ label: "Date", value: data.dateLabel },
|
||||
{ label: "Time", value: data.timeLabel },
|
||||
{ label: "Duration", value: data.durationLabel },
|
||||
{ label: "Total", value: data.totalLabel },
|
||||
{ label: "Reference", value: data.appointmentRef },
|
||||
];
|
||||
|
||||
const secondaryLinks: EmailLink[] = [];
|
||||
if (data.calendarPath && copy.live !== false)
|
||||
secondaryLinks.push({ label: "Add to calendar", url: absoluteUrl(data.calendarPath) });
|
||||
if (data.business.address && copy.live !== false)
|
||||
secondaryLinks.push({ label: "View on map", url: mapUrl(data.business.address) });
|
||||
|
||||
const footerLinks: EmailLink[] = data.preferencesPath
|
||||
? [{ label: "Manage notification preferences", url: absoluteUrl(data.preferencesPath) }]
|
||||
: [];
|
||||
|
||||
const preheader = `${data.dateLabel} at ${data.timeLabel} with ${data.staffName} · ${data.serviceNames.join(", ")}`;
|
||||
const bodyHtml = [
|
||||
paragraph(`Hi ${data.recipientName},`),
|
||||
paragraph(copy.intro),
|
||||
detailsHtml,
|
||||
copy.extraNote ? paragraph(copy.extraNote) : "",
|
||||
].join("");
|
||||
|
||||
@@ -45,9 +68,14 @@ function renderAppointmentEmail(
|
||||
businessLogoUrl: data.business.logoUrl,
|
||||
headline: copy.headline,
|
||||
bodyHtml,
|
||||
details,
|
||||
actionLabel,
|
||||
actionUrl,
|
||||
secondaryLinks,
|
||||
accentColor: data.business.themeColor,
|
||||
preheader,
|
||||
footerNote: footerNoteHtml(data.business),
|
||||
footerLinks,
|
||||
});
|
||||
|
||||
const text = renderPlainTextLayout({
|
||||
@@ -57,19 +85,14 @@ function renderAppointmentEmail(
|
||||
`Hi ${data.recipientName},`,
|
||||
"",
|
||||
copy.intro,
|
||||
"",
|
||||
`Service: ${data.serviceNames.join(", ")}`,
|
||||
`Barber: ${data.staffName}`,
|
||||
`Date: ${data.dateLabel}`,
|
||||
`Time: ${data.timeLabel}`,
|
||||
`Duration: ${data.durationLabel}`,
|
||||
`Total: ${data.totalLabel}`,
|
||||
`Reference: ${data.appointmentRef}`,
|
||||
...(copy.extraNote ? ["", copy.extraNote] : []),
|
||||
],
|
||||
details,
|
||||
actionLabel,
|
||||
actionUrl,
|
||||
secondaryLinks,
|
||||
footerNote: footerNoteText(data.business),
|
||||
footerLinks,
|
||||
});
|
||||
|
||||
return { subject: copy.subject, html, text };
|
||||
@@ -113,6 +136,7 @@ export function renderBookingCancelledEmail(
|
||||
? `Reason: ${options.reason}`
|
||||
: undefined,
|
||||
actionLabel: "Book Another Appointment",
|
||||
live: false,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,10 @@ export interface GenericNotificationEmailData {
|
||||
/** The Notification's own canonical bodyText (see Notification.bodyText in schema.prisma) — this template is the fallback for any type without a bespoke renderer, so it has no more structured data than that. */
|
||||
bodyText: string;
|
||||
deepLinkPath?: string;
|
||||
/** Button text — defaults to "View Details"; render-email.ts picks a fitting one per notification type. */
|
||||
actionLabel?: string;
|
||||
/** Relative path of the recipient's notification settings, for the footer link. */
|
||||
preferencesPath?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -23,6 +27,10 @@ export interface GenericNotificationEmailData {
|
||||
export function renderGenericNotificationEmail(data: GenericNotificationEmailData): RenderedEmail {
|
||||
const actionUrl = data.deepLinkPath ? absoluteUrl(data.deepLinkPath) : undefined;
|
||||
const bodyHtml = [paragraph(`Hi ${data.recipientName},`), paragraph(data.bodyText)].join("");
|
||||
const actionLabel = actionUrl ? (data.actionLabel ?? "View Details") : undefined;
|
||||
const footerLinks = data.preferencesPath
|
||||
? [{ label: "Manage notification preferences", url: absoluteUrl(data.preferencesPath) }]
|
||||
: [];
|
||||
|
||||
return {
|
||||
subject: data.subject,
|
||||
@@ -31,17 +39,21 @@ export function renderGenericNotificationEmail(data: GenericNotificationEmailDat
|
||||
businessLogoUrl: data.business.logoUrl,
|
||||
headline: data.subject,
|
||||
bodyHtml,
|
||||
actionLabel: actionUrl ? "View Details" : undefined,
|
||||
actionLabel,
|
||||
actionUrl,
|
||||
accentColor: data.business.themeColor,
|
||||
preheader: data.bodyText,
|
||||
footerNote: footerNoteHtml(data.business),
|
||||
footerLinks,
|
||||
}),
|
||||
text: renderPlainTextLayout({
|
||||
businessName: data.business.name,
|
||||
headline: data.subject,
|
||||
bodyLines: [`Hi ${data.recipientName},`, "", data.bodyText],
|
||||
actionLabel: actionUrl ? "View Details" : undefined,
|
||||
actionLabel,
|
||||
actionUrl,
|
||||
footerNote: footerNoteText(data.business),
|
||||
footerLinks,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -17,6 +17,17 @@ export function escapeHtml(value: string): string {
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
export interface EmailDetail {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface EmailLink {
|
||||
label: string;
|
||||
/** Absolute URL. */
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface EmailLayoutInput {
|
||||
businessName: string;
|
||||
/** Absolute URL — email clients can't resolve a relative path the way a browser can. Omitted entirely (not a broken `<img>`) if not supplied. */
|
||||
@@ -24,10 +35,83 @@ export interface EmailLayoutInput {
|
||||
headline: string;
|
||||
/** Pre-escaped/pre-built HTML fragment for the main content — see the per-type renderers, which build this from escaped pieces. */
|
||||
bodyHtml: string;
|
||||
/** Label/value rows shown in a shaded card under the body (date, time, barber…). Plain text — escaped here. */
|
||||
details?: EmailDetail[];
|
||||
actionLabel?: string;
|
||||
/** Absolute URL — see NEXT_PUBLIC_APP_URL / renderAbsoluteLink in index.ts. */
|
||||
actionUrl?: string;
|
||||
/** Lower-emphasis text links under the button ("Add to calendar", "View on map"). */
|
||||
secondaryLinks?: EmailLink[];
|
||||
/** The business's brand colour (#rrggbb) for the button; anything else falls back to near-black. */
|
||||
accentColor?: string;
|
||||
/** The grey line inbox lists show after the subject. Plain text — escaped here. */
|
||||
preheader?: string;
|
||||
footerNote?: string;
|
||||
/** Small links in the footer, e.g. "Manage notification preferences". */
|
||||
footerLinks?: EmailLink[];
|
||||
}
|
||||
|
||||
const DEFAULT_ACCENT = "#171717";
|
||||
|
||||
/** `#rrggbb` only — the value ends up in an inline style, so nothing else is allowed through. */
|
||||
export function normalizeAccentColor(input: string | undefined | null): string {
|
||||
return input && /^#[0-9a-fA-F]{6}$/.test(input.trim())
|
||||
? input.trim().toLowerCase()
|
||||
: DEFAULT_ACCENT;
|
||||
}
|
||||
|
||||
/** Black or white text, whichever reads better on `hex` (WCAG relative luminance). */
|
||||
export function readableTextOn(hex: string): "#ffffff" | "#111111" {
|
||||
const channel = (offset: number) => {
|
||||
const value = parseInt(hex.slice(offset, offset + 2), 16) / 255;
|
||||
return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
|
||||
};
|
||||
const luminance = 0.2126 * channel(1) + 0.7152 * channel(3) + 0.0722 * channel(5);
|
||||
return luminance > 0.4 ? "#111111" : "#ffffff";
|
||||
}
|
||||
|
||||
/** Dark mode on top of inline light styles: clients that honour prefers-color-scheme swap the palette, the rest just show the light one. The accent button is left alone so it keeps the brand colour in both. */
|
||||
const STYLE_BLOCK = `<style>
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.nc-bg { background:#111111 !important; }
|
||||
.nc-card { background:#1c1c1e !important; }
|
||||
.nc-heading { color:#ffffff !important; }
|
||||
.nc-text { color:#e5e5e5 !important; }
|
||||
.nc-muted { color:#a3a3a3 !important; }
|
||||
.nc-details { background:#2a2a2d !important; }
|
||||
.nc-rule { border-color:#3a3a3c !important; }
|
||||
.nc-link { color:#e5e5e5 !important; }
|
||||
.nc-btn { border-color:#8a8a8f !important; }
|
||||
}
|
||||
@media only screen and (max-width: 520px) {
|
||||
.nc-card { padding:24px 20px !important; }
|
||||
}
|
||||
</style>`;
|
||||
|
||||
function detailsCard(details: EmailDetail[] | undefined): string {
|
||||
if (!details || details.length === 0) return "";
|
||||
const rows = details
|
||||
.map(
|
||||
(d) => `<tr>
|
||||
<td class="nc-muted" style="padding:6px 16px 6px 0;font-size:12px;color:#737373;vertical-align:top;white-space:nowrap;">${escapeHtml(d.label)}</td>
|
||||
<td class="nc-heading" style="padding:6px 0;font-size:14px;font-weight:600;color:#171717;">${escapeHtml(d.value)}</td>
|
||||
</tr>`,
|
||||
)
|
||||
.join("");
|
||||
return `<tr><td style="padding:8px 0 0;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" class="nc-details" style="background:#f5f5f4;border-radius:12px;">
|
||||
<tr><td style="padding:12px 18px;"><table role="presentation" cellpadding="0" cellspacing="0" width="100%">${rows}</table></td></tr>
|
||||
</table>
|
||||
</td></tr>`;
|
||||
}
|
||||
|
||||
function linkList(links: EmailLink[] | undefined, separator: string): string {
|
||||
return (links ?? [])
|
||||
.map(
|
||||
(l) =>
|
||||
`<a class="nc-link" href="${escapeHtml(l.url)}" style="color:#404040;text-decoration:underline;">${escapeHtml(l.label)}</a>`,
|
||||
)
|
||||
.join(separator);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -38,32 +122,59 @@ export interface EmailLayoutInput {
|
||||
* blocked, which is the default in most mail clients).
|
||||
*/
|
||||
export function renderEmailLayout(input: EmailLayoutInput): string {
|
||||
const accent = normalizeAccentColor(input.accentColor);
|
||||
const logo = input.businessLogoUrl
|
||||
? `<img src="${escapeHtml(input.businessLogoUrl)}" alt="${escapeHtml(input.businessName)}" height="32" style="height:32px;vertical-align:middle;margin-right:8px;" />`
|
||||
: "";
|
||||
|
||||
const preheader = input.preheader
|
||||
? `<div style="display:none;max-height:0;overflow:hidden;opacity:0;mso-hide:all;font-size:1px;line-height:1px;color:#f5f5f4;">${escapeHtml(input.preheader)}${" ‌".repeat(40)}</div>`
|
||||
: "";
|
||||
|
||||
const action =
|
||||
input.actionLabel && input.actionUrl
|
||||
? `<tr><td style="padding:24px 0 0;">
|
||||
<a href="${escapeHtml(input.actionUrl)}" style="display:inline-block;background:#171717;color:#ffffff;text-decoration:none;padding:12px 24px;border-radius:9999px;font-weight:bold;font-size:14px;">${escapeHtml(input.actionLabel)}</a>
|
||||
? `<tr><td style="padding:24px 0 ${input.secondaryLinks && input.secondaryLinks.length > 0 ? 0 : 24}px;">
|
||||
<a class="nc-btn" href="${escapeHtml(input.actionUrl)}" style="display:inline-block;background:${accent};border:1px solid ${accent};color:${readableTextOn(accent)};text-decoration:none;padding:13px 26px;border-radius:9999px;font-weight:bold;font-size:14px;">${escapeHtml(input.actionLabel)}</a>
|
||||
</td></tr>`
|
||||
: "";
|
||||
|
||||
const footer = input.footerNote
|
||||
? `<tr><td style="padding-top:24px;border-top:1px solid #e5e5e5;color:#737373;font-size:12px;line-height:1.5;">${input.footerNote}</td></tr>`
|
||||
const secondary =
|
||||
input.secondaryLinks && input.secondaryLinks.length > 0
|
||||
? `<tr><td class="nc-muted" style="padding:14px 0 24px;font-size:13px;color:#737373;">${linkList(input.secondaryLinks, " · ")}</td></tr>`
|
||||
: "";
|
||||
|
||||
const footerParts = [
|
||||
input.footerNote ? `<div>${input.footerNote}</div>` : "",
|
||||
input.footerLinks && input.footerLinks.length > 0
|
||||
? `<div style="margin-top:8px;">${linkList(input.footerLinks, " · ")}</div>`
|
||||
: "",
|
||||
].join("");
|
||||
const footer = footerParts
|
||||
? `<tr><td class="nc-muted nc-rule" style="padding-top:24px;border-top:1px solid #e5e5e5;color:#737373;font-size:12px;line-height:1.5;">${footerParts}</td></tr>`
|
||||
: "";
|
||||
|
||||
return `<!doctype html>
|
||||
<html>
|
||||
<body style="margin:0;padding:0;background:#f5f5f4;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f5f5f4;padding:24px 0;">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<meta name="supported-color-schemes" content="light dark" />
|
||||
<title>${escapeHtml(input.headline)}</title>
|
||||
${STYLE_BLOCK}
|
||||
</head>
|
||||
<body class="nc-bg" style="margin:0;padding:0;background:#f5f5f4;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
|
||||
${preheader}
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" class="nc-bg" style="background:#f5f5f4;padding:24px 0;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table role="presentation" width="480" cellpadding="0" cellspacing="0" style="background:#ffffff;border-radius:16px;padding:32px;max-width:480px;width:100%;">
|
||||
<tr><td style="padding-bottom:16px;font-size:16px;font-weight:bold;color:#171717;">${logo}${escapeHtml(input.businessName)}</td></tr>
|
||||
<tr><td style="font-size:20px;font-weight:bold;color:#171717;padding-bottom:12px;">${escapeHtml(input.headline)}</td></tr>
|
||||
<tr><td style="font-size:14px;line-height:1.6;color:#404040;">${input.bodyHtml}</td></tr>
|
||||
<td align="center" style="padding:0 12px;">
|
||||
<table role="presentation" width="520" cellpadding="0" cellspacing="0" class="nc-card" style="background:#ffffff;border-radius:16px;padding:32px;max-width:520px;width:100%;">
|
||||
<tr><td class="nc-heading" style="padding-bottom:16px;font-size:16px;font-weight:bold;color:#171717;">${logo}${escapeHtml(input.businessName)}</td></tr>
|
||||
<tr><td class="nc-heading" style="font-size:22px;line-height:1.3;font-weight:bold;color:#171717;padding-bottom:12px;">${escapeHtml(input.headline)}</td></tr>
|
||||
<tr><td class="nc-text" style="font-size:14px;line-height:1.6;color:#404040;">${input.bodyHtml}</td></tr>
|
||||
${detailsCard(input.details)}
|
||||
${action}
|
||||
${secondary}
|
||||
${footer}
|
||||
</table>
|
||||
</td>
|
||||
@@ -77,19 +188,27 @@ export interface PlainTextLayoutInput {
|
||||
businessName: string;
|
||||
headline: string;
|
||||
bodyLines: string[];
|
||||
details?: EmailDetail[];
|
||||
actionLabel?: string;
|
||||
actionUrl?: string;
|
||||
secondaryLinks?: EmailLink[];
|
||||
footerNote?: string;
|
||||
footerLinks?: EmailLink[];
|
||||
}
|
||||
|
||||
export function renderPlainTextLayout(input: PlainTextLayoutInput): string {
|
||||
const lines = [input.businessName, "", input.headline, "", ...input.bodyLines];
|
||||
if (input.details && input.details.length > 0) {
|
||||
lines.push("", ...input.details.map((d) => `${d.label}: ${d.value}`));
|
||||
}
|
||||
if (input.actionLabel && input.actionUrl) {
|
||||
lines.push("", `${input.actionLabel}: ${input.actionUrl}`);
|
||||
}
|
||||
for (const link of input.secondaryLinks ?? []) lines.push(`${link.label}: ${link.url}`);
|
||||
if (input.footerNote) {
|
||||
lines.push("", input.footerNote);
|
||||
}
|
||||
for (const link of input.footerLinks ?? []) lines.push(`${link.label}: ${link.url}`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
@@ -98,7 +217,7 @@ export function paragraph(text: string): string {
|
||||
return `<p style="margin:0 0 12px;">${escapeHtml(text)}</p>`;
|
||||
}
|
||||
|
||||
/** A label/value row — "Date: Mon 3 Aug", "Barber: Marcus Reed", etc. */
|
||||
/** A label/value row — "Date: Mon 3 Aug", "Barber: Marcus Reed", etc. Prefer the `details` card on renderEmailLayout; kept for templates that interleave rows with prose. */
|
||||
export function detailRow(label: string, value: string): string {
|
||||
return `<p style="margin:0 0 4px;"><strong>${escapeHtml(label)}:</strong> ${escapeHtml(value)}</p>`;
|
||||
}
|
||||
Loaded 100 of 116 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user