Phase 4
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:
66 files changed
+4599
-107
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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(() => {});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user