Seed data extended with a second barber (different hours/services), a beard-trim service + category, breaks, time off, a guest customer, and appointments in all three key states — verified against a live throwaway Postgres.
14 new integration tests (38 total with Phase 3's) covering guest/authenticated booking, any-barber assignment, slot-conflict handling, ownership enforcement, guest-data isolation, and book-again repricing.
Playwright E2E suite for the 6 required customer journeys, with its own global-setup.ts that migrates and seeds a database automatically.
Full verification pass, which caught and fixed three real bugs along the way (not just test-workarounds):
A date off-by-one bug in the calendar picker that made real weekdays show as closed for anyone in a UTC+ timezone (including the business's own).
A dialog that could vanish mid-confirmation on cancellation, before the customer could read the late-cancellation notice.
A silent test-cleanup leak in the Phase 3 fixtures that had been leaking stray businesses since Phase 3.
Plus a baseline desktop-responsive fix (centered max-w-2xl columns) across Home/Appointments/Settings/booking flow, since desktop had been effectively unstyled.
Final state: 90 unit tests, 38 integration tests, and all 6 E2E journeys passing together on a fresh database; clean tsc, eslint, and next build. Full writeup with routes, security model, guest-matching rules, known limitations, and what to check before Phase 5 is in docs/phase-4-summary.md.
This commit is contained in:
Stefan Lazic committed 2026-08-02 21:39:24 +01:00
1 parent 8b63cc66a9
commit 6ae145d187
66 files changed
+4599 -107

No files matched your search

+120
View File
@@ -0,0 +1,120 @@
import type { PrismaClient } from "@/generated/prisma/client";
import type { GuestDetails } from "@/lib/booking-draft/schema";
/**
* New in Phase 4 — anticipated by the Phase 3 brief's list of future
* domain services ("LoyaltyService, NotificationService, CustomerService,
* StaffService..."). Owns exactly one thing: resolving *which*
* CustomerProfile a booking belongs to. Nothing about availability,
* scheduling, or booking rules — those stay in the Phase 3 services this
* one is called alongside, never duplicated.
*
* ── Guest matching rules (documented per the Phase 4 brief) ──
*
* 1. Authenticated caller → always their own CustomerProfile (via
* User.customerProfile), created on first booking if it doesn't exist
* yet. No email/phone matching happens at all for an authenticated
* caller — their identity is already established by the session, so
* there is no ambiguity to resolve.
*
* 2. Guest caller (no session) → search for an existing REUSABLE guest
* profile: `isGuest = true` AND `userId IS NULL` AND email matches
* (case-insensitive) AND phone matches exactly. Both identifiers must
* match — a single weak identifier (email alone, phone alone) is
* never sufficient, so two different guests who happen to share an
* email (a shared household inbox) or a phone number never collide
* into the same profile from a partial match.
*
* 3. A guest is NEVER matched against a profile that has `userId` set
* (i.e. a registered customer's profile) — that would attach a guest
* booking to a real account without any verification, which the brief
* explicitly forbids. Registered-customer profiles are simply outside
* the guest search space.
*
* 4. No match found → create a new guest CustomerProfile
* (`isGuest: true`, `userId: null`). Existing-profile fields are never
* overwritten on reuse (case 2) — if a returning guest typoed their
* name differently this time, that's a data-quality nuance for a
* later phase, not something this function silently "fixes" by
* guessing which version is correct.
*
* Full account claiming (a guest later creating an account and having
* their past guest bookings attached to it) is explicitly out of scope
* for this phase — see docs/phase-4-summary.md.
*/
export interface ResolveCustomerParams {
businessId: string;
/** Present for an authenticated caller. */
userId?: string;
/** Present for a guest caller. Ignored if `userId` is provided. */
guest?: GuestDetails;
}
export interface ResolvedCustomer {
customerId: string;
isNewProfile: boolean;
}
export async function resolveCustomerForBooking(
prisma: PrismaClient,
params: ResolveCustomerParams,
): Promise<ResolvedCustomer> {
if (params.userId) {
return resolveAuthenticatedCustomer(prisma, params.userId);
}
if (params.guest) {
return resolveGuestCustomer(prisma, params.guest);
}
throw new Error("resolveCustomerForBooking: either userId or guest details are required.");
}
async function resolveAuthenticatedCustomer(prisma: PrismaClient, userId: string): Promise<ResolvedCustomer> {
const existing = await prisma.customerProfile.findUnique({ where: { userId } });
if (existing) return { customerId: existing.id, isNewProfile: false };
const user = await prisma.user.findUniqueOrThrow({ where: { id: userId } });
const [firstName, ...rest] = (user.name ?? "").trim().split(/\s+/).filter(Boolean);
const created = await prisma.customerProfile.create({
data: {
userId,
firstName: firstName || "Customer",
lastName: rest.join(" ") || "",
email: user.email ?? "",
phone: user.phone ?? undefined,
isGuest: false,
},
});
return { customerId: created.id, isNewProfile: true };
}
async function resolveGuestCustomer(prisma: PrismaClient, guest: GuestDetails): Promise<ResolvedCustomer> {
// Case-insensitive email matching without Prisma's `mode: "insensitive"`
// filter — that option is Postgres/MongoDB-only, not MySQL, which would
// break this query silently (or fail) after a provider swap. Normalizing
// case in application code instead keeps the comparison a plain equality
// check, portable across both.
const normalizedEmail = guest.email.trim().toLowerCase();
const existing = await prisma.customerProfile.findFirst({
where: {
userId: null,
isGuest: true,
email: normalizedEmail,
phone: guest.phone,
},
});
if (existing) return { customerId: existing.id, isNewProfile: false };
const created = await prisma.customerProfile.create({
data: {
firstName: guest.firstName,
lastName: guest.lastName,
email: normalizedEmail,
phone: guest.phone,
isGuest: true,
},
});
return { customerId: created.id, isNewProfile: true };
}
@@ -0,0 +1,44 @@
import { afterEach, describe, expect, it } from "vitest";
import { createDemoReviewsProvider, getBusinessReviews, setReviewsProvider } from "../reviews.service";
import type { BusinessReviewSummary, ReviewsProvider } from "../reviews.service";
describe("reviews.service", () => {
afterEach(() => {
setReviewsProvider(createDemoReviewsProvider());
});
it("returns demo data labelled as such by default", async () => {
const result = await getBusinessReviews("any-business-id");
expect(result.isDemoData).toBe(true);
expect(result.reviews.length).toBeGreaterThan(0);
expect(result.averageRating).toBeGreaterThanOrEqual(1);
expect(result.averageRating).toBeLessThanOrEqual(5);
expect(result.totalReviews).toBeGreaterThan(0);
});
it("every demo review has a rating within 1–5", async () => {
const result = await getBusinessReviews("any-business-id");
for (const review of result.reviews) {
expect(review.rating).toBeGreaterThanOrEqual(1);
expect(review.rating).toBeLessThanOrEqual(5);
}
});
it("can be swapped for a different provider without callers changing", async () => {
const fakeSummary: BusinessReviewSummary = {
averageRating: 4.2,
totalReviews: 7,
reviews: [],
isDemoData: false,
};
const fakeProvider: ReviewsProvider = {
async getBusinessReviews() {
return fakeSummary;
},
};
setReviewsProvider(fakeProvider);
const result = await getBusinessReviews("any-business-id");
expect(result).toEqual(fakeSummary);
});
});
+76
View File
@@ -0,0 +1,76 @@
/**
* Reviews behind a provider abstraction, per the Phase 4 brief — a real
* Google Places/Business Profile integration can implement this same
* interface later without any caller (Customer Home, etc.) changing.
* Backed by clearly-labelled demo data for now; no schema/DB involvement,
* per the Phase 1 plan's recommendation to treat reviews as an external,
* cached read rather than a first-party entity.
*/
export interface ReviewItem {
id: string;
authorName: string;
rating: number; // 1–5
text: string;
relativeDate: string; // pre-formatted, e.g. "2 weeks ago" — a real provider would supply this directly
}
export interface BusinessReviewSummary {
averageRating: number;
totalReviews: number;
reviews: ReviewItem[];
/** True for the demo provider — callers should visibly label the data as such (see Customer Home). */
isDemoData: boolean;
}
export interface ReviewsProvider {
getBusinessReviews(businessId: string): Promise<BusinessReviewSummary>;
}
const DEMO_REVIEWS: ReviewItem[] = [
{
id: "demo-1",
authorName: "Daniel R.",
rating: 5,
text: "Best skin fade I've had in years. Booked online in under a minute.",
relativeDate: "2 weeks ago",
},
{
id: "demo-2",
authorName: "Priya K.",
rating: 5,
text: "Great atmosphere and the loyalty card is a nice touch.",
relativeDate: "1 month ago",
},
{
id: "demo-3",
authorName: "Marcus T.",
rating: 4,
text: "Solid haircut, a little wait past my appointment time.",
relativeDate: "1 month ago",
},
];
class DemoReviewsProvider implements ReviewsProvider {
async getBusinessReviews(_businessId: string): Promise<BusinessReviewSummary> {
const totalReviews = 1142; // matches the figure shown in templates/15
const averageRating = 5.0;
return { averageRating, totalReviews, reviews: DEMO_REVIEWS, isDemoData: true };
}
}
/** Exposed so tests can restore the default provider after swapping it — see reviews.service.test.ts. */
export function createDemoReviewsProvider(): ReviewsProvider {
return new DemoReviewsProvider();
}
let provider: ReviewsProvider = new DemoReviewsProvider();
/** Swap the provider (e.g. in a future Google-backed implementation, or a test double) without touching any caller. */
export function setReviewsProvider(next: ReviewsProvider): void {
provider = next;
}
export async function getBusinessReviews(businessId: string): Promise<BusinessReviewSummary> {
return provider.getBusinessReviews(businessId);
}
@@ -156,7 +156,26 @@ export async function createTestCustomer(business: TestBusiness): Promise<{ cust
return { customerId: user.customerProfile!.id, userId: user.id };
}
/** A guest CustomerProfile — no linked User, matching the shape CustomerService's guest-matching creates. */
export async function createTestGuestCustomer(
overrides: { email?: string; phone?: string } = {},
): Promise<{ customerId: string; email: string; phone: string }> {
const email = overrides.email ?? `${uniqueId("guest")}@test.local`;
const phone = overrides.phone ?? `+44 7700 ${Math.floor(100000 + Math.random() * 900000)}`;
const created = await prisma.customerProfile.create({
data: { firstName: "Guest", lastName: "Customer", email, phone, isGuest: true },
});
return { customerId: created.id, email, phone };
}
export async function cleanupTestBusiness(business: TestBusiness): Promise<void> {
// Appointment.staff is onDelete: Restrict (see prisma/schema.prisma) — a
// deliberate production safeguard against deleting a staff member with
// appointment history, but it also blocks Business's own cascade delete
// to StaffProfile whenever a test actually booked something. Clearing
// appointments first lets that cascade complete instead of the business
// delete silently failing and leaking a "Test Barbershop" row per run.
await prisma.appointment.deleteMany({ where: { businessId: business.businessId } }).catch(() => {});
await prisma.business.delete({ where: { id: business.businessId } }).catch(() => {});
if (business.createdUserIds.length > 0) {
await prisma.user.deleteMany({ where: { id: { in: business.createdUserIds } } }).catch(() => {});
+10 -1
View File
@@ -186,7 +186,16 @@ export async function rescheduleBooking(prisma: PrismaClient, input: RescheduleB
export interface CancelBookingInput {
appointmentId: string;
cancelledByUserId: string;
/**
* Omit for a guest customer cancelling their own booking — a guest
* CustomerProfile has no linked User (see prisma/schema.prisma), so
* there's nothing to attribute the cancellation to beyond the
* appointment record itself. Widened from required to optional in
* Phase 4 (a genuine missing capability found while building guest
* self-service cancellation, not a redesign) — every existing caller
* that already passes a userId is unaffected.
*/
cancelledByUserId?: string;
reason?: string;
}