This commit is contained in:
Stefan Lazic committed 2026-08-04 09:57:58 +01:00
1 parent 546613e677
commit 4785510144
82 files changed
+4903 -287

No files matched your search

@@ -0,0 +1,229 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { prisma } from "@/services/scheduling/__tests__/fixtures";
import { InstallationStatus } from "@/generated/prisma/enums";
import { installApplication } from "../installer.service";
import type { InstallInput } from "../schema";
/**
* Exercises the real transactional installer against a real MySQL
* database — see installer.service.ts's own header for why the
* ApplicationInstallation singleton row is the concurrency lock. These
* tests own that row exclusively: any pre-existing installation state in
* this database is removed in `beforeEach` and never restored, since a
* test database is expected to be disposable (see
* docs/phase-8-summary.md's testing section) — do not point this at a
* real deployment's database.
*/
const INSTALLATION_TOKEN = "integration-test-install-token";
function buildInput(overrides: Partial<InstallInput> = {}): InstallInput {
return {
installationToken: INSTALLATION_TOKEN,
administrator: {
firstName: "Test",
lastName: "Owner",
email: `install-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@example.com`,
phone: "",
password: "correct-horse-battery",
confirmPassword: "correct-horse-battery",
},
business: {
name: "Integration Test Barbershop",
shortName: "",
description: "",
email: "",
phone: "",
website: "",
},
location: {
addressLine1: "1 Test Street",
addressLine2: "",
city: "Testville",
region: "",
postalCode: "T1 1TT",
country: "GB",
timezone: "Europe/London",
currency: "GBP",
locale: "en-GB",
},
hours: (["MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY", "SATURDAY", "SUNDAY"] as const).map((dayOfWeek) => ({
dayOfWeek,
isClosed: dayOfWeek === "SUNDAY",
opensAtMinutes: dayOfWeek === "SUNDAY" ? undefined : 540,
closesAtMinutes: dayOfWeek === "SUNDAY" ? undefined : 1080,
})),
bookingRules: {
allowGuestBooking: false,
minNoticeMinutes: 120,
maxAdvanceDays: 60,
cancellationWindowHours: 24,
rescheduleWindowHours: 24,
slotIntervalMinutes: 30,
bufferBeforeMinutes: 0,
bufferAfterMinutes: 5,
maxAppointmentsPerDay: null,
maxAppointmentsPerStaffPerDay: null,
customerCanChooseStaff: true,
requireApproval: false,
},
branding: { logoUrl: "", coverImageUrl: "", themeColor: "", backgroundColor: "" },
starterServices: [
{ categoryName: "Haircuts", name: "Classic Cut", description: "", price: "20.00", durationMinutes: 30, active: true, allowOnlineBooking: true },
],
...overrides,
};
}
async function resetInstallationSingleton(): Promise<void> {
await prisma.applicationInstallation.deleteMany({ where: { id: "singleton" } });
}
async function cleanupInstalledBusiness(businessId: string | undefined): Promise<void> {
if (!businessId) return;
const staff = await prisma.staffProfile.findMany({ where: { businessId }, select: { id: true, userId: true } });
const userIds = staff.map((s) => s.userId);
await prisma.staffService.deleteMany({ where: { staffId: { in: staff.map((s) => s.id) } } });
await prisma.staffProfile.deleteMany({ where: { businessId } });
await prisma.service.deleteMany({ where: { businessId } });
await prisma.serviceCategory.deleteMany({ where: { businessId } });
await prisma.rolePermission.deleteMany({ where: { role: { businessId } } });
await prisma.role.deleteMany({ where: { businessId } });
await prisma.auditLog.deleteMany({ where: { entityId: businessId } });
await prisma.loyaltyProgramme.deleteMany({ where: { businessId } });
await prisma.bookingRule.deleteMany({ where: { businessId } });
await prisma.businessSettings.deleteMany({ where: { businessId } });
await prisma.businessHours.deleteMany({ where: { businessId } });
await prisma.businessLocation.deleteMany({ where: { businessId } });
await prisma.business.deleteMany({ where: { id: businessId } });
await prisma.user.deleteMany({ where: { id: { in: userIds } } });
await resetInstallationSingleton();
}
describe("installApplication (integration)", () => {
let createdBusinessId: string | undefined;
beforeEach(async () => {
await resetInstallationSingleton();
createdBusinessId = undefined;
});
afterEach(async () => {
await cleanupInstalledBusiness(createdBusinessId);
});
it("rejects an incorrect installation token without creating anything", async () => {
const originalToken = process.env.INSTALLATION_TOKEN;
process.env.INSTALLATION_TOKEN = INSTALLATION_TOKEN;
try {
const result = await installApplication(prisma, {
input: buildInput({ installationToken: "wrong-token" }),
installedVersion: "test",
});
expect(result.success).toBe(false);
if (!result.success) expect(result.code).toBe("INVALID_TOKEN");
const business = await prisma.business.findFirst({ where: { name: "Integration Test Barbershop" } });
expect(business).toBeNull();
const installation = await prisma.applicationInstallation.findUnique({ where: { id: "singleton" } });
expect(installation).toBeNull();
} finally {
process.env.INSTALLATION_TOKEN = originalToken;
}
});
it("creates the business, administrator, permissions, booking rules, and starter services transactionally", async () => {
const originalToken = process.env.INSTALLATION_TOKEN;
process.env.INSTALLATION_TOKEN = INSTALLATION_TOKEN;
try {
const result = await installApplication(prisma, { input: buildInput(), installedVersion: "test" });
expect(result.success).toBe(true);
if (!result.success) return;
createdBusinessId = result.businessId;
const installation = await prisma.applicationInstallation.findUnique({ where: { id: "singleton" } });
expect(installation?.status).toBe(InstallationStatus.INSTALLED);
expect(installation?.primaryBusinessId).toBe(result.businessId);
expect(installation?.initialAdminUserId).toBe(result.userId);
const business = await prisma.business.findUnique({
where: { id: result.businessId },
include: { bookingRule: true, locations: true, hours: true, loyaltyProgramme: true },
});
expect(business?.name).toBe("Integration Test Barbershop");
// Guest booking off by default, per the brief — confirmed end to end here.
expect(business?.bookingRule?.allowGuestBooking).toBe(false);
expect(business?.locations).toHaveLength(1);
expect(business?.hours).toHaveLength(7);
expect(business?.loyaltyProgramme?.isEnabled).toBe(false);
const roles = await prisma.role.findMany({ where: { businessId: result.businessId }, include: { permissions: true } });
const ownerRole = roles.find((r) => r.name === "Owner");
const barberRole = roles.find((r) => r.name === "Barber");
expect(ownerRole).toBeDefined();
expect(barberRole).toBeDefined();
expect(ownerRole!.permissions.length).toBeGreaterThan(barberRole!.permissions.length);
const staffProfile = await prisma.staffProfile.findUnique({ where: { userId: result.userId }, include: { services: true } });
expect(staffProfile?.roleId).toBe(ownerRole!.id);
// The initial owner is eligible for every starter service — see the brief's "initial owner is service-eligible."
expect(staffProfile?.services).toHaveLength(1);
const services = await prisma.service.findMany({ where: { businessId: result.businessId } });
expect(services).toHaveLength(1);
expect(services[0]?.name).toBe("Classic Cut");
const auditLog = await prisma.auditLog.findFirst({ where: { entityId: result.businessId, action: "APPLICATION_INSTALLED" } });
expect(auditLog).toBeTruthy();
} finally {
process.env.INSTALLATION_TOKEN = originalToken;
}
});
it("refuses to install a second time once already installed", async () => {
const originalToken = process.env.INSTALLATION_TOKEN;
process.env.INSTALLATION_TOKEN = INSTALLATION_TOKEN;
try {
const first = await installApplication(prisma, { input: buildInput(), installedVersion: "test" });
expect(first.success).toBe(true);
if (first.success) createdBusinessId = first.businessId;
const second = await installApplication(prisma, {
input: buildInput({
administrator: { ...buildInput().administrator, email: `second-${Date.now()}@example.com` },
}),
installedVersion: "test",
});
expect(second.success).toBe(false);
if (!second.success) expect(second.code).toBe("ALREADY_INSTALLED");
} finally {
process.env.INSTALLATION_TOKEN = originalToken;
}
});
it("prevents two concurrent installation attempts from both succeeding", async () => {
const originalToken = process.env.INSTALLATION_TOKEN;
process.env.INSTALLATION_TOKEN = INSTALLATION_TOKEN;
try {
const inputA = buildInput({ administrator: { ...buildInput().administrator, email: `concurrent-a-${Date.now()}@example.com` } });
const inputB = buildInput({ administrator: { ...buildInput().administrator, email: `concurrent-b-${Date.now()}@example.com` } });
const [resultA, resultB] = await Promise.all([
installApplication(prisma, { input: inputA, installedVersion: "test" }),
installApplication(prisma, { input: inputB, installedVersion: "test" }),
]);
const successes = [resultA, resultB].filter((r) => r.success);
expect(successes).toHaveLength(1);
const winner = successes[0];
if (winner?.success) createdBusinessId = winner.businessId;
const businessCount = await prisma.business.count({
where: { name: "Integration Test Barbershop" },
});
expect(businessCount).toBe(1);
} finally {
process.env.INSTALLATION_TOKEN = originalToken;
}
});
});
@@ -0,0 +1,73 @@
"use server";
import { signIn } from "@/auth";
import { prisma } from "@/lib/db/prisma";
import { getRequestIp } from "@/lib/security/request-ip";
import { checkRateLimit } from "@/lib/security/rate-limit";
import { secretsMatch } from "@/lib/security/secrets-match";
import { installApplication } from "../installer.service";
import { installSchema } from "../schema";
import type { InstallInput } from "../schema";
const packageVersion = process.env.npm_package_version ?? "1.0.0";
export interface VerifyTokenResult {
valid: boolean;
error?: string;
}
/**
* A cheap, read-only check the wizard's first step calls for immediate
* feedback — never reveals *why* a token is wrong, and never on its own
* unlocks anything server-side: the real gate is `submitInstallationAction`
* re-checking the same token before it writes anything (see
* installer.service.ts's own header for why it's checked twice).
*/
export async function verifyInstallationTokenAction(token: string): Promise<VerifyTokenResult> {
const ip = await getRequestIp();
const { allowed } = checkRateLimit(`install-token:${ip}`, { maxAttempts: 10, windowMs: 15 * 60 * 1000 });
if (!allowed) {
return { valid: false, error: "Too many attempts. Please try again later." };
}
const expected = process.env.INSTALLATION_TOKEN;
if (!expected) {
return { valid: false, error: "Installation is not available on this deployment." };
}
if (!token || !secretsMatch(token, expected)) {
return { valid: false, error: "Incorrect installation token." };
}
return { valid: true };
}
export interface SubmitInstallationResult {
success: boolean;
error?: string;
fieldErrors?: Record<string, string[] | undefined>;
}
export async function submitInstallationAction(input: InstallInput): Promise<SubmitInstallationResult> {
const ip = await getRequestIp();
const { allowed } = checkRateLimit(`install-submit:${ip}`, { maxAttempts: 10, windowMs: 15 * 60 * 1000 });
if (!allowed) {
return { success: false, error: "Too many attempts. Please try again later." };
}
const parsed = installSchema.safeParse(input);
if (!parsed.success) {
return { success: false, error: "Please fix the highlighted fields.", fieldErrors: parsed.error.flatten().fieldErrors };
}
const result = await installApplication(prisma, { input: parsed.data, installedVersion: packageVersion });
if (!result.success) {
return { success: false, error: result.message };
}
await signIn("credentials", {
email: parsed.data.administrator.email,
password: parsed.data.administrator.password,
redirectTo: "/owner?installed=1",
});
return { success: true };
}
@@ -0,0 +1,598 @@
"use client";
import { useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch";
import { submitInstallationAction, verifyInstallationTokenAction } from "../actions/install";
import type { InstallInput } from "../schema";
/**
* Holds every wizard step's data as plain React state, not a signed
* server-side draft — nothing is validated or written to the database
* until the single final `submitInstallationAction` call on Step 9's
* confirmation (see installer.service.ts's own header). That means a
* page refresh loses progress, a deliberate simplification: this page is
* only ever visited once, by the person physically standing up the
* deployment, so "refresh loses your place" is a fair trade for not
* needing a signed-cookie draft model and its own cleanup story.
*/
const WEEKDAYS = ["MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY", "SATURDAY", "SUNDAY"] as const;
type WeekdayCode = (typeof WEEKDAYS)[number];
const DAY_LABEL: Record<WeekdayCode, string> = {
MONDAY: "Monday",
TUESDAY: "Tuesday",
WEDNESDAY: "Wednesday",
THURSDAY: "Thursday",
FRIDAY: "Friday",
SATURDAY: "Saturday",
SUNDAY: "Sunday",
};
interface HoursDayState {
dayOfWeek: WeekdayCode;
isClosed: boolean;
startTime: string;
endTime: string;
}
interface StarterServiceState {
key: string;
categoryName: string;
name: string;
description: string;
price: string;
durationMinutes: number;
active: boolean;
allowOnlineBooking: boolean;
}
function toMinutes(time: string): number {
const [h, m] = time.split(":").map(Number);
return (h ?? 0) * 60 + (m ?? 0);
}
const STEPS = [
"Welcome",
"Administrator",
"Business Details",
"Location & Contact",
"Opening Hours",
"Booking Settings",
"Branding",
"Starter Services",
"Review",
] as const;
let starterServiceKeySeq = 0;
function newStarterServiceKey(): string {
starterServiceKeySeq += 1;
return `starter-${starterServiceKeySeq}`;
}
const DEFAULT_STARTER_SERVICES: StarterServiceState[] = [
{
key: newStarterServiceKey(),
categoryName: "Haircuts",
name: "Classic Haircut",
description: "",
price: "25.00",
durationMinutes: 30,
active: true,
allowOnlineBooking: true,
},
{
key: newStarterServiceKey(),
categoryName: "Beard Services",
name: "Beard Trim",
description: "",
price: "15.00",
durationMinutes: 20,
active: true,
allowOnlineBooking: true,
},
{
key: newStarterServiceKey(),
categoryName: "Packages",
name: "Haircut & Beard Combo",
description: "",
price: "35.00",
durationMinutes: 45,
active: true,
allowOnlineBooking: true,
},
];
export function InstallWizard() {
const [step, setStep] = useState(1);
const [isPending, startTransition] = useTransition();
const [submitError, setSubmitError] = useState<string | null>(null);
// Step 1
const [token, setToken] = useState("");
const [tokenError, setTokenError] = useState<string | null>(null);
// Step 2
const [admin, setAdmin] = useState({ firstName: "", lastName: "", email: "", phone: "", password: "", confirmPassword: "" });
// Step 3
const [business, setBusiness] = useState({ name: "", shortName: "", description: "", email: "", phone: "", website: "" });
// Step 4
const [location, setLocation] = useState({
addressLine1: "",
addressLine2: "",
city: "",
region: "",
postalCode: "",
country: "GB",
timezone: "Europe/London",
currency: "GBP",
locale: "en-GB",
});
// Step 5
const [hours, setHours] = useState<HoursDayState[]>(
WEEKDAYS.map((dayOfWeek) => ({
dayOfWeek,
isClosed: dayOfWeek === "SUNDAY",
startTime: "09:00",
endTime: "18:00",
})),
);
// Step 6 — guest booking defaults OFF, per the brief.
const [bookingRules, setBookingRules] = useState({
allowGuestBooking: false,
minNoticeMinutes: 120,
maxAdvanceDays: 60,
cancellationWindowHours: 24,
rescheduleWindowHours: 24,
slotIntervalMinutes: 30,
bufferBeforeMinutes: 0,
bufferAfterMinutes: 5,
maxAppointmentsPerDay: null as number | null,
maxAppointmentsPerStaffPerDay: null as number | null,
customerCanChooseStaff: true,
requireApproval: false,
});
// Step 7
const [branding, setBranding] = useState({ logoUrl: "", coverImageUrl: "", themeColor: "", backgroundColor: "" });
// Step 8
const [starterServices, setStarterServices] = useState<StarterServiceState[]>(DEFAULT_STARTER_SERVICES);
const verifyToken = () => {
setTokenError(null);
startTransition(async () => {
const result = await verifyInstallationTokenAction(token);
if (!result.valid) {
setTokenError(result.error ?? "Incorrect installation token.");
return;
}
setStep(2);
});
};
const updateDay = (dayOfWeek: WeekdayCode, patch: Partial<HoursDayState>) => {
setHours((prev) => prev.map((d) => (d.dayOfWeek === dayOfWeek ? { ...d, ...patch } : d)));
};
const copyMondayToWeekdays = () => {
const monday = hours.find((d) => d.dayOfWeek === "MONDAY");
if (!monday) return;
setHours((prev) =>
prev.map((d) =>
d.dayOfWeek === "SATURDAY" || d.dayOfWeek === "SUNDAY" || d.dayOfWeek === "MONDAY"
? d
: { ...d, isClosed: monday.isClosed, startTime: monday.startTime, endTime: monday.endTime },
),
);
};
const copyMondayToAll = () => {
const monday = hours.find((d) => d.dayOfWeek === "MONDAY");
if (!monday) return;
setHours((prev) =>
prev.map((d) => (d.dayOfWeek === "MONDAY" ? d : { ...d, isClosed: monday.isClosed, startTime: monday.startTime, endTime: monday.endTime })),
);
};
const updateStarter = (key: string, patch: Partial<StarterServiceState>) => {
setStarterServices((prev) => prev.map((s) => (s.key === key ? { ...s, ...patch } : s)));
};
const removeStarter = (key: string) => {
setStarterServices((prev) => prev.filter((s) => s.key !== key));
};
const addStarter = () => {
setStarterServices((prev) => [
...prev,
{
key: newStarterServiceKey(),
categoryName: "Haircuts",
name: "",
description: "",
price: "0.00",
durationMinutes: 30,
active: true,
allowOnlineBooking: true,
},
]);
};
const submit = () => {
setSubmitError(null);
const input: InstallInput = {
installationToken: token,
administrator: admin,
business,
location,
hours: hours.map((d) => ({
dayOfWeek: d.dayOfWeek,
isClosed: d.isClosed,
...(d.isClosed ? {} : { opensAtMinutes: toMinutes(d.startTime), closesAtMinutes: toMinutes(d.endTime) }),
})),
bookingRules,
branding,
starterServices: starterServices
.filter((s) => s.name.trim())
.map((s) => ({
categoryName: s.categoryName,
name: s.name,
description: s.description,
price: s.price,
durationMinutes: s.durationMinutes,
active: s.active,
allowOnlineBooking: s.allowOnlineBooking,
})),
};
startTransition(async () => {
const result = await submitInstallationAction(input);
if (!result.success) {
setSubmitError(result.error ?? "Installation failed. Please try again.");
}
// On success, submitInstallationAction signs the administrator in
// and redirects server-side — nothing else to do here.
});
};
const canProceedFromStep = (s: number): boolean => {
switch (s) {
case 2:
return (
!!admin.firstName.trim() &&
!!admin.lastName.trim() &&
!!admin.email.trim() &&
admin.password.length >= 8 &&
admin.password === admin.confirmPassword
);
case 3:
return !!business.name.trim();
case 4:
return (
!!location.addressLine1.trim() &&
!!location.city.trim() &&
!!location.postalCode.trim() &&
!!location.timezone.trim() &&
location.currency.trim().length === 3
);
default:
return true;
}
};
return (
<div className="space-y-6">
<div>
<p className="text-muted text-[10px] font-bold tracking-widest uppercase">
Step {step} of {STEPS.length} — {STEPS[step - 1]}
</p>
<div className="bg-line mt-2 h-1.5 w-full overflow-hidden rounded-full">
<div className="bg-ink h-full rounded-full transition-all" style={{ width: `${(step / STEPS.length) * 100}%` }} />
</div>
</div>
{step === 1 && (
<div className="space-y-4">
<h1 className="text-2xl font-bold">Set up your business</h1>
<p className="text-muted text-sm">
This wizard creates the first administrator account and configures your business — opening hours,
booking rules, and branding. It only needs to run once.
</p>
<Input
label="Installation Token"
type="password"
value={token}
onChange={(e) => setToken(e.target.value)}
error={tokenError ?? undefined}
required
autoComplete="off"
/>
<Button className="w-full" isLoading={isPending} disabled={!token} onClick={verifyToken}>
Continue
</Button>
</div>
)}
{step === 2 && (
<div className="space-y-4">
<h2 className="text-xl font-bold">Administrator account</h2>
<p className="text-muted text-sm">This becomes the first owner — full permissions, protected from removal.</p>
<div className="grid grid-cols-2 gap-3">
<Input label="First Name" value={admin.firstName} onChange={(e) => setAdmin({ ...admin, firstName: e.target.value })} required />
<Input label="Last Name" value={admin.lastName} onChange={(e) => setAdmin({ ...admin, lastName: e.target.value })} required />
</div>
<Input label="Email Address" type="email" value={admin.email} onChange={(e) => setAdmin({ ...admin, email: e.target.value })} required />
<Input label="Phone (optional)" type="tel" value={admin.phone} onChange={(e) => setAdmin({ ...admin, phone: e.target.value })} />
<Input
label="Password"
type="password"
value={admin.password}
onChange={(e) => setAdmin({ ...admin, password: e.target.value })}
hint="At least 8 characters."
required
/>
<Input
label="Confirm Password"
type="password"
value={admin.confirmPassword}
onChange={(e) => setAdmin({ ...admin, confirmPassword: e.target.value })}
error={admin.confirmPassword && admin.password !== admin.confirmPassword ? "Passwords don't match" : undefined}
required
/>
<StepNav onBack={() => setStep(1)} onNext={() => setStep(3)} nextDisabled={!canProceedFromStep(2)} />
</div>
)}
{step === 3 && (
<div className="space-y-4">
<h2 className="text-xl font-bold">Business details</h2>
<Input label="Business Name" value={business.name} onChange={(e) => setBusiness({ ...business, name: e.target.value })} required />
<Input
label="Short Name (optional)"
hint="Used where space is tight, e.g. the home-screen icon label."
value={business.shortName}
onChange={(e) => setBusiness({ ...business, shortName: e.target.value })}
/>
<Textarea
label="Description (optional)"
value={business.description}
onChange={(e) => setBusiness({ ...business, description: e.target.value })}
/>
<Input label="Business Email (optional)" type="email" value={business.email} onChange={(e) => setBusiness({ ...business, email: e.target.value })} />
<Input label="Phone Number (optional)" type="tel" value={business.phone} onChange={(e) => setBusiness({ ...business, phone: e.target.value })} />
<Input label="Website (optional)" value={business.website} onChange={(e) => setBusiness({ ...business, website: e.target.value })} />
<StepNav onBack={() => setStep(2)} onNext={() => setStep(4)} nextDisabled={!canProceedFromStep(3)} />
</div>
)}
{step === 4 && (
<div className="space-y-4">
<h2 className="text-xl font-bold">Location & contact</h2>
<Input label="Address Line 1" value={location.addressLine1} onChange={(e) => setLocation({ ...location, addressLine1: e.target.value })} required />
<Input label="Address Line 2 (optional)" value={location.addressLine2} onChange={(e) => setLocation({ ...location, addressLine2: e.target.value })} />
<div className="grid grid-cols-2 gap-3">
<Input label="Town / City" value={location.city} onChange={(e) => setLocation({ ...location, city: e.target.value })} required />
<Input label="County (optional)" value={location.region} onChange={(e) => setLocation({ ...location, region: e.target.value })} />
</div>
<div className="grid grid-cols-2 gap-3">
<Input label="Postcode" value={location.postalCode} onChange={(e) => setLocation({ ...location, postalCode: e.target.value })} required />
<Input label="Country" value={location.country} onChange={(e) => setLocation({ ...location, country: e.target.value })} required />
</div>
<div className="grid grid-cols-3 gap-3">
<Input label="Timezone" value={location.timezone} onChange={(e) => setLocation({ ...location, timezone: e.target.value })} required />
<Input label="Currency" value={location.currency} onChange={(e) => setLocation({ ...location, currency: e.target.value.toUpperCase() })} maxLength={3} required />
<Input label="Locale" value={location.locale} onChange={(e) => setLocation({ ...location, locale: e.target.value })} required />
</div>
<StepNav onBack={() => setStep(3)} onNext={() => setStep(5)} nextDisabled={!canProceedFromStep(4)} />
</div>
)}
{step === 5 && (
<div className="space-y-4">
<h2 className="text-xl font-bold">Opening hours</h2>
<div className="flex gap-4">
<button type="button" onClick={copyMondayToWeekdays} className="text-accent text-xs font-bold tracking-widest uppercase underline">
Copy Monday to weekdays
</button>
<button type="button" onClick={copyMondayToAll} className="text-accent text-xs font-bold tracking-widest uppercase underline">
Copy Monday to all days
</button>
</div>
<div className="divide-line rounded-jumbo border-line divide-y border bg-white">
{hours.map((day) => (
<div key={day.dayOfWeek} className="flex items-center justify-between gap-4 px-5 py-4">
<div className="flex items-center gap-3">
<Switch checked={!day.isClosed} onCheckedChange={(checked) => updateDay(day.dayOfWeek, { isClosed: !checked })} aria-label={`${DAY_LABEL[day.dayOfWeek]} open`} />
<p className="text-sm font-bold">{DAY_LABEL[day.dayOfWeek]}</p>
</div>
{day.isClosed ? (
<span className="text-muted text-xs">Closed</span>
) : (
<div className="flex items-center gap-2">
<input type="time" value={day.startTime} onChange={(e) => updateDay(day.dayOfWeek, { startTime: e.target.value })} className="border-line rounded-lg border px-2 py-1.5 text-xs" aria-label={`${DAY_LABEL[day.dayOfWeek]} opening time`} />
<span className="text-muted text-xs">to</span>
<input type="time" value={day.endTime} onChange={(e) => updateDay(day.dayOfWeek, { endTime: e.target.value })} className="border-line rounded-lg border px-2 py-1.5 text-xs" aria-label={`${DAY_LABEL[day.dayOfWeek]} closing time`} />
</div>
)}
</div>
))}
</div>
<StepNav onBack={() => setStep(4)} onNext={() => setStep(6)} />
</div>
)}
{step === 6 && (
<div className="space-y-4">
<h2 className="text-xl font-bold">Booking settings</h2>
<div className="rounded-jumbo border-line border bg-white p-5">
<Switch
checked={bookingRules.allowGuestBooking}
onCheckedChange={(checked) => setBookingRules({ ...bookingRules, allowGuestBooking: checked })}
label="Allow customers to book as guests"
description="When enabled, customers can book without creating an account. When disabled, customers must sign in or create an account before confirming their booking. Off is recommended — you can turn this on later in Booking Settings."
/>
</div>
<div className="grid grid-cols-2 gap-3">
<Input
label="Minimum Notice (minutes)"
type="number"
value={bookingRules.minNoticeMinutes}
onChange={(e) => setBookingRules({ ...bookingRules, minNoticeMinutes: Number(e.target.value) })}
/>
<Input
label="Max Advance Booking (days)"
type="number"
value={bookingRules.maxAdvanceDays}
onChange={(e) => setBookingRules({ ...bookingRules, maxAdvanceDays: Number(e.target.value) })}
/>
<Input
label="Cancellation Window (hours)"
type="number"
value={bookingRules.cancellationWindowHours}
onChange={(e) => setBookingRules({ ...bookingRules, cancellationWindowHours: Number(e.target.value) })}
/>
<Input
label="Reschedule Window (hours)"
type="number"
value={bookingRules.rescheduleWindowHours}
onChange={(e) => setBookingRules({ ...bookingRules, rescheduleWindowHours: Number(e.target.value) })}
/>
<Input
label="Slot Interval (minutes)"
type="number"
value={bookingRules.slotIntervalMinutes}
onChange={(e) => setBookingRules({ ...bookingRules, slotIntervalMinutes: Number(e.target.value) })}
/>
</div>
<div className="rounded-jumbo border-line space-y-3 border bg-white p-5">
<Switch
checked={bookingRules.customerCanChooseStaff}
onCheckedChange={(checked) => setBookingRules({ ...bookingRules, customerCanChooseStaff: checked })}
label="Customers can choose their barber"
/>
<Switch
checked={bookingRules.requireApproval}
onCheckedChange={(checked) => setBookingRules({ ...bookingRules, requireApproval: checked })}
label="New bookings require staff approval"
/>
</div>
<StepNav onBack={() => setStep(5)} onNext={() => setStep(7)} />
</div>
)}
{step === 7 && (
<div className="space-y-4">
<h2 className="text-xl font-bold">Branding</h2>
<p className="text-muted text-sm">
Optional — every screen in the app uses your business name and these images/colours instead of the
product name. You can add or change all of this later in Business Settings.
</p>
<Input label="Logo URL (optional)" placeholder="https://…" value={branding.logoUrl} onChange={(e) => setBranding({ ...branding, logoUrl: e.target.value })} />
<Input label="Cover Image URL (optional)" placeholder="https://…" value={branding.coverImageUrl} onChange={(e) => setBranding({ ...branding, coverImageUrl: e.target.value })} />
<div className="grid grid-cols-2 gap-3">
<Input label="Theme Colour (optional)" placeholder="#111b15" value={branding.themeColor} onChange={(e) => setBranding({ ...branding, themeColor: e.target.value })} />
<Input label="Background Colour (optional)" placeholder="#f4f6f4" value={branding.backgroundColor} onChange={(e) => setBranding({ ...branding, backgroundColor: e.target.value })} />
</div>
<StepNav onBack={() => setStep(6)} onNext={() => setStep(8)} />
</div>
)}
{step === 8 && (
<div className="space-y-4">
<h2 className="text-xl font-bold">Starter services</h2>
<p className="text-muted text-sm">
A small editable starting point so your booking page isn&apos;t empty on day one. Edit, remove, or add
your own before finishing setup.
</p>
<div className="space-y-3">
{starterServices.map((s) => (
<div key={s.key} className="rounded-jumbo border-line space-y-3 border bg-white p-4">
<div className="flex items-center justify-between">
<p className="text-muted text-[10px] font-bold tracking-widest uppercase">Service</p>
<button type="button" onClick={() => removeStarter(s.key)} className="text-danger text-xs font-bold uppercase">
Remove
</button>
</div>
<div className="grid grid-cols-2 gap-3">
<Input label="Category" value={s.categoryName} onChange={(e) => updateStarter(s.key, { categoryName: e.target.value })} />
<Input label="Service Name" value={s.name} onChange={(e) => updateStarter(s.key, { name: e.target.value })} />
</div>
<div className="grid grid-cols-2 gap-3">
<Input label="Price" value={s.price} onChange={(e) => updateStarter(s.key, { price: e.target.value })} />
<Input label="Duration (minutes)" type="number" value={s.durationMinutes} onChange={(e) => updateStarter(s.key, { durationMinutes: Number(e.target.value) })} />
</div>
</div>
))}
</div>
<Button variant="secondary" className="w-full" onClick={addStarter}>
Add Another Service
</Button>
<StepNav onBack={() => setStep(7)} onNext={() => setStep(9)} />
</div>
)}
{step === 9 && (
<div className="space-y-4">
<h2 className="text-xl font-bold">Review</h2>
<ReviewSection title="Administrator">
{admin.firstName} {admin.lastName} · {admin.email}
</ReviewSection>
<ReviewSection title="Business">{business.name}</ReviewSection>
<ReviewSection title="Location">
{location.addressLine1}, {location.city}, {location.postalCode} · {location.timezone} · {location.currency}
</ReviewSection>
<ReviewSection title="Opening Hours">
{hours.filter((d) => !d.isClosed).length} day{hours.filter((d) => !d.isClosed).length === 1 ? "" : "s"} open
</ReviewSection>
<ReviewSection title="Booking Settings">
Guest booking {bookingRules.allowGuestBooking ? "enabled" : "disabled"} · {bookingRules.requireApproval ? "requires approval" : "auto-confirms"}
</ReviewSection>
<ReviewSection title="Branding">{branding.logoUrl ? "Logo set" : "No logo yet — add one later in Business Settings"}</ReviewSection>
<ReviewSection title="Starter Services">
{starterServices.filter((s) => s.name.trim()).length} service{starterServices.filter((s) => s.name.trim()).length === 1 ? "" : "s"}
</ReviewSection>
{submitError && (
<p role="alert" className="text-danger text-sm font-medium">
{submitError}
</p>
)}
<div className="flex gap-3">
<Button variant="secondary" className="flex-1" onClick={() => setStep(8)} disabled={isPending}>
Back
</Button>
<Button className="flex-1" isLoading={isPending} onClick={submit}>
Confirm & Install
</Button>
</div>
</div>
)}
</div>
);
}
function StepNav({ onBack, onNext, nextDisabled }: { onBack: () => void; onNext: () => void; nextDisabled?: boolean }) {
return (
<div className="flex gap-3">
<Button variant="secondary" className="flex-1" onClick={onBack}>
Back
</Button>
<Button className="flex-1" onClick={onNext} disabled={nextDisabled}>
Continue
</Button>
</div>
);
}
function ReviewSection({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="rounded-jumbo border-line border bg-white p-4">
<p className="text-muted text-[10px] font-bold tracking-widest uppercase">{title}</p>
<p className="mt-1 text-sm">{children}</p>
</div>
);
}
@@ -0,0 +1,318 @@
import bcrypt from "bcryptjs";
import type { PrismaClient, Prisma } from "@/generated/prisma/client";
import { InstallationStatus, RewardType } from "@/generated/prisma/enums";
import { INSTALLATION_SINGLETON_ID } from "@/lib/installation/installation-state";
import { OWNER_PERMISSIONS, BARBER_PERMISSIONS } from "@/lib/roles/default-permissions";
import { secretsMatch } from "@/lib/security/secrets-match";
import { validateBusinessProfileInput, validateOpeningHoursInput } from "@/services/business/business.service";
import { validateBookingRuleInput } from "@/services/scheduling/booking-rule.service";
import { validateCategoryInput, validateServiceInput } from "@/services/catalogue/service-catalogue.service";
import { fail, ok } from "@/lib/validation-result";
import type { ValidationResult } from "@/lib/validation-result";
import type { InstallInput } from "./schema";
export type InstallErrorCode =
| "INVALID_TOKEN"
| "ALREADY_INSTALLED"
| "VALIDATION_FAILED"
| "EMAIL_ALREADY_IN_USE"
| "INSTALL_FAILED";
export interface InstallSuccess {
success: true;
userId: string;
businessId: string;
}
export interface InstallFailure {
success: false;
code: InstallErrorCode;
message: string;
}
export type InstallResult = InstallSuccess | InstallFailure;
function slugify(name: string): string {
const base = name
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
// Appended so a second install attempt with the same business name (a
// retried/failed install, or genuinely two identically-named
// businesses in dev/test) never collides on Business.slug's unique
// constraint.
return `${base || "business"}-${Date.now().toString(36)}`;
}
function validateAllInput(input: InstallInput): ValidationResult<InstallErrorCode> {
const businessValidation = validateBusinessProfileInput({
name: input.business.name,
description: input.business.description || undefined,
phone: input.business.phone || undefined,
email: input.business.email || undefined,
website: input.business.website || undefined,
timezone: input.location.timezone,
currency: input.location.currency,
locale: input.location.locale,
});
if (!businessValidation.valid) {
return fail({ code: "VALIDATION_FAILED", message: businessValidation.issues[0]!.message });
}
if (!input.location.addressLine1.trim() || !input.location.city.trim() || !input.location.postalCode.trim()) {
return fail({ code: "VALIDATION_FAILED", message: "Address line 1, city, and postcode are required." });
}
const hoursValidation = validateOpeningHoursInput(input.hours);
if (!hoursValidation.valid) {
return fail({ code: "VALIDATION_FAILED", message: hoursValidation.issues[0]!.message });
}
const bookingRuleValidation = validateBookingRuleInput(input.bookingRules);
if (!bookingRuleValidation.valid) {
return fail({ code: "VALIDATION_FAILED", message: bookingRuleValidation.issues[0]!.message });
}
for (const service of input.starterServices) {
const categoryValidation = validateCategoryInput({ name: service.categoryName });
if (!categoryValidation.valid) {
return fail({ code: "VALIDATION_FAILED", message: categoryValidation.issues[0]!.message });
}
const serviceValidation = validateServiceInput({
categoryId: "placeholder", // category is created inside the transaction, before we know its id
name: service.name,
description: service.description || undefined,
durationMinutes: service.durationMinutes,
price: service.price,
bufferBeforeMinutes: 0,
bufferAfterMinutes: 0,
allowOnlineBooking: service.allowOnlineBooking,
});
if (!serviceValidation.valid) {
return fail({ code: "VALIDATION_FAILED", message: serviceValidation.issues[0]!.message });
}
}
return ok();
}
/**
* The one place a fresh deployment goes from "empty database" to "a real,
* usable business" — see docs/phase-8-summary.md for the full narrative.
* Deliberately separate from prisma/seed.ts (dev/demo fixtures only, never
* run against a real deployment) and from every existing owner-facing
* service (BusinessService, LoyaltyService, etc., which all assume a
* business already exists) — this is the one codepath that creates one.
*
* Token verification happens twice by design: once cheaply in the
* `verifyInstallationTokenAction` Server Action (so the wizard's first
* step can give immediate feedback), and again here, as the very first
* thing this function does, before any database write — a client that
* skips straight to final submission without ever calling the first
* check must still be rejected. Neither check ever reveals *why* a token
* was wrong.
*/
export async function installApplication(
prisma: PrismaClient,
params: { input: InstallInput; installedVersion: string },
): Promise<InstallResult> {
const expectedToken = process.env.INSTALLATION_TOKEN;
if (!expectedToken) {
return { success: false, code: "INVALID_TOKEN", message: "Installation is not available." };
}
if (!secretsMatch(params.input.installationToken, expectedToken)) {
return { success: false, code: "INVALID_TOKEN", message: "Installation is not available." };
}
const validation = validateAllInput(params.input);
if (!validation.valid) {
return { success: false, code: "VALIDATION_FAILED", message: validation.issues[0]!.message };
}
const existingUser = await prisma.user.findUnique({ where: { email: params.input.administrator.email.toLowerCase() } });
if (existingUser) {
return { success: false, code: "EMAIL_ALREADY_IN_USE", message: "That email is already in use." };
}
try {
const result = await prisma.$transaction(async (tx) => {
// First statement, and the concurrency lock — see
// ApplicationInstallation's own header in prisma/schema.prisma. A
// concurrent second install attempt blocks on this same
// primary-key insert and then fails with a unique-constraint
// violation once this transaction commits (or proceeds cleanly if
// this transaction instead rolls back on a later failure).
await tx.applicationInstallation.create({
data: { id: INSTALLATION_SINGLETON_ID, status: InstallationStatus.INSTALLING },
});
const { administrator, business: businessInput, location, hours, bookingRules, branding, starterServices } =
params.input;
const business = await tx.business.create({
data: {
slug: slugify(businessInput.name),
name: businessInput.name.trim(),
shortName: businessInput.shortName?.trim() || null,
description: businessInput.description?.trim() || null,
logoUrl: branding.logoUrl?.trim() || null,
coverImageUrl: branding.coverImageUrl?.trim() || null,
themeColor: branding.themeColor?.trim() || null,
backgroundColor: branding.backgroundColor?.trim() || null,
phone: businessInput.phone?.trim() || null,
email: businessInput.email?.trim() || null,
website: businessInput.website?.trim() || null,
timezone: location.timezone,
currency: location.currency.toUpperCase(),
locale: location.locale,
locations: {
create: {
addressLine1: location.addressLine1.trim(),
addressLine2: location.addressLine2?.trim() || null,
city: location.city.trim(),
region: location.region?.trim() || null,
postalCode: location.postalCode.trim(),
country: location.country.trim(),
isPrimary: true,
},
},
settings: { create: {} },
hours: {
create: hours.map((day) => ({
dayOfWeek: day.dayOfWeek,
isClosed: day.isClosed,
opensAtMinutes: day.isClosed ? null : (day.opensAtMinutes ?? null),
closesAtMinutes: day.isClosed ? null : (day.closesAtMinutes ?? null),
})),
},
bookingRule: { create: { ...bookingRules } },
// Disabled by default — the brief is explicit that installation
// must not silently turn loyalty on; the owner opts in later via
// /owner/settings/loyalty, same as every other post-install
// configuration choice.
loyaltyProgramme: {
create: { isEnabled: false, stampsRequired: 10, rewardType: RewardType.PERCENTAGE },
},
},
});
const [ownerRole] = await Promise.all([
tx.role.create({
data: {
businessId: business.id,
name: "Owner",
isSystem: true,
permissions: { create: OWNER_PERMISSIONS.map((permission) => ({ permission })) },
},
}),
tx.role.create({
data: {
businessId: business.id,
name: "Barber",
isSystem: true,
permissions: { create: BARBER_PERMISSIONS.map((permission) => ({ permission })) },
},
}),
]);
const passwordHash = await bcrypt.hash(administrator.password, 12);
const user = await tx.user.create({
data: {
email: administrator.email.toLowerCase(),
name: `${administrator.firstName.trim()} ${administrator.lastName.trim()}`,
phone: administrator.phone?.trim() || null,
passwordHash,
emailVerified: new Date(),
staffProfile: {
create: {
businessId: business.id,
roleId: ownerRole.id,
isBookableOnline: true,
},
},
},
include: { staffProfile: true },
});
const staffId = user.staffProfile!.id;
// Starter services: one ServiceCategory per distinct categoryName,
// created in submission order, then a Service row per entry —
// never silently invented data, only what the installer explicitly
// reviewed and approved in Step 8 (see install-wizard.tsx).
const categoryIdByName = new Map<string, string>();
for (const starter of starterServices) {
if (!categoryIdByName.has(starter.categoryName)) {
const category = await tx.serviceCategory.create({
data: { businessId: business.id, name: starter.categoryName },
});
categoryIdByName.set(starter.categoryName, category.id);
}
}
const createdServiceIds: string[] = [];
for (const starter of starterServices) {
const categoryId = categoryIdByName.get(starter.categoryName)!;
const service = await tx.service.create({
data: {
businessId: business.id,
categoryId,
name: starter.name.trim(),
description: starter.description?.trim() || null,
durationMinutes: starter.durationMinutes,
price: starter.price,
isActive: starter.active,
allowOnlineBooking: starter.allowOnlineBooking,
},
});
createdServiceIds.push(service.id);
}
if (createdServiceIds.length > 0) {
await tx.staffService.createMany({
data: createdServiceIds.map((serviceId) => ({ staffId, serviceId })),
});
}
await tx.applicationInstallation.update({
where: { id: INSTALLATION_SINGLETON_ID },
data: {
status: InstallationStatus.INSTALLED,
installedAt: new Date(),
installedVersion: params.installedVersion,
initialAdminUserId: user.id,
primaryBusinessId: business.id,
metadata: { starterServiceCount: createdServiceIds.length } satisfies Prisma.InputJsonValue,
},
});
await tx.auditLog.create({
data: {
actorUserId: user.id,
action: "APPLICATION_INSTALLED",
entityType: "Business",
entityId: business.id,
metadata: { businessName: business.name } satisfies Prisma.InputJsonValue,
},
});
return { userId: user.id, businessId: business.id };
});
return { success: true, userId: result.userId, businessId: result.businessId };
} catch (error) {
// A concurrent installer won the create() race — see the header
// comment above. Anything else is an unexpected failure; either way,
// the transaction rolled back and left no partial data (see
// ApplicationInstallation's own header for why that's true even for
// the lock row itself).
const isDuplicateSingleton =
typeof error === "object" && error !== null && "code" in error && (error as { code?: string }).code === "P2002";
if (isDuplicateSingleton) {
return { success: false, code: "ALREADY_INSTALLED", message: "This application has already been installed." };
}
return {
success: false,
code: "INSTALL_FAILED",
message: "Installation failed. No changes were saved — you can safely try again.",
};
}
}
+109
View File
@@ -0,0 +1,109 @@
import { z } from "zod";
/**
* Single source of truth for the installation wizard's shape — shared
* between the client wizard component and the final Server Action (see
* actions/install.ts). Deliberately one flat schema, not one per wizard
* step: the wizard itself is client-side-only state (see
* install-wizard.tsx's own header for why) and nothing is validated or
* written server-side until the single final submission, so there is only
* ever one real validation boundary to define.
*/
export const installAdministratorSchema = z
.object({
firstName: z.string().trim().min(1, "First name is required"),
lastName: z.string().trim().min(1, "Last name is required"),
email: z.email("Enter a valid email address"),
phone: z.string().trim().optional().or(z.literal("")),
password: z.string().min(8, "Password must be at least 8 characters"),
confirmPassword: z.string(),
})
.refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ["confirmPassword"],
});
export const installBusinessSchema = z.object({
name: z.string().trim().min(1, "Business name is required"),
shortName: z.string().trim().max(40).optional().or(z.literal("")),
description: z.string().trim().optional().or(z.literal("")),
email: z.email("Enter a valid email address").optional().or(z.literal("")),
phone: z.string().trim().optional().or(z.literal("")),
website: z.string().trim().optional().or(z.literal("")),
});
export const installLocationSchema = z.object({
addressLine1: z.string().trim().min(1, "Address line 1 is required"),
addressLine2: z.string().trim().optional().or(z.literal("")),
city: z.string().trim().min(1, "Town or city is required"),
region: z.string().trim().optional().or(z.literal("")),
postalCode: z.string().trim().min(1, "Postcode is required"),
country: z.string().trim().min(1, "Country is required"),
timezone: z.string().trim().min(1, "Timezone is required"),
currency: z.string().trim().length(3, "Currency should be a 3-letter code, e.g. GBP"),
locale: z.string().trim().min(1, "Locale is required"),
});
const weekdaySchema = z.enum(["MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY", "SATURDAY", "SUNDAY"]);
export const installHoursDaySchema = z.object({
dayOfWeek: weekdaySchema,
isClosed: z.boolean(),
opensAtMinutes: z.number().int().min(0).max(1439).optional(),
closesAtMinutes: z.number().int().min(0).max(1439).optional(),
});
export const installBookingRulesSchema = z.object({
allowGuestBooking: z.boolean(),
minNoticeMinutes: z.number().int().min(0),
maxAdvanceDays: z.number().int().min(1),
cancellationWindowHours: z.number().int().min(0),
rescheduleWindowHours: z.number().int().min(0),
slotIntervalMinutes: z.number().int().min(5).max(120),
bufferBeforeMinutes: z.number().int().min(0),
bufferAfterMinutes: z.number().int().min(0),
maxAppointmentsPerDay: z.number().int().min(1).nullable(),
maxAppointmentsPerStaffPerDay: z.number().int().min(1).nullable(),
customerCanChooseStaff: z.boolean(),
requireApproval: z.boolean(),
});
export const installBrandingSchema = z.object({
logoUrl: z.string().trim().optional().or(z.literal("")),
coverImageUrl: z.string().trim().optional().or(z.literal("")),
themeColor: z.string().trim().optional().or(z.literal("")),
backgroundColor: z.string().trim().optional().or(z.literal("")),
});
const decimalPrice = z.string().regex(/^\d+(\.\d{1,2})?$/, "Enter a price like 22.50");
export const installStarterServiceSchema = z.object({
categoryName: z.string().trim().min(1),
name: z.string().trim().min(1),
description: z.string().trim().optional().or(z.literal("")),
price: decimalPrice,
durationMinutes: z.number().int().min(5).max(480),
active: z.boolean(),
allowOnlineBooking: z.boolean(),
});
export const installSchema = z.object({
installationToken: z.string().min(1, "Installation token is required"),
administrator: installAdministratorSchema,
business: installBusinessSchema,
location: installLocationSchema,
hours: z.array(installHoursDaySchema).length(7),
bookingRules: installBookingRulesSchema,
branding: installBrandingSchema,
starterServices: z.array(installStarterServiceSchema).max(20),
});
export type InstallAdministratorInput = z.infer<typeof installAdministratorSchema>;
export type InstallBusinessInput = z.infer<typeof installBusinessSchema>;
export type InstallLocationInput = z.infer<typeof installLocationSchema>;
export type InstallHoursDayInput = z.infer<typeof installHoursDaySchema>;
export type InstallBookingRulesInput = z.infer<typeof installBookingRulesSchema>;
export type InstallBrandingInput = z.infer<typeof installBrandingSchema>;
export type InstallStarterServiceInput = z.infer<typeof installStarterServiceSchema>;
export type InstallInput = z.infer<typeof installSchema>;