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

+3 -1
View File
@@ -6,6 +6,7 @@ import { prisma } from "@/lib/db/prisma";
import { registerSchema } from "@/features/auth/schemas";
import { checkRateLimit } from "@/lib/security/rate-limit";
import { getRequestIp } from "@/lib/security/request-ip";
import { safeRedirectPath } from "@/lib/utils/safe-redirect";
export interface RegisterResult {
success: boolean;
@@ -75,7 +76,8 @@ export async function registerAction(formData: FormData): Promise<RegisterResult
},
});
await signIn("credentials", { email, password, redirectTo: "/home" });
const redirectTo = safeRedirectPath(formData.get("callbackUrl")?.toString(), "/home");
await signIn("credentials", { email, password, redirectTo });
return { success: true };
}
@@ -39,8 +39,8 @@ export function AcceptInvitationForm({ token, businessName }: { token: string; b
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate className="space-y-4">
<p className="text-muted text-sm">
You&apos;ve been invited to join <span className="text-ink font-bold">{businessName}</span> on NextChair. Set
a password to finish setting up your account.
You&apos;ve been invited to join <span className="text-ink font-bold">{businessName}</span>. Set a password
to finish setting up your account.
</p>
<Input
label="Password"
@@ -2,14 +2,18 @@
import { zodResolver } from "@hookform/resolvers/zod";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { registerAction } from "@/features/auth/actions/register";
import { registerSchema, type RegisterInput } from "@/features/auth/schemas";
import { safeRedirectPath } from "@/lib/utils/safe-redirect";
export function RegisterForm() {
const searchParams = useSearchParams();
const callbackUrl = safeRedirectPath(searchParams.get("callbackUrl"), "/home");
const [formError, setFormError] = useState<string | null>(null);
const {
@@ -25,6 +29,7 @@ export function RegisterForm() {
setFormError(null);
const formData = new FormData();
Object.entries(data).forEach(([key, value]) => formData.set(key, value ?? ""));
formData.set("callbackUrl", callbackUrl);
const result = await registerAction(formData);
@@ -111,7 +116,7 @@ export function RegisterForm() {
<p className="text-muted mt-10 text-center text-sm">
Already have an account?{" "}
<Link href="/sign-in" className="border-ink text-ink border-b font-bold">
<Link href={`/sign-in?callbackUrl=${encodeURIComponent(callbackUrl)}`} className="border-ink text-ink border-b font-bold">
Log in
</Link>
</p>
@@ -10,10 +10,11 @@ import { useForm } from "react-hook-form";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { signInSchema, type SignInInput } from "@/features/auth/schemas";
import { safeRedirectPath } from "@/lib/utils/safe-redirect";
export function SignInForm() {
const searchParams = useSearchParams();
const callbackUrl = searchParams.get("callbackUrl") ?? "/home";
const callbackUrl = safeRedirectPath(searchParams.get("callbackUrl"), "/home");
const [formError, setFormError] = useState<string | null>(null);
const {
@@ -91,7 +92,7 @@ export function SignInForm() {
<p className="text-muted mt-10 text-center text-sm">
Don&apos;t have an account?{" "}
<Link href="/register" className="border-ink text-ink border-b-2 font-bold">
<Link href={`/register?callbackUrl=${encodeURIComponent(callbackUrl)}`} className="border-ink text-ink border-b-2 font-bold">
Create one
</Link>
</p>
+16
View File
@@ -8,6 +8,7 @@ import { checkRateLimit } from "@/lib/security/rate-limit";
import { getRequestIp } from "@/lib/security/request-ip";
import { resolveCustomerForBooking } from "@/services/customer/customer.service";
import { createBooking } from "@/services/scheduling/booking.service";
import { syncAppointmentSafely } from "@/services/calendar/calendar-sync.service";
import { getFirstFriendlyMessage } from "../error-messages";
export interface ConfirmBookingResult {
@@ -44,6 +45,16 @@ export async function confirmBookingAction(businessId: string): Promise<ConfirmB
if (!draft.guest) {
return { success: false, error: "Please provide your contact details first." };
}
// Authoritative guest-booking gate — the UI already hides the
// guest path when disabled (see /book/details), but this is the
// one place that actually creates an Appointment row, so it's
// re-checked here regardless of how the request arrived (a direct
// Server Action call, a stale draft cookie started before the
// owner disabled guest booking mid-flow, etc.).
const bookingRule = await prisma.bookingRule.findUnique({ where: { businessId }, select: { allowGuestBooking: true } });
if (!bookingRule?.allowGuestBooking) {
return { success: false, error: "Guest booking is currently unavailable. Please sign in or create an account to continue." };
}
// Registered customers already have a rate-limited signin gate on
// their account; a guest has none, so this is the one place spam
// bookings can otherwise be generated with no account at all.
@@ -72,6 +83,11 @@ export async function confirmBookingAction(businessId: string): Promise<ConfirmB
return { success: false, error: getFirstFriendlyMessage(result.validation) };
}
// A no-op when the appointment is PENDING (requires approval) — see
// calendar-sync.service.ts's own policy comment. Never blocks or fails
// this action either way.
await syncAppointmentSafely(prisma, result.appointment.id);
await writeLastBookingReference({
appointmentId: result.appointment.id,
guestAccessToken: result.guestAccessToken,
@@ -5,6 +5,7 @@ import { auth } from "@/auth";
import { prisma } from "@/lib/db/prisma";
import { getAppointmentById } from "@/services/scheduling/appointment.service";
import { cancelBooking, rescheduleBooking } from "@/services/scheduling/booking.service";
import { syncAppointmentSafely } from "@/services/calendar/calendar-sync.service";
import { assertCanAccessAppointment, PermissionDeniedError } from "../authorization";
import { getFirstFriendlyMessage } from "../error-messages";
import { customerCancelSchema, customerRescheduleSchema } from "../schemas";
@@ -50,6 +51,8 @@ export async function cancelCustomerAppointmentAction(input: unknown): Promise<C
return { success: false, error: getFirstFriendlyMessage(result.validation) };
}
await syncAppointmentSafely(prisma, parsed.data.appointmentId);
// Deliberately not revalidating here (unlike reschedule) — every affected
// page is already `dynamic = "force-dynamic"`, so revalidatePath buys no
// extra freshness, and doing it immediately would drop the cancelled
@@ -87,6 +90,8 @@ export async function rescheduleCustomerAppointmentAction(input: unknown): Promi
return { success: false, error: getFirstFriendlyMessage(result.validation) };
}
await syncAppointmentSafely(prisma, parsed.data.appointmentId);
revalidatePath("/appointments");
revalidatePath(`/appointments/${parsed.data.appointmentId}`);
return { success: true };
+10
View File
@@ -96,6 +96,16 @@ export async function saveDetailsStepAction(input: unknown): Promise<StepActionR
const session = await auth();
if (!session?.user) {
// Same authoritative check confirmBookingAction re-runs at the final
// commit — rejected here too so a direct call to this action (not
// just the confirm step) can't bypass a disabled guest-booking rule.
const bookingRule = await prisma.bookingRule.findUnique({
where: { businessId: parsed.data.businessId },
select: { allowGuestBooking: true },
});
if (!bookingRule?.allowGuestBooking) {
return { success: false, error: "Please sign in or create an account to continue." };
}
const guestParsed = guestDetailsSchema.safeParse({
firstName: parsed.data.firstName,
lastName: parsed.data.lastName,
+100
View File
@@ -0,0 +1,100 @@
"use server";
import { auth } from "@/auth";
import { prisma } from "@/lib/db/prisma";
import { decryptToken, encryptToken } from "@/lib/security/token-encryption";
import { GoogleCalendarProvider } from "@/services/calendar/google-calendar.provider";
import { CalendarProviderError } from "@/services/calendar/types";
import type { RemoteCalendarInfo } from "@/services/calendar/types";
import { runInitialSync } from "@/services/calendar/calendar-sync.service";
async function requireStaffId(): Promise<string> {
const session = await auth();
if (!session?.user?.id) throw new Error("Not signed in.");
const staffProfile = await prisma.staffProfile.findUnique({ where: { userId: session.user.id }, select: { id: true } });
if (!staffProfile) throw new Error("Not a staff member.");
return staffProfile.id;
}
export interface CalendarActionResult {
success: boolean;
error?: string;
}
export async function disconnectCalendarAction(): Promise<CalendarActionResult> {
const staffId = await requireStaffId();
const connection = await prisma.staffCalendarConnection.findUnique({ where: { staffId } });
if (!connection) return { success: true };
// Best-effort revoke, then always clear locally regardless of whether
// Google's revoke call succeeded — see the brief's "Revoke credentials
// where possible" (not "must succeed before disconnecting").
const provider = new GoogleCalendarProvider();
await provider.revoke(decryptToken(connection.encryptedRefreshToken)).catch(() => {});
// Existing AppointmentCalendarEvent rows and their remote events are
// deliberately left alone — see the brief's "Leave existing events,
// stop managing them." Only the connection itself is removed, so future
// appointment changes have nothing left to sync against.
await prisma.staffCalendarConnection.update({
where: { staffId },
data: { revokedAt: new Date(), encryptedAccessToken: null, tokenExpiresAt: null },
});
return { success: true };
}
export async function syncNowAction(): Promise<CalendarActionResult> {
const staffId = await requireStaffId();
const connection = await prisma.staffCalendarConnection.findUnique({ where: { staffId } });
if (!connection || connection.revokedAt) {
return { success: false, error: "Connect Google Calendar first." };
}
await runInitialSync(prisma, staffId);
return { success: true };
}
export interface ListCalendarsResult {
success: boolean;
calendars?: RemoteCalendarInfo[];
error?: string;
}
export async function listAvailableCalendarsAction(): Promise<ListCalendarsResult> {
const staffId = await requireStaffId();
const connection = await prisma.staffCalendarConnection.findUnique({ where: { staffId } });
if (!connection || connection.revokedAt) {
return { success: false, error: "Connect Google Calendar first." };
}
const provider = new GoogleCalendarProvider();
try {
let accessToken = connection.encryptedAccessToken ? decryptToken(connection.encryptedAccessToken) : null;
const expired = !connection.tokenExpiresAt || connection.tokenExpiresAt.getTime() - Date.now() < 60_000;
if (!accessToken || expired) {
const refreshed = await provider.refreshAccessToken(decryptToken(connection.encryptedRefreshToken));
accessToken = refreshed.accessToken;
await prisma.staffCalendarConnection.update({
where: { staffId },
data: { encryptedAccessToken: encryptToken(refreshed.accessToken), tokenExpiresAt: refreshed.expiresAt },
});
}
const calendars = await provider.listWritableCalendars(accessToken);
return { success: true, calendars };
} catch (error) {
const message = error instanceof CalendarProviderError ? error.message : "Couldn't load calendars.";
return { success: false, error: message };
}
}
export async function selectCalendarAction(calendarId: string, calendarName: string): Promise<CalendarActionResult> {
const staffId = await requireStaffId();
const connection = await prisma.staffCalendarConnection.findUnique({ where: { staffId } });
if (!connection || connection.revokedAt) {
return { success: false, error: "Connect Google Calendar first." };
}
await prisma.staffCalendarConnection.update({
where: { staffId },
data: { selectedCalendarId: calendarId, selectedCalendarName: calendarName },
});
return { success: true };
}
@@ -0,0 +1,128 @@
"use client";
import { useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import {
disconnectCalendarAction,
listAvailableCalendarsAction,
selectCalendarAction,
syncNowAction,
} from "../actions/calendar";
import type { RemoteCalendarInfo } from "@/services/calendar/types";
export interface ConnectionState {
connected: boolean;
accountEmail?: string;
selectedCalendarName?: string;
lastSyncAt?: string;
lastSyncError?: string | null;
}
export function CalendarConnectionPanel({ initial, configured }: { initial: ConnectionState; configured: boolean }) {
const [state, setState] = useState(initial);
const [isPending, startTransition] = useTransition();
const [message, setMessage] = useState<string | null>(null);
const [calendars, setCalendars] = useState<RemoteCalendarInfo[] | null>(null);
const sync = () => {
setMessage(null);
startTransition(async () => {
const result = await syncNowAction();
setMessage(result.success ? "Sync complete." : (result.error ?? "Sync failed."));
});
};
const disconnect = () => {
setMessage(null);
startTransition(async () => {
await disconnectCalendarAction();
setState({ connected: false });
setMessage("Disconnected. Existing calendar events were left as they are.");
});
};
const loadCalendars = () => {
setMessage(null);
startTransition(async () => {
const result = await listAvailableCalendarsAction();
if (!result.success) {
setMessage(result.error ?? "Couldn't load calendars.");
return;
}
setCalendars(result.calendars ?? []);
});
};
const chooseCalendar = (calendar: RemoteCalendarInfo) => {
startTransition(async () => {
const result = await selectCalendarAction(calendar.id, calendar.name);
if (result.success) {
setState((prev) => ({ ...prev, selectedCalendarName: calendar.name }));
setCalendars(null);
setMessage(`Now syncing to "${calendar.name}".`);
}
});
};
if (!state.connected) {
return (
<div className="border-line rounded-jumbo border bg-white p-5">
<p className="mb-1 text-sm font-bold">Google Calendar</p>
<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.
</p>
{message && <p className="text-danger mb-3 text-xs font-medium">{message}</p>}
{configured ? (
<Button asChild size="sm">
<a href="/api/staff/calendar/google/authorize">Connect Google Calendar</a>
</Button>
) : (
<p className="text-muted text-xs">Google Calendar sync is not configured on this deployment yet.</p>
)}
</div>
);
}
return (
<div className="border-line rounded-jumbo border bg-white p-5">
<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 &ldquo;{state.selectedCalendarName}&rdquo;</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>
{message && <p className="text-muted mb-3 text-xs font-medium">{message}</p>}
{calendars ? (
<ul className="mb-4 space-y-2">
{calendars.map((calendar) => (
<li key={calendar.id}>
<button
type="button"
onClick={() => chooseCalendar(calendar)}
className="border-line w-full rounded-lg border px-3 py-2 text-left text-xs font-bold hover:bg-stone"
>
{calendar.name} {calendar.primary && "(primary)"}
</button>
</li>
))}
</ul>
) : null}
<div className="flex flex-wrap gap-2">
<Button size="sm" isLoading={isPending} onClick={sync}>
Sync Now
</Button>
<Button size="sm" variant="secondary" isLoading={isPending} onClick={loadCalendars}>
Change Calendar
</Button>
<Button size="sm" variant="destructive" isLoading={isPending} onClick={disconnect}>
Disconnect
</Button>
</div>
</div>
);
}
@@ -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>;
+9 -3
View File
@@ -16,6 +16,7 @@ import {
} from "@/services/scheduling/appointment.service";
import { awardStampForCompletedAppointment } from "@/services/loyalty/loyalty.service";
import { notifyAppointmentNoShow, notifyBookingApproved, notifyBookingBarberChanged, notifyBookingCompleted } from "@/services/scheduling/booking-notifications";
import { syncAppointmentSafely } from "@/services/calendar/calendar-sync.service";
import { writeAuditLog } from "@/lib/audit/write-audit-log";
import type { StaffAppointmentActionResult } from "@/features/staff/actions/appointment-actions";
@@ -79,6 +80,7 @@ export async function ownerConfirmAppointmentAction(appointmentId: string): Prom
return { success: false, error: friendlyTransitionMessage(result.validation.issues[0]?.code) };
}
await notifyBookingApproved(prisma, result.appointment);
await syncAppointmentSafely(prisma, appointmentId);
return { success: true };
}
@@ -125,6 +127,7 @@ export async function ownerMarkNoShowAction(appointmentId: string): Promise<Staf
return { success: false, error: friendlyTransitionMessage(result.validation.issues[0]?.code) };
}
await notifyAppointmentNoShow(prisma, result.appointment);
await syncAppointmentSafely(prisma, appointmentId);
return { success: true };
}
@@ -140,9 +143,11 @@ export async function ownerCancelAppointmentAction(params: {
cancelledByUserId: owner.userId,
reason: params.reason,
});
return result.success
? { success: true }
: { success: false, error: result.validation.issues[0]?.message ?? "Couldn't cancel this appointment." };
if (!result.success) {
return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't cancel this appointment." };
}
await syncAppointmentSafely(prisma, params.appointmentId);
return { success: true };
}
export interface OwnerRescheduleResult {
@@ -181,5 +186,6 @@ export async function ownerRescheduleAppointmentAction(params: {
await notifyBookingBarberChanged(prisma, result.appointment);
}
await syncAppointmentSafely(prisma, params.appointmentId);
return { success: true };
}
@@ -136,8 +136,8 @@ export function BookingRulesForm({ initial }: { initial: BookingRuleInput }) {
onCheckedChange={(v) => set("customerCanChooseStaff", v)}
/>
<Switch
label="Allow guest bookings"
description="Let customers book without an account."
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."
checked={form.allowGuestBooking}
onCheckedChange={(v) => set("allowGuestBooking", v)}
/>
@@ -1,79 +1,37 @@
"use client";
import { Download } from "lucide-react";
import { useEffect, useState } from "react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
interface BeforeInstallPromptEvent extends Event {
prompt: () => Promise<void>;
userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
}
function isStandalone(): boolean {
if (typeof window === "undefined") return false;
return (
window.matchMedia("(display-mode: standalone)").matches ||
// iOS Safari's own (non-standard, but the only signal it exposes).
(window.navigator as Navigator & { standalone?: boolean }).standalone === true
);
}
function isIos(): boolean {
if (typeof navigator === "undefined") return false;
return /iphone|ipad|ipod/i.test(navigator.userAgent);
}
import { useInstallPrompt } from "../hooks/use-install-prompt";
/**
* Chrome/Edge/Android fire `beforeinstallprompt` when the manifest +
* service worker criteria are met — we capture and defer it (the spec
* requires preventDefault() to control exactly when the native prompt
* shows) and trigger it from this button instead of an unsolicited popup.
* iOS Safari never fires that event at all, so it gets manual
* "Add to Home Screen" instructions instead — there's no programmatic
* install API on that platform.
* service worker criteria are met — captured and triggered from this
* button instead of an unsolicited popup (see use-install-prompt.ts,
* shared with WelcomeInstallCard so there's exactly one PWA install
* implementation, not two). iOS Safari never fires that event at all, so
* it gets manual "Add to Home Screen" instructions instead. `businessName`
* comes from the caller — every Settings page already fetches branding
* server-side, so this client component doesn't need to.
*/
export function InstallAppButton() {
const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null);
const [installed, setInstalled] = useState(() => isStandalone());
export function InstallAppButton({ businessName }: { businessName: string }) {
const { installed, ios, canPromptNatively, promptInstall } = useInstallPrompt();
const [showIosInstructions, setShowIosInstructions] = useState(false);
useEffect(() => {
const onBeforeInstallPrompt = (event: Event) => {
event.preventDefault();
setDeferredPrompt(event as BeforeInstallPromptEvent);
};
const onInstalled = () => {
setInstalled(true);
setDeferredPrompt(null);
};
window.addEventListener("beforeinstallprompt", onBeforeInstallPrompt);
window.addEventListener("appinstalled", onInstalled);
return () => {
window.removeEventListener("beforeinstallprompt", onBeforeInstallPrompt);
window.removeEventListener("appinstalled", onInstalled);
};
}, []);
if (installed) {
return <p className="text-muted text-sm">NextChair is installed on this device.</p>;
return <p className="text-muted text-sm">{businessName} is installed on this device.</p>;
}
if (deferredPrompt) {
if (canPromptNatively) {
return (
<Button
size="sm"
onClick={async () => {
await deferredPrompt.prompt();
await deferredPrompt.userChoice;
setDeferredPrompt(null);
}}
>
<Button size="sm" onClick={promptInstall}>
<Download className="h-4 w-4" aria-hidden="true" /> Install App
</Button>
);
}
if (isIos()) {
if (ios) {
return showIosInstructions ? (
<p className="text-muted text-sm">
Tap the Share icon in Safari, then choose &ldquo;Add to Home Screen&rdquo;.
@@ -0,0 +1,75 @@
"use client";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { useInstallPrompt } from "../hooks/use-install-prompt";
const DISMISSED_UNTIL_KEY = "nextchair:install-card-dismissed-until";
/** Re-prompt interval after "Not Now" — see the brief's "reasonable re-prompt interval such as 14 or 30 days." 30 days chosen: this card is one of several places to install (Settings always has it too), so a longer, less-naggy interval is appropriate here specifically. */
const DISMISS_DAYS = 30;
function isDismissed(): boolean {
if (typeof window === "undefined") return true;
const raw = window.localStorage.getItem(DISMISSED_UNTIL_KEY);
if (!raw) return false;
const until = Number(raw);
return Number.isFinite(until) && Date.now() < until;
}
/**
* The prominent, inline Welcome-page counterpart to the Settings-page
* `InstallAppButton` — same underlying detection (see
* use-install-prompt.ts), never a second installer. Renders nothing when
* already installed, on a non-iOS/non-installable browser, or recently
* dismissed — it never blocks Sign In / Create Account / Continue as
* Guest / Browse Services above it, and never opens as an unsolicited
* modal (see the brief's explicit "do not automatically open a modal").
*/
export function WelcomeInstallCard({ businessName }: { businessName: string }) {
const { installed, ios, canPromptNatively, promptInstall } = useInstallPrompt();
const [dismissed, setDismissed] = useState(true);
const [showIosSteps, setShowIosSteps] = useState(false);
useEffect(() => {
void Promise.resolve().then(() => setDismissed(isDismissed()));
}, []);
const dismiss = () => {
window.localStorage.setItem(DISMISSED_UNTIL_KEY, String(Date.now() + DISMISS_DAYS * 24 * 60 * 60 * 1000));
setDismissed(true);
};
if (installed || dismissed || (!canPromptNatively && !ios)) return null;
return (
<div className="rounded-jumbo mb-6 border border-white/10 bg-white/5 p-5 text-white">
<p className="text-sm font-bold">Add {businessName} to your Home Screen</p>
<p className="mt-1 text-sm text-white/70">Book appointments, manage your visits and receive reminders more easily.</p>
{ios && showIosSteps ? (
<div className="mt-4 space-y-2 text-sm text-white/80">
<p className="font-bold text-white">Install {businessName}</p>
<ol className="list-inside list-decimal space-y-1">
<li>Tap the Share button in Safari.</li>
<li>Scroll down and choose &ldquo;Add to Home Screen&rdquo;.</li>
<li>Tap Add.</li>
</ol>
</div>
) : (
<div className="mt-4 flex gap-3">
<Button
size="sm"
variant="secondary"
className="bg-white"
onClick={ios ? () => setShowIosSteps(true) : promptInstall}
>
Add to Home Screen
</Button>
<Button size="sm" variant="ghost" className="text-white/70 hover:bg-white/10 hover:text-white" onClick={dismiss}>
Not Now
</Button>
</div>
)}
</div>
);
}
@@ -0,0 +1,73 @@
"use client";
import { useEffect, useState } from "react";
export interface BeforeInstallPromptEvent extends Event {
prompt: () => Promise<void>;
userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
}
function isStandalone(): boolean {
if (typeof window === "undefined") return false;
return (
window.matchMedia("(display-mode: standalone)").matches ||
// iOS Safari's own (non-standard, but the only signal it exposes).
(window.navigator as Navigator & { standalone?: boolean }).standalone === true
);
}
function isIos(): boolean {
if (typeof navigator === "undefined") return false;
return /iphone|ipad|ipod/i.test(navigator.userAgent);
}
/**
* Shared `beforeinstallprompt` capture + standalone/iOS detection — the
* one PWA install implementation both `InstallAppButton` (Settings, every
* role) and `WelcomeInstallCard` (the unauthenticated Welcome page) use.
* See the brief's explicit "do not create a competing second installer".
* All detection lives in `useEffect`, never a `useState` lazy
* initializer — reading `window`/`navigator` during the initializer would
* run during SSR too and mismatch the client's first render (the same
* hydration bug this app's push-subscribe-toggle.tsx fixed in Phase 7).
*/
export function useInstallPrompt() {
const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null);
const [installed, setInstalled] = useState(false);
const [ios, setIos] = useState(false);
useEffect(() => {
// Async chain, not a direct setState call in the effect body — same
// pattern push-subscribe-toggle.tsx uses (see that file's own
// comment) to satisfy react-hooks/set-state-in-effect while still
// reading browser-only APIs that can't run during SSR.
void Promise.resolve().then(() => {
setInstalled(isStandalone());
setIos(isIos());
});
const onBeforeInstallPrompt = (event: Event) => {
event.preventDefault();
setDeferredPrompt(event as BeforeInstallPromptEvent);
};
const onInstalled = () => {
setInstalled(true);
setDeferredPrompt(null);
};
window.addEventListener("beforeinstallprompt", onBeforeInstallPrompt);
window.addEventListener("appinstalled", onInstalled);
return () => {
window.removeEventListener("beforeinstallprompt", onBeforeInstallPrompt);
window.removeEventListener("appinstalled", onInstalled);
};
}, []);
const promptInstall = async (): Promise<void> => {
if (!deferredPrompt) return;
await deferredPrompt.prompt();
await deferredPrompt.userChoice;
setDeferredPrompt(null);
};
return { installed, ios, canPromptNatively: !!deferredPrompt, promptInstall };
}
@@ -13,6 +13,7 @@ import {
} from "@/services/scheduling/appointment.service";
import { awardStampForCompletedAppointment } from "@/services/loyalty/loyalty.service";
import { notifyAppointmentNoShow, notifyBookingApproved, notifyBookingCompleted } from "@/services/scheduling/booking-notifications";
import { syncAppointmentSafely } from "@/services/calendar/calendar-sync.service";
import { assertOwnAppointment, StaffPermissionDeniedError } from "../authorization";
/**
@@ -63,6 +64,9 @@ export async function confirmAppointmentAction(
return { success: false, error: friendlyTransitionMessage(result.validation.issues[0]?.code) };
}
await notifyBookingApproved(prisma, result.appointment);
// Approval is the first point a PENDING appointment becomes syncable —
// see calendar-sync.service.ts's own policy comment.
await syncAppointmentSafely(prisma, appointmentId);
return { success: true };
}
@@ -123,6 +127,7 @@ export async function markNoShowAction(
return { success: false, error: friendlyTransitionMessage(result.validation.issues[0]?.code) };
}
await notifyAppointmentNoShow(prisma, result.appointment);
await syncAppointmentSafely(prisma, appointmentId);
return { success: true };
}
@@ -138,12 +143,11 @@ export async function cancelStaffAppointmentAction(params: {
cancelledByUserId: staff.user.id,
reason: params.reason,
});
return result.success
? { success: true }
: {
success: false,
error: result.validation.issues[0]?.message ?? "Couldn't cancel this appointment.",
};
if (!result.success) {
return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't cancel this appointment." };
}
await syncAppointmentSafely(prisma, params.appointmentId);
return { success: true };
}
export async function rescheduleStaffAppointmentAction(params: {
@@ -159,12 +163,15 @@ export async function rescheduleStaffAppointmentAction(params: {
staffId: params.staffId,
startAt: new Date(params.startAt),
});
return result.success
? { success: true }
: {
success: false,
error: result.validation.issues[0]?.message ?? "Couldn't reschedule this appointment.",
};
if (!result.success) {
return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't reschedule this appointment." };
}
// Covers both a plain reschedule and a barber reassignment (staffId
// change) — syncAppointmentSafely itself detects and cancels a stale
// event under the previous staff member's connection when the staffId
// changed (see that function's own comment).
await syncAppointmentSafely(prisma, params.appointmentId);
return { success: true };
}
function friendlyTransitionMessage(code?: string): string {