import type { PrismaClient, Prisma } from "@/generated/prisma/client"; import { InstallationStatus, RewardType } from "@/generated/prisma/enums"; import { finalizeOtp } from "@/lib/auth/otp.service"; 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" | "EMAIL_NOT_VERIFIED" | "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; /** Thrown inside the transaction to carry a specific InstallErrorCode out through the generic try/catch below — see installApplication's own use. */ class InstallStepError extends Error { constructor( readonly code: InstallErrorCode, message: string, ) { super(message); } } 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 { 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 { 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 administratorEmail = administrator.email.toLowerCase(); // The administrator's email must have been verified via OTP in the // wizard's own Administrator step (see install-wizard.tsx and // src/lib/auth/otp.service.ts's verifiedAt/consumedAt design) — // checked and consumed here, inside this same transaction, so it // rolls back together with everything else if a later step fails // (letting an immediate retry reuse the same verification) and so // installation genuinely cannot complete with an unverified email, // not just "the client claimed it was verified". if (!(await finalizeOtp(tx, administratorEmail))) { throw new InstallStepError( "EMAIL_NOT_VERIFIED", "This email hasn't been verified — please verify it again before finishing setup.", ); } 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 user = await tx.user.create({ data: { email: administratorEmail, name: `${administrator.firstName.trim()} ${administrator.lastName.trim()}`, phone: administrator.phone?.trim() || null, // Email OTP is the only auth method now — see finalizeOtp above, // which is what actually proved this email, not a password. 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(); 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, or the administrator's // email wasn't verified — see the header comment above and // InstallStepError. 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). if (error instanceof InstallStepError) { return { success: false, code: error.code, message: error.message }; } 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.", }; } }