Notifications
CI / ci (push) Canceled after 0s

Added a cron job processor so emails and notifications can get processed.

Added super admin settings with a gui to edit.
This commit is contained in:
Stefan Lazic committed 2026-09-11 17:46:28 +01:00
1 parent cbeccc6c1f
commit 1a76d1cacb
17 files changed
+645 -48

No files matched your search

+12 -7
View File
@@ -49,18 +49,23 @@ NEXT_PUBLIC_APP_URL="https://example-business.example.com"
# openssl rand -hex 32 # openssl rand -hex 32
INSTALLATION_TOKEN="replace-me" INSTALLATION_TOKEN="replace-me"
# Shared secret an external scheduler presents to # Shared secret an external scheduler presents to
# /api/cron/process-notifications — generate with: openssl rand -hex 32 # /api/cron/process-notifications and /api/cron/sync-google-reviews —
# generate with: openssl rand -hex 32. Without something actually calling
# those on a schedule, queued notifications (staff invites, reminders,
# etc.) never get sent — see the root README's "Notification delivery"
# section and stacks/infra/scripts/process-cron.sh for the one-time
# crontab setup this requires.
CRON_SECRET="replace-me" CRON_SECRET="replace-me"
# ── Super Administrator (optional — see scripts/ensure-super-admin.ts) ── # ── Super Administrator (optional — see scripts/ensure-super-admin.ts) ──
# Platform-level account with every Owner permission for THIS business. # Platform-level account with every Owner permission for THIS business.
# Not run automatically by this compose file (that's a Docker-entrypoint- # Runs automatically on every container start (see docker/entrypoint.sh)
# only behaviour, see docker/entrypoint.sh) — run it manually, once: # — filling these in and recreating the container is enough, no manual
# docker exec -it next-chair-example-business npx tsx scripts/ensure-super-admin.ts # `docker exec` needed. No password — signs in the same OTP way as
# Leave SUPER_ADMIN_EMAIL blank to skip entirely and use the /install # everyone else, via a one-time code emailed to SUPER_ADMIN_EMAIL. Leave
# wizard's own administrator account instead. # SUPER_ADMIN_EMAIL blank to skip entirely and use the /install wizard's
# own administrator account instead.
SUPER_ADMIN_EMAIL="" SUPER_ADMIN_EMAIL=""
SUPER_ADMIN_PASSWORD=""
SUPER_ADMIN_FIRST_NAME="" SUPER_ADMIN_FIRST_NAME=""
SUPER_ADMIN_LAST_NAME="" SUPER_ADMIN_LAST_NAME=""
+5 -5
View File
@@ -82,12 +82,12 @@ INSTALLATION_TOKEN=""
# Super Administrator (Phase 9) — platform-level account with every Owner # Super Administrator (Phase 9) — platform-level account with every Owner
# permission, provisioned from these env vars rather than the installation # permission, provisioned from these env vars rather than the installation
# wizard (see scripts/ensure-super-admin.ts, run on every container start). # wizard (see scripts/ensure-super-admin.ts, run on every container start).
# Has no StaffProfile by design: never appears in Team, the customer-facing # No password — signs in the same OTP way as everyone else, via a
# barber picker, or staff totals, and can never receive an appointment. All # one-time code emailed to SUPER_ADMIN_EMAIL. Has no StaffProfile by
# four optional — leave SUPER_ADMIN_EMAIL unset to skip provisioning one # design: never appears in Team, the customer-facing barber picker, or
# entirely. Re-run to rotate the password (re-hashed every run). # staff totals, and can never receive an appointment. All three optional
# — leave SUPER_ADMIN_EMAIL unset to skip provisioning one entirely.
SUPER_ADMIN_EMAIL="" SUPER_ADMIN_EMAIL=""
SUPER_ADMIN_PASSWORD=""
SUPER_ADMIN_FIRST_NAME="" SUPER_ADMIN_FIRST_NAME=""
SUPER_ADMIN_LAST_NAME="" SUPER_ADMIN_LAST_NAME=""
+32
View File
@@ -138,6 +138,38 @@ this for you), then finish its setup by visiting
Repeat this step for each additional business, whenever you want one. Repeat this step for each additional business, whenever you want one.
### 6. Notification delivery (required once, covers every business)
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.
[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. Add this to the VPS's crontab
(`crontab -e`) once:
```bash
# Deliver queued notifications every 2 minutes.
*/2 * * * * STACKS_DIR=/opt/nextchair/stacks /opt/nextchair/stacks/infra/scripts/process-cron.sh notifications >> /var/log/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 >> /var/log/nextchair-cron.log 2>&1
```
(Adjust `STACKS_DIR` if you cloned this repo somewhere other than
`/opt/nextchair`.) 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 `/var/log/nextchair-cron.log` — a healthy run logs `<slug>
process-notifications ok` for every business.
## Updating after a code change ## Updating after a code change
For an existing NextChair server, **do not re-run the fresh VPS setup above** and do not overwrite your production `.env` files. For an existing NextChair server, **do not re-run the fresh VPS setup above** and do not overwrite your production `.env` files.
-1
View File
@@ -25,7 +25,6 @@ export default function globalSetup() {
// when the caller hasn't already set their own (same fallback pattern // when the caller hasn't already set their own (same fallback pattern
// as AUTH_SECRET in playwright.config.ts). // as AUTH_SECRET in playwright.config.ts).
SUPER_ADMIN_EMAIL: process.env.SUPER_ADMIN_EMAIL || "superadmin@blakeheritage.co.uk", SUPER_ADMIN_EMAIL: process.env.SUPER_ADMIN_EMAIL || "superadmin@blakeheritage.co.uk",
SUPER_ADMIN_PASSWORD: process.env.SUPER_ADMIN_PASSWORD || "password123",
SUPER_ADMIN_FIRST_NAME: process.env.SUPER_ADMIN_FIRST_NAME || "Super", SUPER_ADMIN_FIRST_NAME: process.env.SUPER_ADMIN_FIRST_NAME || "Super",
SUPER_ADMIN_LAST_NAME: process.env.SUPER_ADMIN_LAST_NAME || "Admin", SUPER_ADMIN_LAST_NAME: process.env.SUPER_ADMIN_LAST_NAME || "Admin",
}; };
@@ -6,15 +6,19 @@ import {
generateVapidKeys, generateVapidKeys,
isEmailConfigComplete, isEmailConfigComplete,
isPushConfigComplete, isPushConfigComplete,
isSuperAdminConfigComplete,
readSuperAdminSettings,
recordLastTestResult, recordLastTestResult,
resolveEmailSettings, resolveEmailSettings,
resolvePushSettings, resolvePushSettings,
saveEmailSettings, saveEmailSettings,
savePushSubject, savePushSubject,
saveSuperAdminSettings,
VapidKeysAlreadyExistError, VapidKeysAlreadyExistError,
type EmailEncryptionMode, type EmailEncryptionMode,
type EmailSettingsInput, type EmailSettingsInput,
} from "@/lib/global-settings"; } from "@/lib/global-settings";
import { applySuperAdminToAllBusinesses, type SuperAdminRolloutResult } from "@/lib/super-admin-rollout";
import { sendTestSmtpEmail } from "@/lib/test-email"; import { sendTestSmtpEmail } from "@/lib/test-email";
function parseEncryption(value: FormDataEntryValue | null): EmailEncryptionMode { function parseEncryption(value: FormDataEntryValue | null): EmailEncryptionMode {
@@ -238,3 +242,59 @@ export async function reapplyPushSettingsAction(
const rollout = await applyPushSettingsToAllBusinesses(resolved); const rollout = await applyPushSettingsToAllBusinesses(resolved);
return { error: null, rollout }; return { error: null, rollout };
} }
// ── Super Administrator ─────────────────────────────────────────────────
export interface SaveSuperAdminState {
error: string | null;
saved: boolean;
rollout: SuperAdminRolloutResult[] | null;
}
/**
* Saves the global default Super Administrator and immediately rolls it
* out to every existing business — unlike email/push, there's no
* "incomplete config, save as a draft" case worth distinguishing (an
* email is either present or it isn't, no secondary field like a
* password gates completeness), so this always rolls out once the email
* itself validates. New businesses pick this up automatically at
* provisioning time (see provisioning.ts) — this action is what applies
* a change to businesses that already exist.
*/
export async function saveSuperAdminSettingsAction(
_prevState: SaveSuperAdminState,
formData: FormData,
): Promise<SaveSuperAdminState> {
const email = String(formData.get("email") ?? "").trim();
const firstName = String(formData.get("firstName") ?? "").trim();
const lastName = String(formData.get("lastName") ?? "").trim();
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
return { error: "Enter a valid email address.", saved: false, rollout: null };
}
const saved = await saveSuperAdminSettings({ email, firstName, lastName });
if (!isSuperAdminConfigComplete(saved)) {
return { error: null, saved: true, rollout: null };
}
const rollout = await applySuperAdminToAllBusinesses(saved);
return { error: null, saved: true, rollout };
}
export interface ReapplySuperAdminState {
error: string | null;
rollout: SuperAdminRolloutResult[] | null;
}
export async function reapplySuperAdminAction(
_prevState: ReapplySuperAdminState,
_formData: FormData,
): Promise<ReapplySuperAdminState> {
const saved = await readSuperAdminSettings();
if (!isSuperAdminConfigComplete(saved)) {
return { error: "No Super Administrator email is saved yet — nothing to reapply.", rollout: null };
}
const rollout = await applySuperAdminToAllBusinesses(saved);
return { error: null, rollout };
}
+7 -5
View File
@@ -1,19 +1,20 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { readEmailSettingsView, readPushSettingsView } from "@/lib/global-settings"; import { readEmailSettingsView, readPushSettingsView, readSuperAdminSettings } from "@/lib/global-settings";
import { EmailSettingsForm } from "./email-settings-form"; import { EmailSettingsForm } from "./email-settings-form";
import { PushSettingsForm } from "./push-settings-form"; import { PushSettingsForm } from "./push-settings-form";
import { SuperAdminSettingsForm } from "./super-admin-settings-form";
export const metadata: Metadata = { title: "Settings" }; export const metadata: Metadata = { title: "Settings" };
/** /**
* Platform-wide settings shared by every business on this installation — * Platform-wide settings shared by every business on this installation —
* see src/lib/global-settings.ts's own header for the storage design. * see src/lib/global-settings.ts's own header for the storage design.
* "Email" and "Push Notifications" exist today; a later section (SMS, * "Email", "Push Notifications", and "Super Administrator" exist today; a
* storage, backups, branding, locale, monitoring) is a sibling here, not * later section (SMS, storage, backups, branding, locale, monitoring) is
* a rewrite. * a sibling here, not a rewrite.
*/ */
export default async function SettingsPage() { export default async function SettingsPage() {
const [email, push] = await Promise.all([readEmailSettingsView(), readPushSettingsView()]); const [email, push, superAdmin] = await Promise.all([readEmailSettingsView(), readPushSettingsView(), readSuperAdminSettings()]);
return ( return (
<div className="space-y-8"> <div className="space-y-8">
@@ -23,6 +24,7 @@ export default async function SettingsPage() {
</div> </div>
<EmailSettingsForm initial={email} /> <EmailSettingsForm initial={email} />
<PushSettingsForm initial={push} /> <PushSettingsForm initial={push} />
<SuperAdminSettingsForm initial={superAdmin} />
</div> </div>
); );
} }
@@ -0,0 +1,151 @@
"use client";
import { useRouter } from "next/navigation";
import { useActionState, useEffect, useState } from "react";
import type { SuperAdminSettings } from "@/lib/global-settings";
import {
reapplySuperAdminAction,
saveSuperAdminSettingsAction,
type ReapplySuperAdminState,
type SaveSuperAdminState,
} from "./actions";
const saveInitialState: SaveSuperAdminState = { error: null, saved: false, rollout: null };
const reapplyInitialState: ReapplySuperAdminState = { error: null, rollout: null };
function StatusBadge({ initial, saveState }: { initial: SuperAdminSettings | null; saveState: SaveSuperAdminState }) {
const configured = (saveState.saved && !!saveState.rollout) || !!initial?.email;
return configured ? (
<span className="rounded-full bg-green-100 px-3 py-1 text-xs font-semibold text-green-800">Configured</span>
) : (
<span className="rounded-full bg-stone px-3 py-1 text-xs font-semibold text-muted">Not configured</span>
);
}
/** Controlled inputs — same reasoning as email-settings-form.tsx: two actions (Save / Reapply) share one field set, and an uncontrolled defaultValue input would reset after either resolves. */
export function SuperAdminSettingsForm({ initial }: { initial: SuperAdminSettings | null }) {
const router = useRouter();
const [saveState, saveAction, isSaving] = useActionState(saveSuperAdminSettingsAction, saveInitialState);
const [reapplyState, reapplyAction, isReapplying] = useActionState(reapplySuperAdminAction, reapplyInitialState);
useEffect(() => {
if (saveState.saved) router.refresh();
}, [saveState, router]);
const [email, setEmail] = useState(initial?.email ?? "");
const [firstName, setFirstName] = useState(initial?.firstName ?? "");
const [lastName, setLastName] = useState(initial?.lastName ?? "");
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Super Administrator</h2>
<StatusBadge initial={initial} saveState={saveState} />
</div>
<p className="text-sm text-muted">
A platform-level account with every Owner permission, provisioned automatically on every business — no
password, it signs in the same one-time-code way as everyone else. Never appears in any business&apos;s Team
list, the customer-facing barber picker, or staff totals, and can never receive an appointment.
</p>
<form className="space-y-4 rounded-md border border-line bg-surface p-5">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label htmlFor="email" className="block text-sm font-medium text-muted">
Email
</label>
<input
id="email"
name="email"
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
className="mt-1 w-full rounded-md border border-line px-3 py-2 text-sm"
/>
</div>
<div />
<div>
<label htmlFor="firstName" className="block text-sm font-medium text-muted">
First name
</label>
<input
id="firstName"
name="firstName"
type="text"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
className="mt-1 w-full rounded-md border border-line px-3 py-2 text-sm"
/>
</div>
<div>
<label htmlFor="lastName" className="block text-sm font-medium text-muted">
Last name
</label>
<input
id="lastName"
name="lastName"
type="text"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
className="mt-1 w-full rounded-md border border-line px-3 py-2 text-sm"
/>
</div>
</div>
{saveState.error && (
<p role="alert" className="text-sm font-medium text-danger">
{saveState.error}
</p>
)}
{saveState.saved && !saveState.error && (
<div className="text-sm text-green-700">
{saveState.rollout === null ? (
<p>Saved as a draft — enter an email to apply it to every business.</p>
) : (
<p>
Saved and applied to {saveState.rollout.filter((r) => r.ok).length} of {saveState.rollout.length}{" "}
business{saveState.rollout.length === 1 ? "" : "es"}.
{saveState.rollout.some((r) => !r.ok) && (
<> Failed: {saveState.rollout.filter((r) => !r.ok).map((r) => `${r.slug} (${r.error})`).join(", ")}</>
)}
</p>
)}
</div>
)}
<div className="flex flex-wrap gap-3 pt-2">
<button
type="submit"
formAction={saveAction}
disabled={isSaving}
className="rounded-md bg-ink px-4 py-2 text-sm font-semibold text-white disabled:opacity-60"
>
{isSaving ? "Saving…" : "Save Changes"}
</button>
<button
type="submit"
formAction={reapplyAction}
disabled={isReapplying || !initial?.email}
title={!initial?.email ? "Save an email first" : undefined}
className="ml-auto text-sm font-medium text-muted hover:text-ink disabled:opacity-40"
>
{isReapplying ? "Reapplying…" : "Reapply to all businesses"}
</button>
</div>
{reapplyState.error && (
<p role="alert" className="text-sm font-medium text-danger">
{reapplyState.error}
</p>
)}
{reapplyState.rollout && (
<p className="text-sm text-green-700">
Reapplied to {reapplyState.rollout.filter((r) => r.ok).length} of {reapplyState.rollout.length} business
{reapplyState.rollout.length === 1 ? "" : "es"}.
</p>
)}
</form>
</div>
);
}
@@ -49,3 +49,30 @@ describe("generateBusinessEnv — AUTH_URL", () => {
expect(env).toContain('NEXT_PUBLIC_APP_URL="https://other-business.example.com"'); expect(env).toContain('NEXT_PUBLIC_APP_URL="https://other-business.example.com"');
}); });
}); });
describe("generateBusinessEnv — SUPER_ADMIN_*", () => {
it("leaves SUPER_ADMIN_EMAIL blank when no global default is configured", () => {
const env = generateBusinessEnv(baseValues);
expect(env).toContain('SUPER_ADMIN_EMAIL=""');
expect(env).toContain('SUPER_ADMIN_FIRST_NAME=""');
expect(env).toContain('SUPER_ADMIN_LAST_NAME=""');
});
it("bakes in the global default Super Administrator when given one", () => {
const env = generateBusinessEnv({
...baseValues,
superAdmin: { email: "lazis002@gmail.com", firstName: "Stefan", lastName: "Lazic" },
});
expect(env).toContain('SUPER_ADMIN_EMAIL="lazis002@gmail.com"');
expect(env).toContain('SUPER_ADMIN_FIRST_NAME="Stefan"');
expect(env).toContain('SUPER_ADMIN_LAST_NAME="Lazic"');
});
it("never writes a SUPER_ADMIN_PASSWORD line — sign-in is OTP-only", () => {
const env = generateBusinessEnv({
...baseValues,
superAdmin: { email: "lazis002@gmail.com", firstName: "Stefan", lastName: "Lazic" },
});
expect(env).not.toContain("SUPER_ADMIN_PASSWORD");
});
});
@@ -0,0 +1,63 @@
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { isSuperAdminConfigComplete, readSuperAdminSettings, saveSuperAdminSettings } from "../global-settings";
describe("global-settings — Super Administrator (not a secret, no encryption involved)", () => {
let stacksDir: string;
const originalStacksDir = process.env.STACKS_DIR;
const originalEncryptionKey = process.env.MANAGER_SETTINGS_ENCRYPTION_KEY;
beforeEach(async () => {
stacksDir = await mkdtemp(path.join(tmpdir(), "nc-manager-super-admin-test-"));
process.env.STACKS_DIR = stacksDir;
process.env.MANAGER_SETTINGS_ENCRYPTION_KEY = "a".repeat(64);
});
afterEach(async () => {
if (originalStacksDir === undefined) delete process.env.STACKS_DIR;
else process.env.STACKS_DIR = originalStacksDir;
if (originalEncryptionKey === undefined) delete process.env.MANAGER_SETTINGS_ENCRYPTION_KEY;
else process.env.MANAGER_SETTINGS_ENCRYPTION_KEY = originalEncryptionKey;
await rm(stacksDir, { recursive: true, force: true });
});
it("returns null before anything has been saved", async () => {
expect(await readSuperAdminSettings()).toBeNull();
});
it("saves and reads back the email/first/last name, lowercasing and trimming the email", async () => {
await saveSuperAdminSettings({ email: " Lazis002@Gmail.com ", firstName: " Stefan ", lastName: " Lazic " });
const saved = await readSuperAdminSettings();
expect(saved).toEqual({ email: "lazis002@gmail.com", firstName: "Stefan", lastName: "Lazic" });
});
it("saving does not clobber email/push settings already stored in the same file", async () => {
const { saveEmailSettings } = await import("../global-settings");
await saveEmailSettings({
host: "smtp.example.com",
port: 587,
username: "user",
password: "pw",
encryption: "starttls",
fromAddress: "from@example.com",
fromName: "",
replyTo: "",
});
await saveSuperAdminSettings({ email: "lazis002@gmail.com", firstName: "Stefan", lastName: "Lazic" });
const { readEmailSettingsView } = await import("../global-settings");
const email = await readEmailSettingsView();
expect(email.host).toBe("smtp.example.com");
expect(email.hasPassword).toBe(true);
});
it("isSuperAdminConfigComplete requires a non-blank email; name is optional", () => {
expect(isSuperAdminConfigComplete(null)).toBe(false);
expect(isSuperAdminConfigComplete({ email: "", firstName: "Stefan", lastName: "Lazic" })).toBe(false);
expect(isSuperAdminConfigComplete({ email: "lazis002@gmail.com", firstName: "", lastName: "" })).toBe(true);
});
});
+22 -4
View File
@@ -64,6 +64,12 @@ export interface BusinessEnvPushValues {
subject: string; subject: string;
} }
export interface BusinessEnvSuperAdminValues {
email: string;
firstName: string;
lastName: string;
}
export interface BusinessEnvValues { export interface BusinessEnvValues {
name: string; name: string;
domain: string; domain: string;
@@ -84,6 +90,8 @@ export interface BusinessEnvValues {
email?: BusinessEnvEmailValues; email?: BusinessEnvEmailValues;
/** Global platform VAPID/Web Push settings (see src/lib/global-settings.ts) — same pattern as `email` above. Omitted falls back to today's blank/"console" defaults. */ /** Global platform VAPID/Web Push settings (see src/lib/global-settings.ts) — same pattern as `email` above. Omitted falls back to today's blank/"console" defaults. */
push?: BusinessEnvPushValues; push?: BusinessEnvPushValues;
/** Global default Super Administrator (see src/lib/global-settings.ts) — same "resolved once at provisioning time" pattern as email/push, but not a secret (no password field exists at all — sign-in is OTP-only). Omitted leaves SUPER_ADMIN_EMAIL blank, same as before this existed. */
superAdmin?: BusinessEnvSuperAdminValues;
} }
/** Same shape as .env.business.example, filled in with generated/collected values instead of placeholders. */ /** Same shape as .env.business.example, filled in with generated/collected values instead of placeholders. */
@@ -120,6 +128,15 @@ VAPID_PUBLIC_KEY=""
VAPID_PRIVATE_KEY="" VAPID_PRIVATE_KEY=""
VAPID_SUBJECT="mailto:owner@example.com"`; VAPID_SUBJECT="mailto:owner@example.com"`;
const superAdmin = values.superAdmin;
const superAdminBlock = superAdmin
? `SUPER_ADMIN_EMAIL="${superAdmin.email}"
SUPER_ADMIN_FIRST_NAME="${superAdmin.firstName}"
SUPER_ADMIN_LAST_NAME="${superAdmin.lastName}"`
: `SUPER_ADMIN_EMAIL=""
SUPER_ADMIN_FIRST_NAME=""
SUPER_ADMIN_LAST_NAME=""`;
return `# Generated by the management UI. See .env.business.example for what return `# Generated by the management UI. See .env.business.example for what
# each of these does. # each of these does.
# BUSINESS_NAME is Manager-only bookkeeping (shown on the dashboard/edit # BUSINESS_NAME is Manager-only bookkeeping (shown on the dashboard/edit
@@ -146,10 +163,11 @@ NEXT_PUBLIC_APP_URL="https://${values.domain}"
INSTALLATION_TOKEN="${values.installationToken}" INSTALLATION_TOKEN="${values.installationToken}"
CRON_SECRET="${values.cronSecret}" CRON_SECRET="${values.cronSecret}"
SUPER_ADMIN_EMAIL="" # Managed centrally — see the Manager's Settings > Super Administrator
SUPER_ADMIN_PASSWORD="" # page. Changing it there does NOT retroactively update existing
SUPER_ADMIN_FIRST_NAME="" # businesses (unlike email/push above) — see set-super-admin.sh for
SUPER_ADMIN_LAST_NAME="" # applying a change across every business at once.
${superAdminBlock}
AUTH_GOOGLE_ID="" AUTH_GOOGLE_ID=""
AUTH_GOOGLE_SECRET="" AUTH_GOOGLE_SECRET=""
+2 -2
View File
@@ -33,8 +33,8 @@ export function readEnvValue(content: string, key: string): string | null {
* file (preserving the surrounding file untouched), otherwise appends a * file (preserving the surrounding file untouched), otherwise appends a
* new line for it. Used by Edit Business: never regenerates a business's * new line for it. Used by Edit Business: never regenerates a business's
* whole .env from a template, which would blow away anything hand-edited * whole .env from a template, which would blow away anything hand-edited
* into it since provisioning (Google/email/push credentials, a rotated * into it since provisioning (Google/email/push credentials, a hand-added
* SUPER_ADMIN_PASSWORD, etc.) — see provisioning.ts's updateBusiness. * GOOGLE_CALENDAR_CLIENT_SECRET, etc.) — see provisioning.ts's updateBusiness.
*/ */
export async function upsertEnvValues(filePath: string, updates: Record<string, string>): Promise<void> { export async function upsertEnvValues(filePath: string, updates: Record<string, string>): Promise<void> {
const current = (await readEnvFile(filePath)) ?? ""; const current = (await readEnvFile(filePath)) ?? "";
+39 -2
View File
@@ -48,9 +48,17 @@ interface StoredPushSettings {
generatedAt: string | null; generatedAt: string | null;
} }
/** Not a secret — no password (sign-in is OTP-only, see scripts/ensure-super-admin.ts) — so this is stored and returned in plain, unlike email/push above. */
export interface SuperAdminSettings {
email: string;
firstName: string;
lastName: string;
}
interface StoredGlobalSettings { interface StoredGlobalSettings {
email: StoredEmailSettings | null; email: StoredEmailSettings | null;
push: StoredPushSettings | null; push: StoredPushSettings | null;
superAdmin: SuperAdminSettings | null;
} }
export interface LastTestResult { export interface LastTestResult {
@@ -119,10 +127,10 @@ async function readStoredSettings(): Promise<StoredGlobalSettings> {
try { try {
const raw = await readFile(settingsFilePath(), "utf8"); const raw = await readFile(settingsFilePath(), "utf8");
const parsed = JSON.parse(raw) as StoredGlobalSettings; const parsed = JSON.parse(raw) as StoredGlobalSettings;
return { email: parsed.email ?? null, push: parsed.push ?? null }; return { email: parsed.email ?? null, push: parsed.push ?? null, superAdmin: parsed.superAdmin ?? null };
} catch { } catch {
// No file yet (first run) or unreadable — treat as "nothing configured" rather than throwing, so the Settings page can always render. // 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 }; return { email: null, push: null, superAdmin: null };
} }
} }
@@ -364,3 +372,32 @@ export async function savePushSubject(subject: string): Promise<ResolvedPushSett
if (!next.encryptedPrivateKey) return null; if (!next.encryptedPrivateKey) return null;
return { publicKey: next.publicKey, privateKey: decrypt(next.encryptedPrivateKey), subject: next.subject }; return { publicKey: next.publicKey, privateKey: decrypt(next.encryptedPrivateKey), subject: next.subject };
} }
// ── Super Administrator ─────────────────────────────────────────────────
/**
* Baked into every business's .env at creation, the same as email/push
* above — see provisioning.ts's provisionBusiness. Unlike those, nothing
* here is a secret (no password, sign-in is OTP-only), so there's no
* View/Resolved split and no encryption: this is read and returned as
* plain data throughout.
*/
export async function readSuperAdminSettings(): Promise<SuperAdminSettings | null> {
const { superAdmin } = await readStoredSettings();
return superAdmin;
}
export function isSuperAdminConfigComplete(settings: SuperAdminSettings | null): settings is SuperAdminSettings {
return Boolean(settings && settings.email.trim());
}
export async function saveSuperAdminSettings(input: SuperAdminSettings): Promise<SuperAdminSettings> {
const current = await readStoredSettings();
const next: SuperAdminSettings = {
email: input.email.trim().toLowerCase(),
firstName: input.firstName.trim(),
lastName: input.lastName.trim(),
};
await writeStoredSettings({ ...current, superAdmin: next });
return next;
}
+21 -3
View File
@@ -4,7 +4,14 @@ import mariadb, { type Connection } from "mariadb";
import { generateBusinessEnv, generateCaddySite, generateComposeYaml } from "./compose-template"; import { generateBusinessEnv, generateCaddySite, generateComposeYaml } from "./compose-template";
import { composeDown, composeUp, reloadCaddy, runMigrations } from "./docker"; import { composeDown, composeUp, reloadCaddy, runMigrations } from "./docker";
import { readEnvFile, readEnvValue, upsertEnvValues } from "./env-file"; import { readEnvFile, readEnvValue, upsertEnvValues } from "./env-file";
import { isEmailConfigComplete, isPushConfigComplete, resolveEmailSettings, resolvePushSettings } from "./global-settings"; import {
isEmailConfigComplete,
isPushConfigComplete,
isSuperAdminConfigComplete,
readSuperAdminSettings,
resolveEmailSettings,
resolvePushSettings,
} from "./global-settings";
import { randomHex } from "./random"; import { randomHex } from "./random";
import { isValidSlug, listBusinessStacks, stackDir, stackExists, stacksRoot } from "./stacks"; import { isValidSlug, listBusinessStacks, stackDir, stackExists, stacksRoot } from "./stacks";
@@ -168,6 +175,16 @@ export async function provisionBusiness(input: ProvisionBusinessInput): Promise<
? { publicKey: globalPushSettings.publicKey, privateKey: globalPushSettings.privateKey, subject: globalPushSettings.subject } ? { publicKey: globalPushSettings.publicKey, privateKey: globalPushSettings.privateKey, subject: globalPushSettings.subject }
: undefined; : undefined;
// Same pattern again — a global default Super Administrator (Manager's
// Settings > Super Administrator page), baked in at creation only when
// an email is actually set. No secret involved (sign-in is OTP-only),
// so unlike email/push there's nothing to fall back to "incomplete"
// for beyond a blank email.
const globalSuperAdminSettings = await readSuperAdminSettings();
const superAdmin = isSuperAdminConfigComplete(globalSuperAdminSettings)
? { email: globalSuperAdminSettings.email, firstName: globalSuperAdminSettings.firstName, lastName: globalSuperAdminSettings.lastName }
: undefined;
const dir = stackDir(slug); const dir = stackDir(slug);
await mkdir(dir, { recursive: true }); await mkdir(dir, { recursive: true });
await writeFile(path.join(dir, "compose.yaml"), generateComposeYaml(slug), "utf8"); await writeFile(path.join(dir, "compose.yaml"), generateComposeYaml(slug), "utf8");
@@ -184,6 +201,7 @@ export async function provisionBusiness(input: ProvisionBusinessInput): Promise<
cronSecret, cronSecret,
email, email,
push, push,
superAdmin,
}), }),
"utf8", "utf8",
); );
@@ -212,8 +230,8 @@ export interface UpdateBusinessResult {
* Edits an existing business's name/domain. Deliberately a surgical .env * Edits an existing business's name/domain. Deliberately a surgical .env
* update (see upsertEnvValues), never a full regenerate from the create * update (see upsertEnvValues), never a full regenerate from the create
* template — a regenerate would blow away anything hand-edited into that * template — a regenerate would blow away anything hand-edited into that
* file since provisioning (Google/email/push credentials, a rotated * file since provisioning (Google/email/push credentials, a hand-added
* SUPER_ADMIN_PASSWORD, etc.), which the caller has no business touching * GOOGLE_CALENDAR_CLIENT_SECRET, etc.), which the caller has no business touching
* just to change a name or domain. * just to change a name or domain.
* *
* The slug itself is immutable here on purpose: it's baked into the * The slug itself is immutable here on purpose: it's baked into the
+56
View File
@@ -0,0 +1,56 @@
import path from "node:path";
import { composeUp } from "./docker";
import { upsertEnvValues } from "./env-file";
import type { SuperAdminSettings } from "./global-settings";
import { listBusinessStacks, stackDir } from "./stacks";
export interface SuperAdminRolloutResult {
slug: string;
ok: boolean;
error?: string;
}
/**
* Pushes the global default Super Administrator into every business's
* own `.env` and force-recreates its container so
* scripts/ensure-super-admin.ts (run on every container start) picks it
* up — same mechanism as email-rollout.ts/push-rollout.ts. Unlike those,
* always overwrites all three keys unconditionally: there's no
* per-business "own value to preserve" concept here the way EMAIL_REPLY_TO
* has (a business doesn't have its own Super Administrator), and no
* secret involved (sign-in is OTP-only) worth being more careful with.
*
* This is the UI equivalent of running
* stacks/infra/scripts/set-super-admin.sh by hand — kept as a genuinely
* separate implementation (not a shared core) since one runs inside the
* Manager process against these same helpers and the other is a
* standalone shell script for someone without Manager access to a
* running instance; letting them drift independently is fine since both
* only ever do this one small thing.
*
* Runs sequentially, not in parallel — see email-rollout.ts's own
* comment for why.
*/
export async function applySuperAdminToAllBusinesses(settings: SuperAdminSettings): Promise<SuperAdminRolloutResult[]> {
const businesses = await listBusinessStacks();
const results: SuperAdminRolloutResult[] = [];
for (const business of businesses) {
try {
const dir = stackDir(business.slug);
const envPath = path.join(dir, ".env");
await upsertEnvValues(envPath, {
SUPER_ADMIN_EMAIL: settings.email,
SUPER_ADMIN_FIRST_NAME: settings.firstName,
SUPER_ADMIN_LAST_NAME: settings.lastName,
});
await composeUp(dir, { 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;
}
+15 -19
View File
@@ -2,9 +2,18 @@
* Idempotent Super Administrator provisioning, run on every container * Idempotent Super Administrator provisioning, run on every container
* start (see docker/entrypoint.sh) — NOT part of the installation wizard, * start (see docker/entrypoint.sh) — NOT part of the installation wizard,
* per the brief's explicit requirement that this account come from * per the brief's explicit requirement that this account come from
* environment variables. Safe to run repeatedly: re-hashes and syncs the * environment variables. Safe to run repeatedly: re-syncs the name from
* password/name from the env vars every run, so rotating * the env vars every run.
* SUPER_ADMIN_PASSWORD and restarting the container is how you change it. *
* No password — sign-in across this entire app is OTP-only (see
* src/lib/auth/authorize-credentials.ts), and a Super Administrator signs
* in exactly the same way as anyone else, via a one-time code emailed to
* SUPER_ADMIN_EMAIL. `User.passwordHash` is never read anywhere in the
* actual sign-in path — this script used to require and set one anyway
* (a leftover from before the OTP migration), which only meant an unused
* value with its own, pointless minimum-length requirement. Removed
* rather than kept "just in case": a field nothing checks is worse than
* no field, since it invites someone to assume it's load-bearing.
* *
* Deliberately does NOT create a StaffProfile — that's what keeps the * Deliberately does NOT create a StaffProfile — that's what keeps the
* Super Administrator out of the Team list, the customer-facing barber * Super Administrator out of the Team list, the customer-facing barber
@@ -18,13 +27,10 @@
* outside Next's own module resolution/bundling. * outside Next's own module resolution/bundling.
*/ */
import "dotenv/config"; import "dotenv/config";
import bcrypt from "bcryptjs";
import { PrismaClient } from "../generated/prisma/client"; import { PrismaClient } from "../generated/prisma/client";
import { PrismaMariaDb } from "@prisma/adapter-mariadb"; import { PrismaMariaDb } from "@prisma/adapter-mariadb";
import { buildMysqlPoolConfig } from "../src/lib/db/mysql-pool-config"; import { buildMysqlPoolConfig } from "../src/lib/db/mysql-pool-config";
const BCRYPT_COST = 12; // matches src/features/installation/installer.service.ts
async function main() { async function main() {
const email = process.env.SUPER_ADMIN_EMAIL?.trim().toLowerCase(); const email = process.env.SUPER_ADMIN_EMAIL?.trim().toLowerCase();
if (!email) { if (!email) {
@@ -32,15 +38,6 @@ async function main() {
return; return;
} }
const password = process.env.SUPER_ADMIN_PASSWORD;
if (!password || password.length < 8) {
console.error(
"[ensure-super-admin] SUPER_ADMIN_EMAIL is set but SUPER_ADMIN_PASSWORD is missing or shorter than 8 characters. Refusing to provision a Super Administrator with no usable password.",
);
process.exitCode = 1;
return;
}
const firstName = process.env.SUPER_ADMIN_FIRST_NAME?.trim() ?? ""; const firstName = process.env.SUPER_ADMIN_FIRST_NAME?.trim() ?? "";
const lastName = process.env.SUPER_ADMIN_LAST_NAME?.trim() ?? ""; const lastName = process.env.SUPER_ADMIN_LAST_NAME?.trim() ?? "";
const name = [firstName, lastName].filter(Boolean).join(" ") || null; const name = [firstName, lastName].filter(Boolean).join(" ") || null;
@@ -49,7 +46,6 @@ async function main() {
const prisma = new PrismaClient({ adapter }); const prisma = new PrismaClient({ adapter });
try { try {
const passwordHash = await bcrypt.hash(password, BCRYPT_COST);
const existing = await prisma.user.findUnique({ where: { email } }); const existing = await prisma.user.findUnique({ where: { email } });
if (existing && !existing.isSuperAdmin) { if (existing && !existing.isSuperAdmin) {
@@ -63,12 +59,12 @@ async function main() {
if (existing) { if (existing) {
await prisma.user.update({ await prisma.user.update({
where: { id: existing.id }, where: { id: existing.id },
data: { name, passwordHash, emailVerified: existing.emailVerified ?? new Date() }, data: { name, emailVerified: existing.emailVerified ?? new Date() },
}); });
console.log(`[ensure-super-admin] Synced credentials for existing Super Administrator (${email}).`); console.log(`[ensure-super-admin] Synced name for existing Super Administrator (${email}).`);
} else { } else {
await prisma.user.create({ await prisma.user.create({
data: { email, name, passwordHash, isSuperAdmin: true, emailVerified: new Date() }, data: { email, name, isSuperAdmin: true, emailVerified: new Date() },
}); });
console.log(`[ensure-super-admin] Created Super Administrator (${email}).`); console.log(`[ensure-super-admin] Created Super Administrator (${email}).`);
} }
+70
View File
@@ -0,0 +1,70 @@
#!/bin/sh
# Calls one of the app's CRON_SECRET-gated endpoints (see
# src/app/api/cron/process-notifications/route.ts and
# src/app/api/cron/sync-google-reviews/route.ts) for every business
# currently provisioned under STACKS_DIR — so a single crontab entry on
# the host covers every business automatically, including ones added
# later, without a manual crontab line per business.
#
# Without something calling process-notifications on a schedule, queued
# notifications (staff invitation emails, appointment reminders, loyalty
# reward emails, etc.) are created in the database as PENDING and never
# actually sent — see that route's own header for why this is a required
# piece of infrastructure, not optional. This script is the reference
# implementation for wiring that up; see the root README's "Notification
# delivery" section for the crontab lines that call it.
#
# Usage:
# process-cron.sh notifications # recommended every 1-2 minutes
# process-cron.sh reviews # recommended once every 24 hours
#
# STACKS_DIR defaults to /opt/nextchair/stacks — override via env var if
# this repo is cloned somewhere else (must match every other STACKS_DIR
# use across this deployment — see .env.infra.example's own note on why).
set -eu
if [ $# -lt 1 ]; then
echo "Usage: $0 notifications|reviews" >&2
exit 1
fi
ENDPOINT="$1"
STACKS_DIR="${STACKS_DIR:-/opt/nextchair/stacks}"
case "$ENDPOINT" in
notifications) PATH_SUFFIX="process-notifications" ;;
reviews) PATH_SUFFIX="sync-google-reviews" ;;
*)
echo "Unknown endpoint '$ENDPOINT' — expected 'notifications' or 'reviews'." >&2
exit 1
;;
esac
if [ ! -d "$STACKS_DIR" ]; then
echo "STACKS_DIR '$STACKS_DIR' does not exist." >&2
exit 1
fi
for env_file in "$STACKS_DIR"/*/.env; do
[ -f "$env_file" ] || continue
domain=$(grep -E '^DOMAIN=' "$env_file" | head -n1 | cut -d= -f2- | tr -d '"')
cron_secret=$(grep -E '^CRON_SECRET=' "$env_file" | head -n1 | cut -d= -f2- | tr -d '"')
slug=$(basename "$(dirname "$env_file")")
if [ -z "$domain" ] || [ -z "$cron_secret" ]; then
echo "[$slug] skipped — no DOMAIN/CRON_SECRET in .env (not fully provisioned yet)." >&2
continue
fi
status=$(curl -sS -o /tmp/process-cron-response.$$ -w '%{http_code}' -X POST \
"https://${domain}/api/cron/${PATH_SUFFIX}" \
-H "Authorization: Bearer ${cron_secret}" || echo "curl_failed")
if [ "$status" = "200" ]; then
echo "[$slug] $PATH_SUFFIX ok"
else
echo "[$slug] $PATH_SUFFIX FAILED (status=$status): $(cat /tmp/process-cron-response.$$ 2>/dev/null)" >&2
fi
rm -f /tmp/process-cron-response.$$
done
+63
View File
@@ -0,0 +1,63 @@
#!/bin/sh
# Sets SUPER_ADMIN_EMAIL/FIRST_NAME/LAST_NAME in every business's .env
# under STACKS_DIR and recreates each container so
# scripts/ensure-super-admin.ts (run on every container start via
# docker/entrypoint.sh) picks it up immediately. No password to set —
# sign-in is OTP-only; see ensure-super-admin.ts's own header for why.
#
# Only touches these three keys in each .env — every other secret/setting
# already in the file (Google/email/push credentials, etc.) is left
# byte-for-byte alone. Safe to re-run any time you want to change who the
# Super Administrator is, or after adding a new business.
#
# Usage:
# set-super-admin.sh <email> <first-name> <last-name>
#
# STACKS_DIR defaults to /opt/nextchair/stacks — override via env var if
# this repo is cloned somewhere else.
set -eu
if [ $# -lt 3 ]; then
echo "Usage: $0 <email> <first-name> <last-name>" >&2
exit 1
fi
EMAIL="$1"
FIRST_NAME="$2"
LAST_NAME="$3"
STACKS_DIR="${STACKS_DIR:-/opt/nextchair/stacks}"
if [ ! -d "$STACKS_DIR" ]; then
echo "STACKS_DIR '$STACKS_DIR' does not exist." >&2
exit 1
fi
for env_file in "$STACKS_DIR"/*/.env; do
[ -f "$env_file" ] || continue
dir=$(dirname "$env_file")
slug=$(basename "$dir")
tmp="${env_file}.tmp.$$"
awk -v email="$EMAIL" -v first="$FIRST_NAME" -v last="$LAST_NAME" '
BEGIN { seen_email=0; seen_first=0; seen_last=0 }
/^SUPER_ADMIN_EMAIL=/ { print "SUPER_ADMIN_EMAIL=\"" email "\""; seen_email=1; next }
/^SUPER_ADMIN_FIRST_NAME=/ { print "SUPER_ADMIN_FIRST_NAME=\"" first "\""; seen_first=1; next }
/^SUPER_ADMIN_LAST_NAME=/ { print "SUPER_ADMIN_LAST_NAME=\"" last "\""; seen_last=1; next }
/^SUPER_ADMIN_PASSWORD=/ { next }
{ print }
END {
if (!seen_email) print "SUPER_ADMIN_EMAIL=\"" email "\""
if (!seen_first) print "SUPER_ADMIN_FIRST_NAME=\"" first "\""
if (!seen_last) print "SUPER_ADMIN_LAST_NAME=\"" last "\""
}
' "$env_file" > "$tmp" && mv "$tmp" "$env_file"
echo "[$slug] .env updated — recreating container..."
if (cd "$dir" && docker compose up -d --force-recreate) > /tmp/set-super-admin-$$.log 2>&1; then
echo "[$slug] ok"
else
echo "[$slug] FAILED to recreate container:" >&2
cat /tmp/set-super-admin-$$.log >&2
fi
rm -f /tmp/set-super-admin-$$.log
done