CI / ci (push) Canceled after 0s
1. Summary of changes Password auth → email OTP, everywhere it existed for the tenant/business app: sign-in, customer self-registration (/register), staff-invitation acceptance, and the installer's administrator step. Flow is always: email → 6-digit code emailed → code entered → session created, via one shared service (otp.service.ts) reused by every entry point. NextChair Manager's own auth was not touched — this only affects tenant-side (business admin/staff/customer) authentication, per the spec's explicit instruction. Installer improvements: Step 2 (Administrator) now asks only Name + Email + optional Phone, verified inline via the same OTP widget (no redirect). Step 3 (Business Details) pre-fills Business Name from the Manager-provisioned BUSINESS_NAME, shows the booking URL (NEXT_PUBLIC_APP_URL) as a read-only info box instead of asking for it, and has a distinctly-labeled "Business Website (optional)" field with copy clarifying it's not the booking URL. Installation cannot complete with an unverified admin email — enforced server-side (finalizeOtp runs inside the same DB transaction as user/business creation), not just trusted from client state. A mid-wizard refresh still can't create duplicate admins or partial records — that property was preserved by design, not incidentally. 2. Prisma/database changes New EmailOtp model (email as PK — one row per address, so a new request naturally invalidates the previous code): codeHash, purpose, expiresAt, attempts, verifiedAt, consumedAt, timestamps. Only a bcrypt hash of the code is ever stored. User.passwordHash — unchanged schema (already nullable), just annotated as deprecated. No destructive migration; historical rows are untouched. Migration: prisma/migrations/20260905190022_email_otp_authentication — adds EmailOtp only. Generated and applied against throwaway containers only; the real next-chair-mysql production database was never touched (confirmed not even running on this host during this session). 3. New environment variables None required for production. One new variable, E2E_TEST_MODE, exists purely for the Playwright suite (set only in playwright.config.ts's webServer.env) — it unlocks a /api/test/otp-code debug endpoint and bypasses OTP rate limiting so the same seeded users can sign in dozens of times across one test run. It must never be set on a real deployment; nothing in the app sets it anywhere else. 4. Email delivery configuration Nothing new to configure. OTP emails reuse the existing EmailProvider abstraction and EMAIL_PROVIDER env var exactly as configured today — console in dev (prints to server logs), brevo in production via the same SMTP credentials already documented. The OTP email is a new template (otp-email.ts) sent directly (bypassing the async notification/cron pipeline, since a 10-minute-expiry code can't wait for a delivery retry loop). 5. Migration considerations for existing users/installations Existing users lose password sign-in — the only way to authenticate now is email OTP. There is no in-app fallback; this is an intentional, sharp cutover. If existing customers/staff/admins need advance notice, that's a business/communications decision outside this change. No account data is deleted or migrated — passwordHash rows are simply never read for auth anymore. prisma/seed.ts / scripts/ensure-super-admin.ts still set a passwordHash for seeded dev users — harmless and vestigial now, not cleaned up (flagging it rather than touching seed data silently). Installations already completed are unaffected; only new installer runs go through the OTP admin-verification step. 6. Files changed New: src/lib/auth/otp.service.ts — the core requestOtp/verifyOtp/finalizeOtp service src/lib/auth/__tests__/otp.service.integration.test.ts src/services/notifications/templates/otp-email.ts src/features/auth/components/otp-code-step.tsx — shared code-entry UI src/features/auth/actions/request-sign-in-otp.ts src/app/api/test/otp-code/route.ts — e2e-only, gated on E2E_TEST_MODE prisma/migrations/20260905190022_email_otp_authentication/ Modified — auth core: prisma/schema.prisma, src/auth.ts, src/features/auth/schemas.ts Modified — sign-in/register/invitations: sign-in-form.tsx, register.ts, register-form.tsx, accept-invitation.ts, accept-invitation-form.tsx, staff-invitation.service.ts Modified — installer: installer.service.ts, schema.ts, install.ts, install-wizard.tsx, install/page.tsx, installer.service.integration.test.ts Modified — email / e2e infra: console-email-provider.ts, playwright.config.ts, e2e/helpers.ts, and the four e2e/*.spec.ts files (dropped the now-unused password argument from every signIn() call). 7. Verification performed npx tsc --noEmit, npm run lint, npm test (276 unit) — all clean. npm run test:integration (207 tests, throwaway DB) — includes 11 new OTP-service tests and a rewritten installer suite; a genuine race condition in requestOtp (concurrent requests for a brand-new email double-hitting the upsert's create branch) was caught and fixed here. Full npm run test:e2e against a fresh throwaway database: 36/37 passed. The one failure (owner-journeys "opening hours conflict" test) is a pre-existing, auth-unrelated ordering flake — appointment-count ambiguity from earlier tests' bookings — not something this change touches or introduced. Live manual verification in-browser: existing seeded customer sign-in via OTP; new customer registration via OTP (session created, landed on Home); installer wizard end-to-end through Step 3, confirming email-verification gating, Business Name pre-population from BUSINESS_NAME, and the read-only Booking URL / distinct Business Website fields. All throwaway Docker containers/DBs used for this work have been removed; the real production database was never touched.
350 lines
14 KiB
TypeScript
350 lines
14 KiB
TypeScript
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<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 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<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, 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.",
|
|
};
|
|
}
|
|
}
|