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

@@ -0,0 +1,44 @@
import { beforeEach, describe, expect, it } from "vitest";
import { sign, verify } from "../sign";
describe("sign / verify", () => {
beforeEach(() => {
process.env.AUTH_SECRET = "test-secret-not-for-production-use-only";
});
it("round-trips a payload", () => {
const token = sign('{"serviceIds":["svc_1"]}');
expect(verify(token)).toBe('{"serviceIds":["svc_1"]}');
});
it("rejects a tampered payload", () => {
const token = sign('{"serviceIds":["svc_1"]}');
const [, mac] = token.split(".");
const tamperedPayload = Buffer.from('{"serviceIds":["svc_2"]}', "utf8").toString("base64url");
expect(verify(`${tamperedPayload}.${mac}`)).toBeNull();
});
it("rejects a tampered signature", () => {
const token = sign('{"a":1}');
const [payload] = token.split(".");
expect(verify(`${payload}.not-a-real-signature`)).toBeNull();
});
it("rejects malformed tokens without throwing", () => {
expect(verify("")).toBeNull();
expect(verify("no-separator")).toBeNull();
expect(verify("...")).toBeNull();
expect(verify("!!!not-base64!!!.mac")).toBeNull();
});
it("rejects a token signed with a different secret", () => {
const token = sign('{"a":1}');
process.env.AUTH_SECRET = "a-completely-different-secret-value";
expect(verify(token)).toBeNull();
});
it("throws clearly if AUTH_SECRET is missing rather than signing with an empty key", () => {
delete process.env.AUTH_SECRET;
expect(() => sign("payload")).toThrow(/AUTH_SECRET/);
});
});
+82
View File
@@ -0,0 +1,82 @@
import "server-only";
import { cookies } from "next/headers";
import { bookingDraftSchema, emptyDraft } from "./schema";
import type { BookingDraft } from "./schema";
import { sign, verify } from "./sign";
/**
* Server-only I/O around the signed booking-draft cookie. Callable from
* Server Components (read) and Server Actions/Route Handlers (read +
* write) — `next/headers`'s `cookies()` enforces that boundary itself.
*/
const COOKIE_NAME = "nc_booking_draft";
const MAX_AGE_SECONDS = 60 * 60; // 1 hour — long enough to finish a booking, short enough not to linger
export async function readDraft(businessId: string): Promise<BookingDraft> {
const store = await cookies();
const raw = store.get(COOKIE_NAME)?.value;
if (!raw) return emptyDraft(businessId);
const payload = verify(raw);
if (!payload) return emptyDraft(businessId);
try {
const parsed = bookingDraftSchema.safeParse(JSON.parse(payload));
if (!parsed.success || parsed.data.businessId !== businessId) return emptyDraft(businessId);
return parsed.data;
} catch {
return emptyDraft(businessId);
}
}
export async function writeDraft(draft: BookingDraft): Promise<void> {
const store = await cookies();
const token = sign(JSON.stringify(draft));
store.set(COOKIE_NAME, token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: MAX_AGE_SECONDS,
});
}
export async function clearDraft(): Promise<void> {
const store = await cookies();
store.delete(COOKIE_NAME);
}
// ── Post-booking confirmation reference ─────────────────────────────────
// Deliberately a separate, tiny cookie rather than a URL query param, so
// a bookmarked/shared confirmation URL never carries even a non-sensitive
// appointment id — see docs/phase-4-summary.md.
const CONFIRMATION_COOKIE_NAME = "nc_last_booking";
const CONFIRMATION_MAX_AGE_SECONDS = 60 * 30;
export async function writeLastBookingReference(appointmentId: string): Promise<void> {
const store = await cookies();
const token = sign(JSON.stringify({ appointmentId }));
store.set(CONFIRMATION_COOKIE_NAME, token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: CONFIRMATION_MAX_AGE_SECONDS,
});
}
export async function readLastBookingReference(): Promise<string | null> {
const store = await cookies();
const raw = store.get(CONFIRMATION_COOKIE_NAME)?.value;
if (!raw) return null;
const payload = verify(raw);
if (!payload) return null;
try {
const parsed = JSON.parse(payload) as { appointmentId?: unknown };
return typeof parsed.appointmentId === "string" ? parsed.appointmentId : null;
} catch {
return null;
}
}
+38
View File
@@ -0,0 +1,38 @@
import { z } from "zod";
/**
* The booking-in-progress draft. Lives in a signed, httpOnly cookie (see
* cookie.ts) rather than only in React state, so it survives a page
* refresh, browser back/forward, and a sign-in that happens mid-flow —
* see docs/phase-4-summary.md for the full reasoning.
*/
export const guestDetailsSchema = z.object({
firstName: z.string().trim().min(1, "First name is required"),
lastName: z.string().trim().min(1, "Last name is required"),
email: z.email("Enter a valid email address"),
phone: z.string().trim().min(1, "Phone number is required"),
});
export type GuestDetails = z.infer<typeof guestDetailsSchema>;
export const bookingDraftSchema = z.object({
businessId: z.string(),
serviceIds: z.array(z.string()).default([]),
/** undefined = "any available barber". */
staffId: z.string().optional(),
date: z.string().optional(), // CalendarDate, "YYYY-MM-DD"
startAt: z.iso.datetime().optional(),
notes: z.string().max(2000).optional(),
guest: guestDetailsSchema.optional(),
policyAcknowledged: z.boolean().default(false),
/** Set by "Book again" so the services step can show which appointment this draft originated from — display only, never trusted for pricing (see BookingService, which always re-reads current Service prices). */
rebookFromAppointmentId: z.string().optional(),
});
export type BookingDraft = z.infer<typeof bookingDraftSchema>;
export function emptyDraft(businessId: string): BookingDraft {
return {
businessId,
serviceIds: [],
policyAcknowledged: false,
};
}
+62
View File
@@ -0,0 +1,62 @@
import { createHmac, timingSafeEqual } from "node:crypto";
/**
* Tamper-evident (not encrypted) signing for the booking-draft cookie —
* an HMAC-SHA256 over the JSON payload using Node's built-in `crypto`,
* not a hand-rolled cipher. Signing rather than encrypting is a
* deliberate choice: the draft holds data the customer themselves just
* typed into a form on this domain (their own name/email/phone, plus
* non-sensitive selections), so the realistic threat is tampering
* (editing the cookie to book at a different price or under a different
* identity), not disclosure to the customer's own browser. httpOnly +
* Secure + SameSite=Lax (set in cookie.ts) cover disclosure to anyone
* else. Kept as a separate module from cookie.ts so the crypto itself is
* unit-testable without Next's `cookies()` API.
*/
const CONTEXT = "nextchair-booking-draft-v1";
function getSigningKey(): Buffer {
const secret = process.env.AUTH_SECRET;
if (!secret) {
throw new Error("AUTH_SECRET must be set to sign the booking draft cookie.");
}
// Derive a purpose-specific key from the shared app secret rather than
// reusing it directly, so this cookie's signing key and NextAuth's JWT
// key are cryptographically independent even though they start from
// the same secret.
return createHmac("sha256", secret).update(CONTEXT).digest();
}
/** payload.signature, both base64url — a compact, cookie-safe format. */
export function sign(payload: string): string {
const mac = createHmac("sha256", getSigningKey()).update(payload).digest("base64url");
const encodedPayload = Buffer.from(payload, "utf8").toString("base64url");
return `${encodedPayload}.${mac}`;
}
/** Returns the original payload if the signature is valid, otherwise null. Never throws on malformed input. */
export function verify(token: string): string | null {
const separatorIndex = token.indexOf(".");
if (separatorIndex === -1) return null;
const encodedPayload = token.slice(0, separatorIndex);
const mac = token.slice(separatorIndex + 1);
if (!encodedPayload || !mac) return null;
let payload: string;
try {
payload = Buffer.from(encodedPayload, "base64url").toString("utf8");
} catch {
return null;
}
const expectedMac = createHmac("sha256", getSigningKey()).update(payload).digest("base64url");
const macBuffer = Buffer.from(mac);
const expectedBuffer = Buffer.from(expectedMac);
if (macBuffer.length !== expectedBuffer.length) return null;
if (!timingSafeEqual(macBuffer, expectedBuffer)) return null;
return payload;
}
+21
View File
@@ -0,0 +1,21 @@
import { cache } from "react";
import { prisma } from "@/lib/db/prisma";
/**
* v1 is single-business (see docs/implementation-plan.md §11) — every
* page that needs "the business" fetches it through here rather than
* hardcoding a slug or re-querying independently. Wrapped in React's
* `cache()` so multiple Server Components in the same request (a layout
* and its page, for instance) share one query instead of issuing it
* once each.
*/
export const getPrimaryBusiness = cache(async () => {
return prisma.business.findFirstOrThrow({
include: {
locations: true,
hours: true,
settings: true,
bookingRule: true,
},
});
});
+45
View File
@@ -0,0 +1,45 @@
import { minutesFromMidnight, todayIn, weekdayOf } from "@/lib/time/business-time";
import { Weekday } from "@/generated/prisma/enums";
export interface OpeningStatus {
isOpen: boolean;
label: string;
}
interface DayHoursRow {
dayOfWeek: Weekday;
isClosed: boolean;
opensAtMinutes: number | null;
closesAtMinutes: number | null;
}
function formatMinutes(minutes: number): string {
const h = Math.floor(minutes / 60);
const m = minutes % 60;
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
}
/**
* Purely presentational (read-only "open now?" label for the home page)
* — not part of the booking engine's own open/closed check, which
* AvailabilityService already performs independently as a real booking
* rule. This is display-only, so it lives here rather than in
* services/scheduling.
*/
export function computeOpeningStatus(hours: DayHoursRow[], timezone: string): OpeningStatus {
const today = todayIn(timezone);
const weekday = weekdayOf(today);
const todayHours = hours.find((h) => h.dayOfWeek === weekday);
if (!todayHours || todayHours.isClosed || todayHours.opensAtMinutes === null || todayHours.closesAtMinutes === null) {
return { isOpen: false, label: "Closed today" };
}
const nowMinutes = minutesFromMidnight(new Date(), timezone);
const isOpen = nowMinutes >= todayHours.opensAtMinutes && nowMinutes < todayHours.closesAtMinutes;
return {
isOpen,
label: isOpen ? `Open until ${formatMinutes(todayHours.closesAtMinutes)}` : `Closed · Opens ${formatMinutes(todayHours.opensAtMinutes)}`,
};
}
+94
View File
@@ -0,0 +1,94 @@
import { describe, expect, it } from "vitest";
import { generateAppointmentIcs } from "../ics";
const BASE_INPUT = {
appointmentId: "appt_123",
businessName: "Blake Heritage Barbershop",
businessAddress: "79 Joel Street, HA6 1LW",
staffName: "Marcus Reed",
serviceNames: ["Haircut & Style"],
startAt: new Date("2026-08-10T09:00:00.000Z"),
endAt: new Date("2026-08-10T09:30:00.000Z"),
};
describe("generateAppointmentIcs", () => {
it("produces a well-formed VCALENDAR/VEVENT block", () => {
const ics = generateAppointmentIcs(BASE_INPUT);
expect(ics).toMatch(/^BEGIN:VCALENDAR\r\n/);
expect(ics).toContain("BEGIN:VEVENT\r\n");
expect(ics).toContain("END:VEVENT\r\n");
expect(ics.trimEnd()).toMatch(/END:VCALENDAR$/);
});
it("formats DTSTART/DTEND as UTC instants regardless of local time zone", () => {
const ics = generateAppointmentIcs(BASE_INPUT);
expect(ics).toContain("DTSTART:20260810T090000Z");
expect(ics).toContain("DTEND:20260810T093000Z");
});
it("includes a UID derived from the appointment id, stable across re-generation", () => {
const first = generateAppointmentIcs(BASE_INPUT);
const second = generateAppointmentIcs(BASE_INPUT);
const uidPattern = /UID:appt_123@nextchair/;
expect(first).toMatch(uidPattern);
expect(second).toMatch(uidPattern);
});
it("combines multiple service names in the summary and description", () => {
const ics = generateAppointmentIcs({ ...BASE_INPUT, serviceNames: ["Haircut & Style", "Beard Trim"] });
expect(ics).toContain("SUMMARY:Haircut & Style + Beard Trim at Blake Heritage Barbershop");
expect(ics).toContain("Services: Haircut & Style\\, Beard Trim");
});
it("escapes commas, semicolons, and backslashes per RFC 5545", () => {
const ics = generateAppointmentIcs({
...BASE_INPUT,
businessName: "Sam's Cuts; Trims, & More\\Co",
});
// Escaped backslash first, then semicolon, then comma — order matters so
// the escaping backslashes themselves don't get re-escaped.
expect(ics).toContain("Sam's Cuts\\; Trims\\, & More\\\\Co");
});
it("escapes newlines in notes as literal \\n rather than breaking the content line", () => {
const ics = generateAppointmentIcs({ ...BASE_INPUT, notes: "Line one\nLine two" });
expect(ics).toContain("Line one\\nLine two");
// No raw, unescaped newline should appear inside the DESCRIPTION value itself.
const descriptionLine = ics.split("\r\n").find((l) => l.startsWith("DESCRIPTION"));
expect(descriptionLine).toBeDefined();
});
it("omits the Notes line entirely when there are no notes", () => {
const ics = generateAppointmentIcs(BASE_INPUT);
expect(ics).not.toContain("Notes:");
});
it("falls back to just the business name when no address is given", () => {
const ics = generateAppointmentIcs({ ...BASE_INPUT, businessAddress: undefined });
expect(ics).toContain("LOCATION:Blake Heritage Barbershop");
});
it("folds content lines longer than 75 octets per RFC 5545", () => {
const longNotes = "N".repeat(200);
const ics = generateAppointmentIcs({ ...BASE_INPUT, notes: longNotes });
const rawLines = ics.split("\r\n");
// No single physical line (before folding continuation) should exceed 75 octets.
for (const line of rawLines) {
expect(line.length).toBeLessThanOrEqual(75);
}
// Continuation lines start with a single space, per the spec.
const descriptionStart = rawLines.findIndex((l) => l.startsWith("DESCRIPTION"));
expect(rawLines[descriptionStart + 1]?.startsWith(" ")).toBe(true);
});
it("uses CRLF line endings throughout", () => {
const ics = generateAppointmentIcs(BASE_INPUT);
expect(ics.includes("\r\n")).toBe(true);
// Every bare \n should be immediately preceded by \r.
for (let i = 0; i < ics.length; i++) {
if (ics[i] === "\n") {
expect(ics[i - 1]).toBe("\r");
}
}
});
});
+89
View File
@@ -0,0 +1,89 @@
/**
* RFC 5545 (.ics) generation — provider-independent, works with every
* calendar app without any Google/Apple API access, per the Phase 4
* brief ("Do not require Google Calendar API access for the basic
* feature"). Pure string formatting, no I/O — the caller (a route
* handler) supplies already-validated, already-loaded data.
*
* Time zone handling: DTSTART/DTEND are emitted as UTC instants
* (`YYYYMMDDTHHMMSSZ`). This is the simplest *correct* approach — every
* calendar app converts a UTC-suffixed time to the viewer's own local
* time zone automatically, so there's no VTIMEZONE block to get wrong
* and no ambiguity around DST for whoever's viewing the event.
*/
export interface AppointmentIcsInput {
appointmentId: string;
businessName: string;
businessAddress?: string;
staffName: string;
serviceNames: string[];
startAt: Date;
endAt: Date;
notes?: string;
}
function pad(n: number, width = 2): string {
return String(n).padStart(width, "0");
}
function formatUtc(date: Date): string {
return (
`${date.getUTCFullYear()}${pad(date.getUTCMonth() + 1)}${pad(date.getUTCDate())}` +
`T${pad(date.getUTCHours())}${pad(date.getUTCMinutes())}${pad(date.getUTCSeconds())}Z`
);
}
/** Escapes text per RFC 5545 §3.3.11 — backslash, semicolon, comma, then newlines. Order matters: backslash must be escaped first, or the escaping backslashes we add for the other characters would themselves get re-escaped. */
function escapeText(value: string): string {
return value.replace(/\\/g, "\\\\").replace(/;/g, "\\;").replace(/,/g, "\\,").replace(/\r\n|\r|\n/g, "\\n");
}
/** Folds a content line at 75 octets per RFC 5545 §3.1, continuation lines prefixed with a single space. */
function foldLine(line: string): string {
const CRLF_FOLD = "\r\n ";
if (line.length <= 75) return line;
const chunks: string[] = [];
let rest = line;
let first = true;
while (rest.length > 0) {
const limit = first ? 75 : 74; // continuation lines lose one column to the leading space
chunks.push(rest.slice(0, limit));
rest = rest.slice(limit);
first = false;
}
return chunks.join(CRLF_FOLD);
}
export function generateAppointmentIcs(input: AppointmentIcsInput): string {
const summary = escapeText(`${input.serviceNames.join(" + ")} at ${input.businessName}`);
const descriptionParts = [
`Barber: ${input.staffName}`,
`Services: ${input.serviceNames.join(", ")}`,
`Booking reference: ${input.appointmentId}`,
];
if (input.notes) descriptionParts.push(`Notes: ${input.notes}`);
const description = escapeText(descriptionParts.join("\n"));
const location = input.businessAddress ? escapeText(`${input.businessName}, ${input.businessAddress}`) : escapeText(input.businessName);
const lines = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//NextChair//Booking//EN",
"CALSCALE:GREGORIAN",
"METHOD:PUBLISH",
"BEGIN:VEVENT",
`UID:${input.appointmentId}@nextchair`,
`DTSTAMP:${formatUtc(new Date())}`,
`DTSTART:${formatUtc(input.startAt)}`,
`DTEND:${formatUtc(input.endAt)}`,
`SUMMARY:${summary}`,
`DESCRIPTION:${description}`,
`LOCATION:${location}`,
"END:VEVENT",
"END:VCALENDAR",
];
return lines.map(foldLine).join("\r\n") + "\r\n";
}