Revert "PHP Rebuild"

This reverts commit 2ef3654841.
This commit is contained in:
Stefan Lazic committed 2026-09-05 09:32:05 +01:00
1 parent a59f998f2b
commit 2aa8405006
690 files changed
+12045 -33943

No files matched your search

@@ -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.",
};
}
}