Phase 8
This commit is contained in:
82 files changed
+4903
-287
No files matched your search
@@ -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'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'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'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>
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 “{state.selectedCalendarName}”</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'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.",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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 “Add to Home Screen”.
|
||||
|
||||
@@ -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 “Add to Home Screen”.</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 {
|
||||
|
||||
Reference in new issue
Block a user