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,153 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { Weekday } from "@/generated/prisma/enums";
import { addDays, todayIn, wallClockToInstant, weekdayOf } from "@/lib/time/business-time";
import type { CalendarDate } from "@/lib/time/business-time";
import { getOpeningHours, setOpeningHours, updateBusinessProfile } from "../business.service";
import {
cleanupTestBusiness,
createTestBusiness,
createTestCustomer,
createTestStaff,
prisma,
} from "@/services/scheduling/__tests__/fixtures";
import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures";
function nextWeekday(timezone: string, target: Weekday, minDaysAhead = 7): CalendarDate {
let date = addDays(todayIn(timezone), minDaysAhead);
for (let i = 0; i < 8; i++) {
if (weekdayOf(date) === target) return date;
date = addDays(date, 1);
}
throw new Error("unreachable");
}
describe("BusinessService (integration)", () => {
let business: TestBusiness;
beforeEach(async () => {
business = await createTestBusiness();
});
afterEach(async () => {
await cleanupTestBusiness(business);
});
describe("updateBusinessProfile", () => {
it("writes through to the Business and its primary BusinessLocation", async () => {
const result = await updateBusinessProfile(prisma, {
businessId: business.businessId,
profile: {
name: "Renamed Barbershop",
timezone: business.timezone,
currency: "GBP",
locale: "en-GB",
},
location: { addressLine1: "1 New Street", city: "Testville", postalCode: "TE1 1ST", country: "GB" },
});
expect(result.success).toBe(true);
const updated = await prisma.business.findUniqueOrThrow({
where: { id: business.businessId },
include: { locations: true },
});
expect(updated.name).toBe("Renamed Barbershop");
expect(updated.locations[0]?.city).toBe("Testville");
});
it("rejects an empty business name without writing anything", async () => {
const result = await updateBusinessProfile(prisma, {
businessId: business.businessId,
profile: { name: " ", timezone: business.timezone, currency: "GBP", locale: "en-GB" },
location: { addressLine1: "1 New Street", city: "Testville", postalCode: "TE1 1ST", country: "GB" },
});
expect(result.success).toBe(false);
expect(result.validation.issues[0]?.code).toBe("INVALID_INPUT");
const unchanged = await prisma.business.findUniqueOrThrow({ where: { id: business.businessId } });
expect(unchanged.name).toBe("Test Barbershop");
});
});
describe("setOpeningHours", () => {
it("blocks closing a day that has a future confirmed appointment, and Save Anyway bypasses it", async () => {
const { staffId } = await createTestStaff(business, { workingDays: [Weekday.MONDAY] });
const { customerId } = await createTestCustomer(business);
const monday = nextWeekday(business.timezone, Weekday.MONDAY);
const appointment = await prisma.appointment.create({
data: {
businessId: business.businessId,
staffId,
customerId,
status: "CONFIRMED",
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
endAt: wallClockToInstant(monday, 10 * 60 + 30, business.timezone),
services: {
create: [{ serviceId: business.haircutServiceId, priceAtBooking: 20, durationAtBookingMinutes: 30 }],
},
},
});
const days = [
{ dayOfWeek: Weekday.MONDAY, isClosed: true },
{ dayOfWeek: Weekday.TUESDAY, isClosed: false, opensAtMinutes: 9 * 60, closesAtMinutes: 18 * 60 },
];
const blocked = await setOpeningHours(prisma, {
businessId: business.businessId,
businessTimezone: business.timezone,
days,
});
expect(blocked.success).toBe(false);
if (blocked.success) throw new Error("unreachable");
expect(blocked.conflicts.map((c) => c.id)).toContain(appointment.id);
// The appointment is untouched — never auto-cancelled by tightening hours.
const stillConfirmed = await prisma.appointment.findUniqueOrThrow({ where: { id: appointment.id } });
expect(stillConfirmed.status).toBe("CONFIRMED");
const acknowledged = await setOpeningHours(prisma, {
businessId: business.businessId,
businessTimezone: business.timezone,
days,
acknowledgeConflicts: true,
});
expect(acknowledged.success).toBe(true);
const hours = await getOpeningHours(prisma, business.businessId);
const mondayHours = hours.find((h) => h.dayOfWeek === Weekday.MONDAY);
expect(mondayHours?.isClosed).toBe(true);
// The appointment still exists, unmodified, after the "Save Anyway".
const afterSave = await prisma.appointment.findUniqueOrThrow({ where: { id: appointment.id } });
expect(afterSave.status).toBe("CONFIRMED");
});
it("does not flag a conflict for an appointment that falls inside the new hours", async () => {
const { staffId } = await createTestStaff(business, { workingDays: [Weekday.MONDAY] });
const { customerId } = await createTestCustomer(business);
const monday = nextWeekday(business.timezone, Weekday.MONDAY);
await prisma.appointment.create({
data: {
businessId: business.businessId,
staffId,
customerId,
status: "CONFIRMED",
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
endAt: wallClockToInstant(monday, 10 * 60 + 30, business.timezone),
services: {
create: [{ serviceId: business.haircutServiceId, priceAtBooking: 20, durationAtBookingMinutes: 30 }],
},
},
});
const result = await setOpeningHours(prisma, {
businessId: business.businessId,
businessTimezone: business.timezone,
days: [{ dayOfWeek: Weekday.MONDAY, isClosed: false, opensAtMinutes: 9 * 60, closesAtMinutes: 18 * 60 }],
});
expect(result.success).toBe(true);
});
});
});
@@ -0,0 +1,73 @@
import { describe, expect, it } from "vitest";
import { Weekday } from "@/generated/prisma/enums";
import { validateBusinessProfileInput, validateOpeningHoursInput } from "../business.service";
describe("validateBusinessProfileInput", () => {
const base = {
name: "Blake Heritage Barbershop",
timezone: "Europe/London",
currency: "GBP",
locale: "en-GB",
};
it("accepts a complete profile", () => {
expect(validateBusinessProfileInput(base).valid).toBe(true);
});
it("rejects an empty name", () => {
const result = validateBusinessProfileInput({ ...base, name: " " });
expect(result.valid).toBe(false);
expect(result.issues[0]?.code).toBe("INVALID_INPUT");
});
it("rejects a missing timezone/currency/locale", () => {
const result = validateBusinessProfileInput({ ...base, timezone: "" });
expect(result.valid).toBe(false);
expect(result.issues[0]?.code).toBe("INVALID_INPUT");
});
});
describe("validateOpeningHoursInput", () => {
it("accepts a day marked closed with no times", () => {
const result = validateOpeningHoursInput([{ dayOfWeek: Weekday.SUNDAY, isClosed: true }]);
expect(result.valid).toBe(true);
});
it("requires open/close times for an open day", () => {
const result = validateOpeningHoursInput([{ dayOfWeek: Weekday.MONDAY, isClosed: false }]);
expect(result.valid).toBe(false);
expect(result.issues[0]?.code).toBe("INVALID_INPUT");
});
it("rejects opening time at or after closing time", () => {
const result = validateOpeningHoursInput([
{ dayOfWeek: Weekday.TUESDAY, isClosed: false, opensAtMinutes: 600, closesAtMinutes: 600 },
]);
expect(result.valid).toBe(false);
expect(result.issues[0]?.code).toBe("INVALID_TIME_RANGE");
});
it("rejects times outside a single day", () => {
const result = validateOpeningHoursInput([
{ dayOfWeek: Weekday.WEDNESDAY, isClosed: false, opensAtMinutes: 0, closesAtMinutes: 25 * 60 },
]);
expect(result.valid).toBe(false);
expect(result.issues[0]?.code).toBe("INVALID_TIME_RANGE");
});
it("rejects a negative opening time", () => {
const result = validateOpeningHoursInput([
{ dayOfWeek: Weekday.THURSDAY, isClosed: false, opensAtMinutes: -30, closesAtMinutes: 600 },
]);
expect(result.valid).toBe(false);
expect(result.issues[0]?.code).toBe("INVALID_TIME_RANGE");
});
it("accepts a valid full week mixing open and closed days", () => {
const result = validateOpeningHoursInput([
{ dayOfWeek: Weekday.MONDAY, isClosed: false, opensAtMinutes: 10 * 60, closesAtMinutes: 19 * 60 },
{ dayOfWeek: Weekday.SUNDAY, isClosed: true },
]);
expect(result.valid).toBe(true);
});
});
+348
View File
@@ -0,0 +1,348 @@
import { Prisma } from "@/generated/prisma/client";
import type { PrismaClient } from "@/generated/prisma/client";
import { Weekday } from "@/generated/prisma/enums";
import { calendarDateOf, minutesFromMidnight, weekdayOf } from "@/lib/time/business-time";
import { fail, ok } from "@/lib/validation-result";
import type { ValidationResult } from "@/lib/validation-result";
import type { SplashScreenEntry } from "@/lib/business/branding";
/**
* The business's own profile, address, policies, and opening hours —
* owner-only configuration that sits above everything StaffService and
* BookingRule already cover. Kept separate from BookingRule (scheduling
* policy) and LoyaltyService (loyalty) per the brief's own service
* boundaries; this file only knows about Business/BusinessLocation/
* BusinessSettings/BusinessHours.
*/
export type BusinessServiceErrorCode =
| "INVALID_INPUT"
| "INVALID_TIME_RANGE"
| "APPOINTMENT_CONFLICTS";
export interface BusinessProfileInput {
name: string;
description?: string;
/**
* Image/colour branding fields are `string | null` (not `| undefined`)
* and always included on the update payload — see updateBusinessProfile
* below — so MediaPicker/ColorPicker's "Remove" actually clears the
* column to NULL instead of silently being skipped.
*/
logoUrl?: string | null;
coverImageUrl?: string | null;
faviconUrl?: string | null;
appIconUrl?: string | null;
themeColor?: string | null;
backgroundColor?: string | null;
welcomeBackgroundDesktopUrl?: string | null;
welcomeBackgroundMobileUrl?: string | null;
/** Google Places Place ID reviews sync from — see src/services/reviews/review-sync.service.ts. Null/empty disables sync for this business. */
googlePlaceId?: string | null;
phone?: string;
email?: string;
website?: string;
instagramUrl?: string;
facebookUrl?: string;
timezone: string;
currency: string;
locale: string;
}
export interface BusinessLocationInput {
addressLine1: string;
addressLine2?: string;
city: string;
region?: string;
postalCode: string;
country: string;
latitude?: number;
longitude?: number;
}
export interface BusinessSettingsInput {
cancellationPolicy?: string;
lateArrivalPolicy?: string;
bookingTerms?: string;
privacyPolicyUrl?: string;
/** Reviews visibility policy — see src/services/reviews. Applied on top of each Review's own isHidden flag. */
reviewsMinRating?: number | null;
reviewsExcludeRatingOnly?: boolean;
}
export function validateBusinessProfileInput(
input: BusinessProfileInput,
): ValidationResult<BusinessServiceErrorCode> {
if (!input.name.trim()) {
return fail({ code: "INVALID_INPUT", message: "Business name is required." });
}
if (!input.timezone.trim() || !input.currency.trim() || !input.locale.trim()) {
return fail({ code: "INVALID_INPUT", message: "Timezone, currency, and locale are required." });
}
return ok();
}
export async function getBusinessProfile(prisma: PrismaClient, businessId: string) {
return prisma.business.findUnique({
where: { id: businessId },
include: {
locations: { where: { isPrimary: true }, take: 1 },
settings: true,
},
});
}
export async function updateBusinessProfile(
prisma: PrismaClient,
params: {
businessId: string;
profile: BusinessProfileInput;
location: BusinessLocationInput;
},
): Promise<{ success: boolean; validation: ValidationResult<BusinessServiceErrorCode> }> {
const validation = validateBusinessProfileInput(params.profile);
if (!validation.valid) return { success: false, validation };
if (!params.location.addressLine1.trim() || !params.location.city.trim() || !params.location.postalCode.trim()) {
return {
success: false,
validation: fail({ code: "INVALID_INPUT", message: "Address line 1, city, and postcode are required." }),
};
}
const existingLocation = await prisma.businessLocation.findFirst({
where: { businessId: params.businessId, isPrimary: true },
});
await prisma.$transaction([
prisma.business.update({
where: { id: params.businessId },
data: { ...params.profile },
}),
existingLocation
? prisma.businessLocation.update({
where: { id: existingLocation.id },
data: { ...params.location },
})
: prisma.businessLocation.create({
data: { businessId: params.businessId, isPrimary: true, ...params.location },
}),
]);
return { success: true, validation: ok() };
}
export async function updateBusinessSettings(
prisma: PrismaClient,
params: { businessId: string; settings: BusinessSettingsInput },
): Promise<{ success: boolean }> {
await prisma.businessSettings.upsert({
where: { businessId: params.businessId },
create: { businessId: params.businessId, ...params.settings },
update: { ...params.settings },
});
return { success: true };
}
/**
* The subset of Business fields that actually drive "Add to Home Screen"
* — manifest short_name/theme_color/background_color/icons (see
* src/app/manifest.ts) and the iOS apple touch icon/splash screens (see
* layout.tsx) — split out from BusinessProfileInput/updateBusinessProfile
* so this one focused screen doesn't also have to carry a
* BusinessLocationInput through its save (that pair always transacts
* together for the main Business Details form, which this isn't part
* of). Restricted to Super Administrators only — see
* updateAppIconSettingsAction.
*/
export interface AppIconSettingsInput {
/** The name shown under the icon once installed — distinct from the full business `name` (manifest's short_name). */
shortName?: string | null;
/** Apple touch icon (iOS "Add to Home Screen") — distinct from the manifest icon fields below. */
appIconUrl?: string | null;
themeColor?: string | null;
backgroundColor?: string | null;
/** PWA manifest `icons` array (Android/Chrome) — see src/app/manifest.ts. */
icon192Url?: string | null;
icon512Url?: string | null;
maskableIcon192Url?: string | null;
maskableIcon512Url?: string | null;
/** iOS splash screens — see src/app/layout.tsx. Replaces the whole set on every save; there's no per-entry add/remove, only a wholesale re-import or clear. */
splashScreens?: SplashScreenEntry[] | null;
}
export async function updateAppIconSettings(
prisma: PrismaClient,
params: { businessId: string; settings: AppIconSettingsInput },
): Promise<{ success: boolean }> {
const { splashScreens, ...rest } = params.settings;
await prisma.business.update({
where: { id: params.businessId },
data: {
...rest,
...(splashScreens !== undefined && {
splashScreens:
splashScreens === null ? Prisma.JsonNull : (splashScreens as unknown as Prisma.InputJsonValue),
}),
},
});
return { success: true };
}
/**
* Business-wide app-display toggles that aren't PWA-icon-specific enough
* to belong in AppIconSettingsInput above, but are still Super
* Administrator only (same rendering-affects-everyone-instantly reason —
* see updateNavigationSettingsAction). Kept as its own input/function
* rather than folded into AppIconSettingsInput so that interface's own
* "drives Add to Home Screen" doc comment stays accurate.
*/
export interface NavigationSettingsInput {
/** Icon+label vs icon-only bottom navigation — see components/navigation/bottom-nav.tsx. */
bottomNavShowLabels: boolean;
}
export async function updateNavigationSettings(
prisma: PrismaClient,
params: { businessId: string; settings: NavigationSettingsInput },
): Promise<{ success: boolean }> {
await prisma.business.update({
where: { id: params.businessId },
data: { ...params.settings },
});
return { success: true };
}
// ── Opening hours ──────────────────────────────────────────────────────
export interface OpeningHoursDayInput {
dayOfWeek: Weekday;
isClosed: boolean;
opensAtMinutes?: number;
closesAtMinutes?: number;
}
export function validateOpeningHoursInput(
days: OpeningHoursDayInput[],
): ValidationResult<BusinessServiceErrorCode> {
for (const day of days) {
if (day.isClosed) continue;
if (day.opensAtMinutes === undefined || day.closesAtMinutes === undefined) {
return fail({
code: "INVALID_INPUT",
message: `${day.dayOfWeek}: opening and closing time are required for an open day.`,
});
}
if (day.opensAtMinutes < 0 || day.closesAtMinutes > 24 * 60) {
return fail({
code: "INVALID_TIME_RANGE",
message: `${day.dayOfWeek}: times must fall within a single day.`,
});
}
if (day.opensAtMinutes >= day.closesAtMinutes) {
return fail({
code: "INVALID_TIME_RANGE",
message: `${day.dayOfWeek}: opening time must be before closing time.`,
});
}
}
return ok();
}
export async function getOpeningHours(prisma: PrismaClient, businessId: string) {
return prisma.businessHours.findMany({ where: { businessId }, orderBy: { dayOfWeek: "asc" } });
}
const APPOINTMENT_CONFLICT_INCLUDE = {
services: { include: { service: true } },
customer: true,
staff: { include: { user: { select: { name: true } } } },
} as const;
/**
* Future, non-cancelled appointments that would fall outside the proposed
* hours — either on a day newly marked closed, or before/after the new
* open/close window on a day that stays open. Mirrors the Phase 5
* break/time-off conflict pattern: this never cancels anything itself, it
* only reports what the caller should show before asking for
* acknowledgement.
*/
export async function findOpeningHoursConflicts(
prisma: PrismaClient,
params: { businessId: string; businessTimezone: string; days: OpeningHoursDayInput[] },
) {
const appointments = await prisma.appointment.findMany({
where: {
businessId: params.businessId,
status: { notIn: ["CANCELLED", "NO_SHOW", "COMPLETED"] },
startAt: { gte: new Date() },
},
include: APPOINTMENT_CONFLICT_INCLUDE,
orderBy: { startAt: "asc" },
});
const byDay = new Map(params.days.map((d) => [d.dayOfWeek, d]));
return appointments.filter((a) => {
const weekday = weekdayOf(calendarDateOf(a.startAt, params.businessTimezone));
const proposed = byDay.get(weekday);
if (!proposed) return false;
if (proposed.isClosed) return true;
const aStart = minutesFromMidnight(a.startAt, params.businessTimezone);
const aEnd = minutesFromMidnight(a.endAt, params.businessTimezone);
return aStart < (proposed.opensAtMinutes ?? 0) || aEnd > (proposed.closesAtMinutes ?? 24 * 60);
});
}
export async function setOpeningHours(
prisma: PrismaClient,
params: {
businessId: string;
businessTimezone: string;
days: OpeningHoursDayInput[];
acknowledgeConflicts?: boolean;
},
) {
const validation = validateOpeningHoursInput(params.days);
if (!validation.valid) return { success: false as const, validation, conflicts: [] };
if (!params.acknowledgeConflicts) {
const conflicts = await findOpeningHoursConflicts(prisma, {
businessId: params.businessId,
businessTimezone: params.businessTimezone,
days: params.days,
});
if (conflicts.length > 0) {
return {
success: false as const,
conflicts,
validation: fail({
code: "APPOINTMENT_CONFLICTS",
message: "These hours would leave existing appointments outside the business's open hours.",
}),
};
}
}
await prisma.$transaction(
params.days.map((day) =>
prisma.businessHours.upsert({
where: { businessId_dayOfWeek: { businessId: params.businessId, dayOfWeek: day.dayOfWeek } },
create: {
businessId: params.businessId,
dayOfWeek: day.dayOfWeek,
isClosed: day.isClosed,
opensAtMinutes: day.isClosed ? null : (day.opensAtMinutes ?? null),
closesAtMinutes: day.isClosed ? null : (day.closesAtMinutes ?? null),
},
update: {
isClosed: day.isClosed,
opensAtMinutes: day.isClosed ? null : (day.opensAtMinutes ?? null),
closesAtMinutes: day.isClosed ? null : (day.closesAtMinutes ?? null),
},
}),
),
);
return { success: true as const, conflicts: [], validation: ok() };
}
@@ -0,0 +1,187 @@
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
import {
cleanupTestBusiness,
createTestBusiness,
createTestCustomer,
createTestStaff,
prisma,
} from "@/services/scheduling/__tests__/fixtures";
import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures";
import { AppointmentStatus, CalendarSyncStatus } from "@/generated/prisma/enums";
import { encryptToken } from "@/lib/security/token-encryption";
import { MockCalendarProvider } from "../mock-calendar.provider";
import { runInitialSync, syncAppointmentSafely } from "../calendar-sync.service";
/**
* Exercises the real sync policy (create/update/cancel/duplicate
* prevention/reassignment) against a real database, with a
* `MockCalendarProvider` standing in for Google — see that file's own
* header for why (the brief: "Do not call real Google Calendar APIs in
* automated tests").
*/
describe("calendar-sync.service (integration)", () => {
let business: TestBusiness;
let staffId: string;
let customerId: string;
let provider: MockCalendarProvider;
beforeAll(() => {
process.env.CALENDAR_TOKEN_ENCRYPTION_KEY = "test-key-not-for-production-use";
});
beforeEach(async () => {
business = await createTestBusiness();
const staff = await createTestStaff(business);
staffId = staff.staffId;
const customer = await createTestCustomer(business);
customerId = customer.customerId;
provider = new MockCalendarProvider();
});
afterEach(async () => {
await cleanupTestBusiness(business);
});
async function connectCalendar(): Promise<string> {
const connection = await prisma.staffCalendarConnection.create({
data: {
staffId,
externalAccountId: "mock-account",
accountEmail: "barber@example.com",
encryptedAccessToken: encryptToken("mock-access-token"),
encryptedRefreshToken: encryptToken("mock-refresh-token"),
tokenExpiresAt: new Date(Date.now() + 3600_000),
selectedCalendarId: "primary",
selectedCalendarName: "Primary Calendar",
},
});
return connection.id;
}
async function createAppointment(status: AppointmentStatus, overrides: { staffId?: string } = {}) {
return prisma.appointment.create({
data: {
businessId: business.businessId,
staffId: overrides.staffId ?? staffId,
customerId,
status,
startAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
endAt: new Date(Date.now() + 24 * 60 * 60 * 1000 + 30 * 60_000),
services: { create: [{ serviceId: business.haircutServiceId, priceAtBooking: 20, durationAtBookingMinutes: 30 }] },
},
});
}
it("creates a remote event for a CONFIRMED appointment", async () => {
await connectCalendar();
const appointment = await createAppointment(AppointmentStatus.CONFIRMED);
await syncAppointmentSafely(prisma, appointment.id, provider);
const event = await prisma.appointmentCalendarEvent.findFirst({ where: { appointmentId: appointment.id } });
expect(event?.status).toBe(CalendarSyncStatus.SYNCED);
expect(provider.events.size).toBe(1);
const [providerEvent] = [...provider.events.values()];
expect(providerEvent?.summary).toContain("Test Customer");
});
it("does not sync a PENDING appointment", async () => {
await connectCalendar();
const appointment = await createAppointment(AppointmentStatus.PENDING);
await syncAppointmentSafely(prisma, appointment.id, provider);
const event = await prisma.appointmentCalendarEvent.findFirst({ where: { appointmentId: appointment.id } });
expect(event).toBeNull();
expect(provider.events.size).toBe(0);
});
it("is idempotent — syncing an already-synced appointment updates the same remote event rather than creating a second one", async () => {
await connectCalendar();
const appointment = await createAppointment(AppointmentStatus.CONFIRMED);
await syncAppointmentSafely(prisma, appointment.id, provider);
await syncAppointmentSafely(prisma, appointment.id, provider);
const events = await prisma.appointmentCalendarEvent.findMany({ where: { appointmentId: appointment.id } });
expect(events).toHaveLength(1);
expect(provider.events.size).toBe(1);
});
it("deletes the remote event when the appointment is cancelled", async () => {
await connectCalendar();
const appointment = await createAppointment(AppointmentStatus.CONFIRMED);
await syncAppointmentSafely(prisma, appointment.id, provider);
expect(provider.events.size).toBe(1);
await prisma.appointment.update({ where: { id: appointment.id }, data: { status: AppointmentStatus.CANCELLED } });
await syncAppointmentSafely(prisma, appointment.id, provider);
expect(provider.events.size).toBe(0);
const event = await prisma.appointmentCalendarEvent.findFirst({ where: { appointmentId: appointment.id } });
expect(event?.status).toBe(CalendarSyncStatus.CANCELLED);
});
it("cancels the previous staff member's event on reassignment and creates one under the new staff member's connection", async () => {
await connectCalendar();
const otherStaff = await createTestStaff(business);
const otherConnection = await prisma.staffCalendarConnection.create({
data: {
staffId: otherStaff.staffId,
externalAccountId: "mock-account-2",
accountEmail: "other-barber@example.com",
encryptedAccessToken: encryptToken("mock-access-token-2"),
encryptedRefreshToken: encryptToken("mock-refresh-token-2"),
tokenExpiresAt: new Date(Date.now() + 3600_000),
selectedCalendarId: "primary",
selectedCalendarName: "Primary Calendar",
},
});
const appointment = await createAppointment(AppointmentStatus.CONFIRMED);
await syncAppointmentSafely(prisma, appointment.id, provider);
expect(provider.events.size).toBe(1);
await prisma.appointment.update({ where: { id: appointment.id }, data: { staffId: otherStaff.staffId } });
await syncAppointmentSafely(prisma, appointment.id, provider);
const originalConnectionEvent = await prisma.appointmentCalendarEvent.findFirst({
where: { appointmentId: appointment.id, connection: { staffId } },
});
expect(originalConnectionEvent?.status).toBe(CalendarSyncStatus.CANCELLED);
const newConnectionEvent = await prisma.appointmentCalendarEvent.findFirst({
where: { appointmentId: appointment.id, connectionId: otherConnection.id },
});
expect(newConnectionEvent?.status).toBe(CalendarSyncStatus.SYNCED);
await prisma.staffCalendarConnection.delete({ where: { id: otherConnection.id } }).catch(() => {});
});
it("runInitialSync syncs every future non-pending, non-cancelled appointment for a newly connected staff member", async () => {
await connectCalendar();
const confirmed = await createAppointment(AppointmentStatus.CONFIRMED);
const pending = await createAppointment(AppointmentStatus.PENDING);
const cancelled = await createAppointment(AppointmentStatus.CANCELLED);
const result = await runInitialSync(prisma, staffId, provider);
expect(result.synced).toBe(1);
expect(provider.events.size).toBe(1);
const syncedEvent = await prisma.appointmentCalendarEvent.findFirst({ where: { appointmentId: confirmed.id } });
expect(syncedEvent).toBeTruthy();
const pendingEvent = await prisma.appointmentCalendarEvent.findFirst({ where: { appointmentId: pending.id } });
expect(pendingEvent).toBeNull();
const cancelledEvent = await prisma.appointmentCalendarEvent.findFirst({ where: { appointmentId: cancelled.id } });
expect(cancelledEvent).toBeNull();
});
it("does nothing when no calendar connection exists for the staff member", async () => {
const appointment = await createAppointment(AppointmentStatus.CONFIRMED);
await syncAppointmentSafely(prisma, appointment.id, provider);
const event = await prisma.appointmentCalendarEvent.findFirst({ where: { appointmentId: appointment.id } });
expect(event).toBeNull();
expect(provider.events.size).toBe(0);
});
});
@@ -0,0 +1,32 @@
import type { CalendarEventInput, ExchangedTokens, RefreshedAccessToken, RemoteCalendarInfo } from "./types";
/**
* Provider-neutral operations `CalendarSyncService` calls — Google is the
* only implementation today (`google-calendar.provider.ts`), but nothing
* in the sync service or appointment Server Actions calls Google's API
* directly (see the brief's explicit "Do not call Google APIs directly
* from appointment Server Actions"), so a second provider is additive.
*/
export interface CalendarProvider {
name: string;
/** OAuth authorization URL the browser is redirected to — `state` is an opaque, server-generated CSRF/replay token the callback route re-verifies. */
buildAuthorizationUrl(params: { redirectUri: string; state: string }): string;
/** Exchanges the callback's `code` for tokens, and resolves which external account this actually is. */
exchangeCodeForTokens(params: { code: string; redirectUri: string }): Promise<ExchangedTokens>;
refreshAccessToken(refreshToken: string): Promise<RefreshedAccessToken>;
/** Only ever returns calendars the account can write to — see RemoteCalendarInfo.writable. */
listWritableCalendars(accessToken: string): Promise<RemoteCalendarInfo[]>;
createEvent(params: { accessToken: string; calendarId: string; event: CalendarEventInput }): Promise<{ providerEventId: string }>;
updateEvent(params: { accessToken: string; calendarId: string; providerEventId: string; event: CalendarEventInput }): Promise<void>;
deleteEvent(params: { accessToken: string; calendarId: string; providerEventId: string }): Promise<void>;
/** Best-effort server-side revocation at disconnect — see the brief's "Revoke credentials where possible." */
revoke(refreshToken: string): Promise<void>;
}
@@ -0,0 +1,257 @@
import type { PrismaClient } from "@/generated/prisma/client";
import { AppointmentStatus, CalendarSyncStatus } from "@/generated/prisma/enums";
import { decryptToken, encryptToken } from "@/lib/security/token-encryption";
import type { CalendarProvider } from "./calendar-provider";
import { GoogleCalendarProvider } from "./google-calendar.provider";
import { CalendarProviderError } from "./types";
import type { CalendarEventInput } from "./types";
/**
* Appointment → Google Calendar sync policy — the one place that decides
* *whether* and *how* an appointment's status maps to a calendar event.
* Provider-neutral: everything here goes through `CalendarProvider`, never
* a Google-specific call directly (see calendar-provider.ts). Every
* public function here is safe to call from a Server Action after an
* appointment mutation commits — sync failures are caught and recorded,
* never thrown, so a Google outage can never roll back or block a real
* booking change (see the brief's explicit requirement).
*/
function getProvider(): CalendarProvider {
// Google is the only provider today — see CalendarProvider's own header
// for why a second one is additive, not a rewrite, when needed.
return new GoogleCalendarProvider();
}
async function getValidAccessToken(
prisma: PrismaClient,
connection: { id: string; encryptedAccessToken: string | null; encryptedRefreshToken: string; tokenExpiresAt: Date | null },
provider: CalendarProvider,
): Promise<string> {
const stillValid =
connection.encryptedAccessToken && connection.tokenExpiresAt && connection.tokenExpiresAt.getTime() - Date.now() > 60_000;
if (stillValid) return decryptToken(connection.encryptedAccessToken!);
const refreshToken = decryptToken(connection.encryptedRefreshToken);
const refreshed = await provider.refreshAccessToken(refreshToken);
await prisma.staffCalendarConnection.update({
where: { id: connection.id },
data: { encryptedAccessToken: encryptToken(refreshed.accessToken), tokenExpiresAt: refreshed.expiresAt },
});
return refreshed.accessToken;
}
function buildEventContent(appointment: {
id: string;
startAt: Date;
endAt: Date;
status: AppointmentStatus;
customer: { firstName: string; lastName: string };
services: { service: { name: string } }[];
business: { name: string; locations: { addressLine1: string; city: string; postalCode: string }[] };
}): CalendarEventInput {
const serviceNames = appointment.services.map((s) => s.service.name).join(", ");
const location = appointment.business.locations[0];
const locationLine = location ? `${location.addressLine1}, ${location.city} ${location.postalCode}` : undefined;
const appUrl = process.env.NEXT_PUBLIC_APP_URL || "";
const descriptionLines = [
`Customer: ${appointment.customer.firstName} ${appointment.customer.lastName}`,
`Service: ${serviceNames}`,
`Business: ${appointment.business.name}`,
locationLine ? `Address: ${locationLine}` : undefined,
`Reference: ${appointment.id}`,
appUrl ? `View: ${appUrl}/staff/appointments/${appointment.id}` : undefined,
appointment.status === AppointmentStatus.NO_SHOW ? "Status: customer did not show up." : undefined,
].filter((line): line is string => !!line);
return {
summary: `${serviceNames} — ${appointment.customer.firstName} ${appointment.customer.lastName}`,
description: descriptionLines.join("\n"),
location: locationLine,
startAt: appointment.startAt,
endAt: appointment.endAt,
};
}
/**
* Policy: PENDING appointments are never synced (an unapproved booking
* shouldn't occupy the barber's personal calendar); CANCELLED deletes the
* remote event outright (a cancelled slot has no reason to still show
* busy); everything else (CONFIRMED/CHECKED_IN/IN_PROGRESS/COMPLETED/
* NO_SHOW) creates or updates the event, with NO_SHOW additionally noting
* the outcome in the description rather than removing the event (the
* barber's calendar should still show they were scheduled to work).
*/
async function syncOneConnection(
prisma: PrismaClient,
params: {
appointmentId: string;
connection: {
id: string;
encryptedAccessToken: string | null;
encryptedRefreshToken: string;
tokenExpiresAt: Date | null;
selectedCalendarId: string;
};
provider: CalendarProvider;
},
): Promise<void> {
const appointment = await prisma.appointment.findUnique({
where: { id: params.appointmentId },
include: {
customer: { select: { firstName: true, lastName: true } },
services: { include: { service: { select: { name: true } } } },
business: { select: { name: true, locations: { where: { isPrimary: true }, take: 1 } } },
},
});
if (!appointment) return;
const provider = params.provider;
const existing = await prisma.appointmentCalendarEvent.findUnique({
where: { appointmentId_connectionId: { appointmentId: params.appointmentId, connectionId: params.connection.id } },
});
try {
if (appointment.status === AppointmentStatus.PENDING) return;
const accessToken = await getValidAccessToken(prisma, params.connection, provider);
if (appointment.status === AppointmentStatus.CANCELLED) {
if (existing) {
await provider.deleteEvent({ accessToken, calendarId: params.connection.selectedCalendarId, providerEventId: existing.providerEventId });
await prisma.appointmentCalendarEvent.update({
where: { id: existing.id },
data: { status: CalendarSyncStatus.CANCELLED, lastSyncedAt: new Date(), lastSyncError: null },
});
}
return;
}
const event = buildEventContent(appointment);
if (existing && existing.status !== CalendarSyncStatus.CANCELLED) {
await provider.updateEvent({
accessToken,
calendarId: params.connection.selectedCalendarId,
providerEventId: existing.providerEventId,
event,
});
await prisma.appointmentCalendarEvent.update({
where: { id: existing.id },
data: { status: CalendarSyncStatus.SYNCED, lastSyncedAt: new Date(), lastSyncError: null, sourceVersion: { increment: 1 } },
});
} else {
const created = await provider.createEvent({ accessToken, calendarId: params.connection.selectedCalendarId, event });
await prisma.appointmentCalendarEvent.upsert({
where: { appointmentId_connectionId: { appointmentId: params.appointmentId, connectionId: params.connection.id } },
create: {
appointmentId: params.appointmentId,
connectionId: params.connection.id,
providerEventId: created.providerEventId,
providerCalendarId: params.connection.selectedCalendarId,
status: CalendarSyncStatus.SYNCED,
},
update: {
providerEventId: created.providerEventId,
providerCalendarId: params.connection.selectedCalendarId,
status: CalendarSyncStatus.SYNCED,
lastSyncedAt: new Date(),
lastSyncError: null,
},
});
}
await prisma.staffCalendarConnection.update({ where: { id: params.connection.id }, data: { lastSyncAt: new Date(), lastSyncError: null } });
} catch (error) {
const message = error instanceof CalendarProviderError ? error.message : "Calendar sync failed unexpectedly.";
await prisma.staffCalendarConnection.update({ where: { id: params.connection.id }, data: { lastSyncError: message } }).catch(() => {});
if (existing) {
await prisma.appointmentCalendarEvent
.update({ where: { id: existing.id }, data: { status: CalendarSyncStatus.FAILED, lastSyncError: message } })
.catch(() => {});
}
}
}
/**
* Call after any appointment mutation that should be reflected in
* calendars — confirmation, approval, reschedule, service change,
* cancellation, no-show. Never throws; a Google failure is recorded on
* the connection/event rows for the staff settings page to surface, not
* propagated back to the caller (see this file's own header). If the
* appointment was reassigned to a different staff member, the previous
* staff member's event (if any) is cancelled first so responsibility
* moves cleanly rather than leaving a stale duplicate.
*/
export async function syncAppointmentSafely(
prisma: PrismaClient,
appointmentId: string,
provider: CalendarProvider = getProvider(),
): Promise<void> {
const appointment = await prisma.appointment.findUnique({ where: { id: appointmentId }, select: { staffId: true } });
if (!appointment) return;
const staleEvents = await prisma.appointmentCalendarEvent.findMany({
where: { appointmentId, status: { not: CalendarSyncStatus.CANCELLED }, connection: { staffId: { not: appointment.staffId } } },
include: { connection: true },
});
for (const stale of staleEvents) {
await syncOneConnection(prisma, {
appointmentId,
provider,
connection: {
id: stale.connection.id,
encryptedAccessToken: stale.connection.encryptedAccessToken,
encryptedRefreshToken: stale.connection.encryptedRefreshToken,
tokenExpiresAt: stale.connection.tokenExpiresAt,
selectedCalendarId: stale.connection.selectedCalendarId,
},
}).catch(() => {});
}
// A reassigned-away staff member's event is deleted via the CANCELLED
// path above regardless of the appointment's own current status — it's
// no longer this connection's appointment to reflect.
if (staleEvents.length > 0) {
await prisma.appointmentCalendarEvent.updateMany({
where: { id: { in: staleEvents.map((e) => e.id) } },
data: { status: CalendarSyncStatus.CANCELLED },
});
}
const connection = await prisma.staffCalendarConnection.findUnique({ where: { staffId: appointment.staffId } });
if (!connection || connection.revokedAt) return;
await syncOneConnection(prisma, { appointmentId, connection, provider });
}
/**
* Sync every future appointment for a newly connected staff member — see
* the brief's "Initial sync... default to the next 90 days." `provider`
* is injectable (defaults to the real Google provider) purely so tests
* can pass a `MockCalendarProvider` — see
* src/services/calendar/mock-calendar.provider.ts and the brief's "Do
* not call real Google Calendar APIs in automated tests."
*/
export async function runInitialSync(
prisma: PrismaClient,
staffId: string,
provider: CalendarProvider = getProvider(),
): Promise<{ synced: number }> {
const connection = await prisma.staffCalendarConnection.findUnique({ where: { staffId } });
if (!connection) return { synced: 0 };
const appointments = await prisma.appointment.findMany({
where: {
staffId,
startAt: { gte: new Date(), lte: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000) },
status: { notIn: [AppointmentStatus.PENDING, AppointmentStatus.CANCELLED] },
},
select: { id: true },
});
for (const appointment of appointments) {
await syncOneConnection(prisma, { appointmentId: appointment.id, connection, provider });
}
return { synced: appointments.length };
}
@@ -0,0 +1,225 @@
import type { CalendarProvider } from "./calendar-provider";
import { CalendarProviderError } from "./types";
import type { CalendarEventInput, ExchangedTokens, RefreshedAccessToken, RemoteCalendarInfo } from "./types";
/**
* Google Calendar over its plain REST API (OAuth 2.0 + Calendar API v3)
* via `fetch` — no `googleapis` SDK dependency, matching this app's
* existing pattern of building directly against a documented REST
* contract rather than adding a heavy client library (see
* brevo-email-provider.ts's own header for the same reasoning applied to
* email). Minimum scopes: `calendar.events` (create/update/delete events)
* and `calendar.readonly` (list calendars to choose a destination) —
* deliberately not the full `calendar` scope, which also grants calendar
* *settings* management this app never needs.
*
* Unverified against a live Google account in this environment — no
* OAuth credentials were available to test against. See
* docs/phase-8-summary.md, "Unverified external-provider behaviour."
*/
const SCOPES = ["https://www.googleapis.com/auth/calendar.events", "https://www.googleapis.com/auth/calendar.readonly", "email"].join(" ");
interface GoogleErrorBody {
error?: string | { message?: string; errors?: { reason?: string }[] };
error_description?: string;
}
function mapGoogleError(status: number, body: GoogleErrorBody | undefined): CalendarProviderError {
const reason =
typeof body?.error === "object" ? body.error.errors?.[0]?.reason : typeof body?.error === "string" ? body.error : undefined;
if (status === 401 || reason === "authError") {
return new CalendarProviderError("TOKEN_EXPIRED", "The connection to Google Calendar has expired.", true);
}
if (status === 403 && reason === "insufficientPermissions") {
return new CalendarProviderError("CALENDAR_NOT_WRITABLE", "This calendar can't be written to.", false);
}
if (status === 403 || status === 429) {
return new CalendarProviderError("RATE_LIMITED", "Google Calendar is temporarily rate-limiting requests.", true);
}
if (status === 404) {
return new CalendarProviderError("CALENDAR_NOT_FOUND", "That calendar no longer exists.", false);
}
if (status === 400) {
return new CalendarProviderError("INVALID_EVENT", "Google Calendar rejected this event.", false);
}
if (status >= 500) {
return new CalendarProviderError("PROVIDER_OUTAGE", "Google Calendar is temporarily unavailable.", true);
}
return new CalendarProviderError("UNKNOWN", "Google Calendar returned an unexpected error.", true);
}
async function googleFetch(url: string, init: RequestInit): Promise<Response> {
let response: Response;
try {
response = await fetch(url, init);
} catch {
throw new CalendarProviderError("TIMEOUT", "Could not reach Google Calendar.", true);
}
if (!response.ok) {
let body: GoogleErrorBody | undefined;
try {
body = await response.json();
} catch {
// Non-JSON error body — mapGoogleError below still has the status code.
}
throw mapGoogleError(response.status, body);
}
return response;
}
interface GoogleConfig {
clientId: string;
clientSecret: string;
}
function loadConfig(): GoogleConfig {
const clientId = process.env.GOOGLE_CALENDAR_CLIENT_ID;
const clientSecret = process.env.GOOGLE_CALENDAR_CLIENT_SECRET;
if (!clientId || !clientSecret) {
throw new Error("GOOGLE_CALENDAR_CLIENT_ID and GOOGLE_CALENDAR_CLIENT_SECRET must be set to use Google Calendar sync.");
}
return { clientId, clientSecret };
}
function toGoogleEventBody(event: CalendarEventInput) {
return {
summary: event.summary,
description: event.description,
location: event.location,
start: { dateTime: event.startAt.toISOString() },
end: { dateTime: event.endAt.toISOString() },
// Never public — see the brief's "Events must not be public."
visibility: "private",
};
}
export class GoogleCalendarProvider implements CalendarProvider {
name = "google";
buildAuthorizationUrl(params: { redirectUri: string; state: string }): string {
const { clientId } = loadConfig();
const url = new URL("https://accounts.google.com/o/oauth2/v2/auth");
url.searchParams.set("client_id", clientId);
url.searchParams.set("redirect_uri", params.redirectUri);
url.searchParams.set("response_type", "code");
url.searchParams.set("scope", SCOPES);
url.searchParams.set("access_type", "offline");
// Forces Google to reissue a refresh token even for a user who
// previously connected — otherwise a reconnect after disconnect can
// silently get no refresh token at all.
url.searchParams.set("prompt", "consent");
url.searchParams.set("state", params.state);
return url.toString();
}
async exchangeCodeForTokens(params: { code: string; redirectUri: string }): Promise<ExchangedTokens> {
const { clientId, clientSecret } = loadConfig();
const response = await googleFetch("https://oauth2.googleapis.com/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
code: params.code,
client_id: clientId,
client_secret: clientSecret,
redirect_uri: params.redirectUri,
grant_type: "authorization_code",
}),
});
const tokens = await response.json();
if (!tokens.refresh_token) {
throw new CalendarProviderError(
"MISSING_REFRESH_TOKEN",
"Google didn't return a refresh token — please try connecting again.",
false,
);
}
const userInfoResponse = await googleFetch("https://www.googleapis.com/oauth2/v2/userinfo", {
headers: { Authorization: `Bearer ${tokens.access_token}` },
});
const userInfo = await userInfoResponse.json();
return {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresAt: new Date(Date.now() + tokens.expires_in * 1000),
externalAccountId: userInfo.id,
accountEmail: userInfo.email,
};
}
async refreshAccessToken(refreshToken: string): Promise<RefreshedAccessToken> {
const { clientId, clientSecret } = loadConfig();
const response = await googleFetch("https://oauth2.googleapis.com/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
refresh_token: refreshToken,
client_id: clientId,
client_secret: clientSecret,
grant_type: "refresh_token",
}),
});
const tokens = await response.json();
return { accessToken: tokens.access_token, expiresAt: new Date(Date.now() + tokens.expires_in * 1000) };
}
async listWritableCalendars(accessToken: string): Promise<RemoteCalendarInfo[]> {
const response = await googleFetch("https://www.googleapis.com/calendar/v3/users/me/calendarList", {
headers: { Authorization: `Bearer ${accessToken}` },
});
const data = await response.json();
const items: { id: string; summary: string; primary?: boolean; accessRole: string }[] = data.items ?? [];
return items
.filter((item) => item.accessRole === "owner" || item.accessRole === "writer")
.map((item) => ({ id: item.id, name: item.summary, writable: true, primary: !!item.primary }));
}
async createEvent(params: { accessToken: string; calendarId: string; event: CalendarEventInput }): Promise<{ providerEventId: string }> {
const response = await googleFetch(
`https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(params.calendarId)}/events`,
{
method: "POST",
headers: { Authorization: `Bearer ${params.accessToken}`, "Content-Type": "application/json" },
body: JSON.stringify(toGoogleEventBody(params.event)),
},
);
const created = await response.json();
return { providerEventId: created.id };
}
async updateEvent(params: { accessToken: string; calendarId: string; providerEventId: string; event: CalendarEventInput }): Promise<void> {
await googleFetch(
`https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(params.calendarId)}/events/${encodeURIComponent(params.providerEventId)}`,
{
method: "PUT",
headers: { Authorization: `Bearer ${params.accessToken}`, "Content-Type": "application/json" },
body: JSON.stringify(toGoogleEventBody(params.event)),
},
);
}
async deleteEvent(params: { accessToken: string; calendarId: string; providerEventId: string }): Promise<void> {
try {
await googleFetch(
`https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(params.calendarId)}/events/${encodeURIComponent(params.providerEventId)}`,
{ method: "DELETE", headers: { Authorization: `Bearer ${params.accessToken}` } },
);
} catch (error) {
// Already deleted on the Google side (e.g. the owner removed it
// manually) — not a failure from this app's point of view.
if (error instanceof CalendarProviderError && error.code === "CALENDAR_NOT_FOUND") return;
throw error;
}
}
async revoke(refreshToken: string): Promise<void> {
try {
await fetch(`https://oauth2.googleapis.com/revoke?token=${encodeURIComponent(refreshToken)}`, { method: "POST" });
} catch {
// Best-effort — see the brief's "Revoke credentials where possible." A failure here doesn't block local disconnect.
}
}
}
@@ -0,0 +1,57 @@
import type { CalendarProvider } from "./calendar-provider";
import type { CalendarEventInput, ExchangedTokens, RefreshedAccessToken, RemoteCalendarInfo } from "./types";
/**
* In-memory `CalendarProvider` for tests — never makes a real network
* call, per the brief's "Do not call real Google Calendar APIs in
* automated tests." Records every create/update/delete so a test can
* assert on sync behavior (idempotency, retry, error mapping) without a
* live Google account.
*/
export class MockCalendarProvider implements CalendarProvider {
name = "mock";
events = new Map<string, CalendarEventInput>();
private nextEventId = 1;
public calendars: RemoteCalendarInfo[] = [{ id: "primary", name: "Primary Calendar", writable: true, primary: true }];
buildAuthorizationUrl(): string {
return "https://mock.local/authorize";
}
async exchangeCodeForTokens(): Promise<ExchangedTokens> {
return {
accessToken: "mock-access-token",
refreshToken: "mock-refresh-token",
expiresAt: new Date(Date.now() + 3600_000),
externalAccountId: "mock-account-id",
accountEmail: "mock@example.com",
};
}
async refreshAccessToken(): Promise<RefreshedAccessToken> {
return { accessToken: "mock-access-token-refreshed", expiresAt: new Date(Date.now() + 3600_000) };
}
async listWritableCalendars(): Promise<RemoteCalendarInfo[]> {
return this.calendars;
}
async createEvent(params: { event: CalendarEventInput }): Promise<{ providerEventId: string }> {
const id = `mock-event-${this.nextEventId++}`;
this.events.set(id, params.event);
return { providerEventId: id };
}
async updateEvent(params: { providerEventId: string; event: CalendarEventInput }): Promise<void> {
this.events.set(params.providerEventId, params.event);
}
async deleteEvent(params: { providerEventId: string }): Promise<void> {
this.events.delete(params.providerEventId);
}
async revoke(): Promise<void> {
// No-op — nothing to revoke in-memory.
}
}
+61
View File
@@ -0,0 +1,61 @@
/**
* Provider-neutral shapes for one-way (business → calendar) appointment
* sync — see calendar-provider.ts for the interface these compose, and
* docs/phase-8-summary.md for the overall architecture.
*/
export interface CalendarEventInput {
summary: string;
description: string;
location?: string;
startAt: Date;
endAt: Date;
}
export interface RemoteCalendarInfo {
id: string;
name: string;
/** Only calendars the connected account can write to are ever offered — see the brief's "Do not allow read-only calendars." */
writable: boolean;
primary: boolean;
}
export interface ExchangedTokens {
accessToken: string;
refreshToken: string;
/** Absent when the provider didn't return one (e.g. a token refresh call, which doesn't reissue a refresh token). */
expiresAt: Date;
externalAccountId: string;
accountEmail: string;
}
export interface RefreshedAccessToken {
accessToken: string;
expiresAt: Date;
}
export type CalendarProviderErrorCode =
| "CONSENT_DENIED"
| "TOKEN_EXPIRED"
| "TOKEN_REVOKED"
| "MISSING_REFRESH_TOKEN"
| "CALENDAR_NOT_FOUND"
| "CALENDAR_NOT_WRITABLE"
| "RATE_LIMITED"
| "PROVIDER_OUTAGE"
| "INVALID_EVENT"
| "TIMEOUT"
| "UNKNOWN";
/** Never exposes a raw provider response — see the brief's "Do not expose raw provider responses." */
export class CalendarProviderError extends Error {
code: CalendarProviderErrorCode;
retryable: boolean;
constructor(code: CalendarProviderErrorCode, message: string, retryable: boolean) {
super(message);
this.name = "CalendarProviderError";
this.code = code;
this.retryable = retryable;
}
}
@@ -0,0 +1,153 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
createCategory,
deleteCategory,
findServiceUsageConflicts,
updateService,
} from "../service-catalogue.service";
import {
cleanupTestBusiness,
createTestBusiness,
createTestCustomer,
createTestStaff,
prisma,
} from "@/services/scheduling/__tests__/fixtures";
import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures";
describe("ServiceCatalogueService (integration)", () => {
let business: TestBusiness;
beforeEach(async () => {
business = await createTestBusiness();
});
afterEach(async () => {
await cleanupTestBusiness(business);
});
describe("categories", () => {
it("blocks deleting a category that still has services", async () => {
// business.categoryId already owns the fixture haircut/beard-trim services.
const result = await deleteCategory(prisma, { categoryId: business.categoryId, businessId: business.businessId });
expect(result.success).toBe(false);
if (result.success) throw new Error("unreachable");
expect(result.validation.issues[0]?.code).toBe("CATEGORY_HAS_SERVICES");
const stillExists = await prisma.serviceCategory.findUnique({ where: { id: business.categoryId } });
expect(stillExists).not.toBeNull();
});
it("allows deleting an empty category", async () => {
const created = await createCategory(prisma, { businessId: business.businessId, input: { name: "Empty" } });
if (!created.success) throw new Error("setup failed");
const result = await deleteCategory(prisma, { categoryId: created.category.id, businessId: business.businessId });
expect(result.success).toBe(true);
const gone = await prisma.serviceCategory.findUnique({ where: { id: created.category.id } });
expect(gone).toBeNull();
});
it("scopes deletion to the given business — a category from a different business is not found", async () => {
const otherBusiness = await createTestBusiness();
try {
const result = await deleteCategory(prisma, {
categoryId: otherBusiness.categoryId,
businessId: business.businessId,
});
expect(result.success).toBe(false);
if (result.success) throw new Error("unreachable");
expect(result.validation.issues[0]?.code).toBe("CATEGORY_NOT_FOUND");
} finally {
await cleanupTestBusiness(otherBusiness);
}
});
});
describe("services", () => {
it("editing a service's price never rewrites a past appointment's priceAtBooking snapshot", async () => {
const { staffId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
const pastAppointment = await prisma.appointment.create({
data: {
businessId: business.businessId,
staffId,
customerId,
status: "COMPLETED",
startAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000),
endAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000 + 30 * 60_000),
services: {
create: [{ serviceId: business.haircutServiceId, priceAtBooking: 20, durationAtBookingMinutes: 30 }],
},
},
include: { services: true },
});
const updated = await updateService(prisma, {
serviceId: business.haircutServiceId,
businessId: business.businessId,
input: {
categoryId: business.categoryId,
name: "Haircut",
durationMinutes: 45,
price: "35.00",
bufferBeforeMinutes: 0,
bufferAfterMinutes: 0,
allowOnlineBooking: true,
},
});
expect(updated.success).toBe(true);
const snapshot = await prisma.appointmentService.findUniqueOrThrow({
where: { id: pastAppointment.services[0]!.id },
});
expect(Number(snapshot.priceAtBooking)).toBe(20);
expect(snapshot.durationAtBookingMinutes).toBe(30);
const liveService = await prisma.service.findUniqueOrThrow({ where: { id: business.haircutServiceId } });
expect(Number(liveService.price)).toBe(35);
expect(liveService.durationMinutes).toBe(45);
});
it("findServiceUsageConflicts finds only future, non-cancelled appointments booking that service", async () => {
const { staffId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
const future = await prisma.appointment.create({
data: {
businessId: business.businessId,
staffId,
customerId,
status: "CONFIRMED",
startAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
endAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000 + 30 * 60_000),
services: {
create: [{ serviceId: business.haircutServiceId, priceAtBooking: 20, durationAtBookingMinutes: 30 }],
},
},
});
// Cancelled — must not appear in conflicts.
await prisma.appointment.create({
data: {
businessId: business.businessId,
staffId,
customerId,
status: "CANCELLED",
startAt: new Date(Date.now() + 8 * 24 * 60 * 60 * 1000),
endAt: new Date(Date.now() + 8 * 24 * 60 * 60 * 1000 + 30 * 60_000),
services: {
create: [{ serviceId: business.haircutServiceId, priceAtBooking: 20, durationAtBookingMinutes: 30 }],
},
},
});
const conflicts = await findServiceUsageConflicts(prisma, {
serviceId: business.haircutServiceId,
businessId: business.businessId,
});
expect(conflicts.map((c) => c.id)).toEqual([future.id]);
});
});
});
@@ -0,0 +1,70 @@
import { describe, expect, it } from "vitest";
import { validateCategoryInput, validateServiceInput } from "../service-catalogue.service";
describe("validateCategoryInput", () => {
it("accepts a valid category name", () => {
expect(validateCategoryInput({ name: "Beard Services" }).valid).toBe(true);
});
it("rejects an empty/whitespace-only name", () => {
const result = validateCategoryInput({ name: " " });
expect(result.valid).toBe(false);
expect(result.issues[0]?.code).toBe("INVALID_INPUT");
});
});
describe("validateServiceInput", () => {
const base = {
categoryId: "cat-1",
name: "Haircut & Style",
durationMinutes: 30,
price: "22.50",
bufferBeforeMinutes: 0,
bufferAfterMinutes: 5,
allowOnlineBooking: true,
};
it("accepts a fully valid service", () => {
expect(validateServiceInput(base).valid).toBe(true);
});
it("rejects an empty name", () => {
const result = validateServiceInput({ ...base, name: "" });
expect(result.valid).toBe(false);
expect(result.issues[0]?.code).toBe("INVALID_INPUT");
});
it("rejects a missing category", () => {
const result = validateServiceInput({ ...base, categoryId: "" });
expect(result.valid).toBe(false);
expect(result.issues[0]?.code).toBe("INVALID_INPUT");
});
it("rejects a zero or negative duration", () => {
const result = validateServiceInput({ ...base, durationMinutes: 0 });
expect(result.valid).toBe(false);
expect(result.issues[0]?.code).toBe("INVALID_DURATION");
});
it("rejects a non-integer duration", () => {
const result = validateServiceInput({ ...base, durationMinutes: 30.5 });
expect(result.valid).toBe(false);
expect(result.issues[0]?.code).toBe("INVALID_DURATION");
});
it.each(["free", "-5.00", "5.999", "5,00", ""])("rejects an invalid price %s", (price) => {
const result = validateServiceInput({ ...base, price });
expect(result.valid).toBe(false);
expect(result.issues[0]?.code).toBe("INVALID_PRICE");
});
it.each(["0", "22", "22.5", "22.50"])("accepts a valid decimal price %s", (price) => {
expect(validateServiceInput({ ...base, price }).valid).toBe(true);
});
it("rejects a negative buffer", () => {
const result = validateServiceInput({ ...base, bufferBeforeMinutes: -1 });
expect(result.valid).toBe(false);
expect(result.issues[0]?.code).toBe("INVALID_INPUT");
});
});
@@ -0,0 +1,328 @@
import type { PrismaClient } from "@/generated/prisma/client";
import { fail, ok } from "@/lib/validation-result";
import type { ValidationResult } from "@/lib/validation-result";
/**
* Service categories and services — the owner-facing catalogue. Staff
* eligibility (which barber can perform which service) stays in
* StaffService (see docs/phase-6-summary.md, "Domain services") since it's
* staff-scoped data, not catalogue data; this file only owns
* ServiceCategory/Service themselves.
*
* Money is handled as strings end to end — a price never passes through a
* JS `number` on its way into Prisma's `Decimal` column, so there's no
* floating-point rounding step to get wrong.
*/
export type ServiceCatalogueErrorCode =
| "INVALID_INPUT"
| "INVALID_PRICE"
| "INVALID_DURATION"
| "CATEGORY_NOT_FOUND"
| "CATEGORY_HAS_SERVICES"
| "SERVICE_NOT_FOUND"
| "APPOINTMENT_CONFLICTS";
const DECIMAL_PRICE = /^\d+(\.\d{1,2})?$/;
// ── Categories ──────────────────────────────────────────────────────────
export interface CategoryInput {
name: string;
description?: string;
}
export function validateCategoryInput(input: CategoryInput): ValidationResult<ServiceCatalogueErrorCode> {
if (!input.name.trim()) return fail({ code: "INVALID_INPUT", message: "Category name is required." });
return ok();
}
export async function listCategories(prisma: PrismaClient, businessId: string) {
return prisma.serviceCategory.findMany({
where: { businessId },
orderBy: { displayOrder: "asc" },
include: { services: { orderBy: { displayOrder: "asc" } } },
});
}
export async function createCategory(
prisma: PrismaClient,
params: { businessId: string; input: CategoryInput },
) {
const validation = validateCategoryInput(params.input);
if (!validation.valid) return { success: false as const, validation };
const maxOrder = await prisma.serviceCategory.aggregate({
where: { businessId: params.businessId },
_max: { displayOrder: true },
});
const category = await prisma.serviceCategory.create({
data: {
businessId: params.businessId,
name: params.input.name.trim(),
displayOrder: (maxOrder._max.displayOrder ?? -1) + 1,
},
});
return { success: true as const, category, validation: ok() };
}
export async function updateCategory(
prisma: PrismaClient,
params: { categoryId: string; businessId: string; input: CategoryInput },
) {
const validation = validateCategoryInput(params.input);
if (!validation.valid) return { success: false as const, validation };
const existing = await prisma.serviceCategory.findFirst({
where: { id: params.categoryId, businessId: params.businessId },
});
if (!existing) {
return {
success: false as const,
validation: fail({ code: "CATEGORY_NOT_FOUND", message: "Category not found." }),
};
}
await prisma.serviceCategory.update({
where: { id: params.categoryId },
data: { name: params.input.name.trim() },
});
return { success: true as const, validation: ok() };
}
export async function setCategoryActive(
prisma: PrismaClient,
params: { categoryId: string; businessId: string; isActive: boolean },
): Promise<{ success: boolean }> {
const result = await prisma.serviceCategory.updateMany({
where: { id: params.categoryId, businessId: params.businessId },
data: { isActive: params.isActive },
});
return { success: result.count > 0 };
}
export async function reorderCategories(
prisma: PrismaClient,
params: { businessId: string; orderedCategoryIds: string[] },
): Promise<{ success: boolean }> {
await prisma.$transaction(
params.orderedCategoryIds.map((id, index) =>
prisma.serviceCategory.updateMany({
where: { id, businessId: params.businessId },
data: { displayOrder: index },
}),
),
);
return { success: true };
}
/** Categories with services (active or not) can't be deleted outright — deactivate instead, per the brief's "prefer deactivation over destructive deletion where historical data depends on it." */
export async function deleteCategory(
prisma: PrismaClient,
params: { categoryId: string; businessId: string },
) {
const existing = await prisma.serviceCategory.findFirst({
where: { id: params.categoryId, businessId: params.businessId },
include: { _count: { select: { services: true } } },
});
if (!existing) {
return {
success: false as const,
validation: fail({ code: "CATEGORY_NOT_FOUND", message: "Category not found." }),
};
}
if (existing._count.services > 0) {
return {
success: false as const,
validation: fail({
code: "CATEGORY_HAS_SERVICES",
message: "This category still has services — move or remove them first, or deactivate the category instead.",
}),
};
}
await prisma.serviceCategory.delete({ where: { id: params.categoryId } });
return { success: true as const, validation: ok() };
}
// ── Services ────────────────────────────────────────────────────────────
export interface ServiceInput {
categoryId: string;
name: string;
description?: string;
/** Set via the Media Manager — see src/features/media/components/media-picker.tsx. */
imageUrl?: string;
durationMinutes: number;
/** Decimal string, e.g. "22.50" — never a JS number, see file header. */
price: string;
bufferBeforeMinutes: number;
bufferAfterMinutes: number;
allowOnlineBooking: boolean;
}
export function validateServiceInput(input: ServiceInput): ValidationResult<ServiceCatalogueErrorCode> {
if (!input.name.trim()) return fail({ code: "INVALID_INPUT", message: "Service name is required." });
if (!input.categoryId) return fail({ code: "INVALID_INPUT", message: "Choose a category." });
if (!Number.isInteger(input.durationMinutes) || input.durationMinutes <= 0) {
return fail({ code: "INVALID_DURATION", message: "Duration must be a positive number of minutes." });
}
if (!DECIMAL_PRICE.test(input.price)) {
return fail({ code: "INVALID_PRICE", message: "Enter a price like 22.50, with at most two decimal places." });
}
if (
!Number.isInteger(input.bufferBeforeMinutes) ||
!Number.isInteger(input.bufferAfterMinutes) ||
input.bufferBeforeMinutes < 0 ||
input.bufferAfterMinutes < 0
) {
return fail({ code: "INVALID_INPUT", message: "Buffers must be zero or a positive number of minutes." });
}
return ok();
}
export async function listServices(
prisma: PrismaClient,
params: { businessId: string; categoryId?: string; q?: string },
) {
return prisma.service.findMany({
where: {
businessId: params.businessId,
...(params.categoryId ? { categoryId: params.categoryId } : {}),
// MySQL's default collation is already case-insensitive — see staff.service.ts's
// listStaffForBusiness for the full note on why `mode: "insensitive"` was removed.
...(params.q ? { name: { contains: params.q } } : {}),
},
include: { category: true },
orderBy: [{ categoryId: "asc" }, { displayOrder: "asc" }],
});
}
export async function createService(
prisma: PrismaClient,
params: { businessId: string; input: ServiceInput },
) {
const validation = validateServiceInput(params.input);
if (!validation.valid) return { success: false as const, validation };
const category = await prisma.serviceCategory.findFirst({
where: { id: params.input.categoryId, businessId: params.businessId },
});
if (!category) {
return {
success: false as const,
validation: fail({ code: "CATEGORY_NOT_FOUND", message: "Category not found." }),
};
}
const maxOrder = await prisma.service.aggregate({
where: { businessId: params.businessId, categoryId: params.input.categoryId },
_max: { displayOrder: true },
});
const service = await prisma.service.create({
data: {
businessId: params.businessId,
categoryId: params.input.categoryId,
name: params.input.name.trim(),
description: params.input.description?.trim() || null,
imageUrl: params.input.imageUrl?.trim() || null,
durationMinutes: params.input.durationMinutes,
price: params.input.price,
bufferBeforeMinutes: params.input.bufferBeforeMinutes,
bufferAfterMinutes: params.input.bufferAfterMinutes,
allowOnlineBooking: params.input.allowOnlineBooking,
displayOrder: (maxOrder._max.displayOrder ?? -1) + 1,
},
});
return { success: true as const, service, validation: ok() };
}
const APPOINTMENT_CONFLICT_INCLUDE = {
customer: true,
staff: { include: { user: { select: { name: true } } } },
} as const;
/** Future, non-cancelled appointments that booked this exact service — informational for an edit/deactivation confirmation, never auto-resolved. Historical AppointmentService rows (priceAtBooking/durationAtBookingMinutes) are untouched regardless. */
export async function findServiceUsageConflicts(
prisma: PrismaClient,
params: { serviceId: string; businessId: string },
) {
return prisma.appointment.findMany({
where: {
businessId: params.businessId,
status: { notIn: ["CANCELLED", "NO_SHOW", "COMPLETED"] },
startAt: { gte: new Date() },
services: { some: { serviceId: params.serviceId } },
},
include: APPOINTMENT_CONFLICT_INCLUDE,
orderBy: { startAt: "asc" },
});
}
export async function updateService(
prisma: PrismaClient,
params: { serviceId: string; businessId: string; input: ServiceInput },
) {
const validation = validateServiceInput(params.input);
if (!validation.valid) return { success: false as const, validation };
const existing = await prisma.service.findFirst({
where: { id: params.serviceId, businessId: params.businessId },
});
if (!existing) {
return {
success: false as const,
validation: fail({ code: "SERVICE_NOT_FOUND", message: "Service not found." }),
};
}
// Price/duration snapshots on past AppointmentService rows are never
// rewritten — this only ever changes the live Service row future
// bookings will read at booking time. See docs/phase-6-summary.md.
await prisma.service.update({
where: { id: params.serviceId },
data: {
categoryId: params.input.categoryId,
name: params.input.name.trim(),
description: params.input.description?.trim() || null,
imageUrl: params.input.imageUrl?.trim() || null,
durationMinutes: params.input.durationMinutes,
price: params.input.price,
bufferBeforeMinutes: params.input.bufferBeforeMinutes,
bufferAfterMinutes: params.input.bufferAfterMinutes,
allowOnlineBooking: params.input.allowOnlineBooking,
},
});
return { success: true as const, validation: ok() };
}
export async function setServiceActive(
prisma: PrismaClient,
params: { serviceId: string; businessId: string; isActive: boolean },
): Promise<{ success: boolean }> {
const result = await prisma.service.updateMany({
where: { id: params.serviceId, businessId: params.businessId },
data: { isActive: params.isActive },
});
return { success: result.count > 0 };
}
export async function reorderServices(
prisma: PrismaClient,
params: { businessId: string; categoryId: string; orderedServiceIds: string[] },
): Promise<{ success: boolean }> {
await prisma.$transaction(
params.orderedServiceIds.map((id, index) =>
prisma.service.updateMany({
where: { id, businessId: params.businessId, categoryId: params.categoryId },
data: { displayOrder: index },
}),
),
);
return { success: true };
}
@@ -0,0 +1,144 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { setCustomerAvatar, updateCustomerProfile, updatePreferredBarber } from "@/services/customer/customer.service";
import {
cleanupTestBusiness,
createTestBusiness,
createTestCustomer,
createTestStaff,
prisma,
} from "@/services/scheduling/__tests__/fixtures";
import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures";
describe("customer.service (integration)", () => {
let business: TestBusiness;
beforeEach(async () => {
business = await createTestBusiness();
});
afterEach(async () => {
await cleanupTestBusiness(business);
});
describe("updateCustomerProfile", () => {
it("updates both User and CustomerProfile", async () => {
const { userId } = await createTestCustomer(business);
// Distinct from the phone number, not just the email — User.phone has
// its own unique constraint, so a hardcoded literal here would
// collide with any other row (seed data, another test run) that
// happens to use the same number.
const phone = `+44 7700 ${Math.floor(100000 + Math.random() * 900000)}`;
const result = await updateCustomerProfile(prisma, {
userId,
firstName: "Jamie",
lastName: "Fraser",
email: `jamie-${userId}@test.local`,
phone,
});
expect(result.success).toBe(true);
const user = await prisma.user.findUniqueOrThrow({ where: { id: userId } });
expect(user.name).toBe("Jamie Fraser");
expect(user.email).toBe(`jamie-${userId}@test.local`);
expect(user.phone).toBe(phone);
const profile = await prisma.customerProfile.findUniqueOrThrow({ where: { userId } });
expect(profile.firstName).toBe("Jamie");
expect(profile.lastName).toBe("Fraser");
expect(profile.phone).toBe(phone);
});
it("creates a CustomerProfile via upsert for a user who has never booked", async () => {
const user = await prisma.user.create({ data: { email: `never-booked-${Date.now()}@test.local`, name: "New User" } });
business.createdUserIds.push(user.id);
const result = await updateCustomerProfile(prisma, {
userId: user.id,
firstName: "New",
lastName: "User",
email: user.email!,
phone: "",
});
expect(result.success).toBe(true);
const profile = await prisma.customerProfile.findUniqueOrThrow({ where: { userId: user.id } });
expect(profile.firstName).toBe("New");
expect(profile.isGuest).toBe(false);
});
it("rejects an email already used by another user", async () => {
const { userId } = await createTestCustomer(business);
const other = await createTestCustomer(business);
const otherUser = await prisma.user.findUniqueOrThrow({ where: { id: other.userId } });
const result = await updateCustomerProfile(prisma, {
userId,
firstName: "Jamie",
lastName: "Fraser",
email: otherUser.email!,
phone: "",
});
expect(result.success).toBe(false);
expect(result.field).toBe("email");
});
});
describe("setCustomerAvatar", () => {
it("sets User.image and returns the previous value", async () => {
const { userId } = await createTestCustomer(business);
const first = await setCustomerAvatar(prisma, { userId, url: "/api/media/customer-avatars/a.jpg" });
expect(first.previousUrl).toBeNull();
let user = await prisma.user.findUniqueOrThrow({ where: { id: userId } });
expect(user.image).toBe("/api/media/customer-avatars/a.jpg");
const second = await setCustomerAvatar(prisma, { userId, url: "/api/media/customer-avatars/b.jpg" });
expect(second.previousUrl).toBe("/api/media/customer-avatars/a.jpg");
user = await prisma.user.findUniqueOrThrow({ where: { id: userId } });
expect(user.image).toBe("/api/media/customer-avatars/b.jpg");
const cleared = await setCustomerAvatar(prisma, { userId, url: null });
expect(cleared.previousUrl).toBe("/api/media/customer-avatars/b.jpg");
user = await prisma.user.findUniqueOrThrow({ where: { id: userId } });
expect(user.image).toBeNull();
});
});
describe("updatePreferredBarber", () => {
it("sets and clears a preferred barber on an existing CustomerProfile", async () => {
const { userId } = await createTestCustomer(business);
const { staffId } = await createTestStaff(business);
const set = await updatePreferredBarber(prisma, { userId, staffId });
expect(set.success).toBe(true);
let profile = await prisma.customerProfile.findUniqueOrThrow({ where: { userId } });
expect(profile.preferredBarberId).toBe(staffId);
const cleared = await updatePreferredBarber(prisma, { userId, staffId: null });
expect(cleared.success).toBe(true);
profile = await prisma.customerProfile.findUniqueOrThrow({ where: { userId } });
expect(profile.preferredBarberId).toBeNull();
});
it("creates a CustomerProfile from session User data when none exists yet", async () => {
const user = await prisma.user.create({ data: { email: `no-profile-${Date.now()}@test.local`, name: "Casey Rivers" } });
business.createdUserIds.push(user.id);
const { staffId } = await createTestStaff(business);
const result = await updatePreferredBarber(prisma, { userId: user.id, staffId });
expect(result.success).toBe(true);
const profile = await prisma.customerProfile.findUniqueOrThrow({ where: { userId: user.id } });
expect(profile.firstName).toBe("Casey");
expect(profile.lastName).toBe("Rivers");
expect(profile.preferredBarberId).toBe(staffId);
});
it("rejects a staffId that doesn't exist", async () => {
const { userId } = await createTestCustomer(business);
const result = await updatePreferredBarber(prisma, { userId, staffId: "not-a-real-staff-id" });
expect(result.success).toBe(false);
});
});
});
+268
View File
@@ -0,0 +1,268 @@
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 };
}
/**
* Staff-directory search (src/app/(staff)/staff/clients). Deliberately a
* plain in-memory filter, not a Prisma `contains`/`mode: "insensitive"`
* query — that Prisma option is Postgres/Mongo-only (same portability
* reasoning as resolveGuestCustomer's email matching above), and a
* single-business shop's customer list is small enough that filtering
* application-side is simpler than a portable case-insensitive SQL query.
*/
export function normalizeSearchQuery(query: string): string {
return query.trim().toLowerCase();
}
export function matchesCustomerSearch(
customer: { firstName: string; lastName: string; email: string; phone: string | null },
query: string,
): boolean {
const normalized = normalizeSearchQuery(query);
if (!normalized) return true;
return (
`${customer.firstName} ${customer.lastName}`.toLowerCase().includes(normalized) ||
customer.email.toLowerCase().includes(normalized) ||
(customer.phone ?? "").toLowerCase().includes(normalized)
);
}
export interface UpdateCustomerProfileParams {
userId: string;
firstName: string;
lastName: string;
email: string;
phone: string;
}
export interface UpdateCustomerProfileResult {
success: boolean;
error?: string;
/** Set only for a validation failure the caller should surface on a specific field (currently just a taken email). */
field?: "email";
}
/**
* A signed-in customer editing their own name/email/phone — see
* features/customer/actions/profile.ts, the only caller. Writes both User
* (the actual auth/session record, same split staff profile editing uses
* in staff.service.ts's updateStaffProfile) and CustomerProfile (the
* denormalized firstName/lastName/email/phone copy read everywhere else —
* see this file's own header on why the two can drift). A customer who has
* never booked yet has no CustomerProfile row at all, so this upserts
* rather than assuming one exists. Photo is handled separately by
* setCustomerAvatar below — it uploads/deletes immediately rather than
* waiting for this form's Save.
*/
export async function updateCustomerProfile(
prisma: PrismaClient,
params: UpdateCustomerProfileParams,
): Promise<UpdateCustomerProfileResult> {
const emailTaken = await prisma.user.findUnique({ where: { email: params.email }, select: { id: true } });
if (emailTaken && emailTaken.id !== params.userId) {
return { success: false, error: "That email is already in use.", field: "email" };
}
try {
await prisma.$transaction([
prisma.user.update({
where: { id: params.userId },
data: {
name: `${params.firstName} ${params.lastName}`.trim(),
email: params.email,
phone: params.phone || null,
},
}),
prisma.customerProfile.upsert({
where: { userId: params.userId },
create: {
userId: params.userId,
firstName: params.firstName,
lastName: params.lastName,
email: params.email,
phone: params.phone || null,
isGuest: false,
},
update: { firstName: params.firstName, lastName: params.lastName, email: params.email, phone: params.phone || null },
}),
]);
} catch {
return { success: false, error: "Couldn't save your profile." };
}
return { success: true };
}
/**
* Directory customer avatar uploads are stored under — deliberately never
* inserted as a MediaAsset row (see features/customer/actions/profile.ts's
* uploadCustomerAvatarAction), so these files never show up in the staff/
* owner Media Manager library. Exported so the "is this old image one of
* ours to delete" check in that action uses the exact same value.
*/
export const CUSTOMER_AVATAR_CATEGORY = "customer-avatars";
/** Swaps User.image, returning the previous value so the caller (uploadCustomerAvatarAction/removeCustomerAvatarAction) can delete the old file from disk after the DB write succeeds. */
export async function setCustomerAvatar(
prisma: PrismaClient,
params: { userId: string; url: string | null },
): Promise<{ previousUrl: string | null }> {
const previous = await prisma.user.findUniqueOrThrow({ where: { id: params.userId }, select: { image: true } });
await prisma.user.update({ where: { id: params.userId }, data: { image: params.url } });
return { previousUrl: previous.image };
}
/** Sets or clears (`staffId: null`) the customer's preferred barber — see Home's "Preferred Barber" display, the only other place this is read. */
export async function updatePreferredBarber(
prisma: PrismaClient,
params: { userId: string; staffId: string | null },
): Promise<{ success: boolean; error?: string }> {
if (params.staffId) {
const staff = await prisma.staffProfile.findUnique({ where: { id: params.staffId }, select: { id: true } });
if (!staff) return { success: false, error: "That barber couldn't be found." };
}
const existing = await prisma.customerProfile.findUnique({ where: { userId: params.userId } });
if (existing) {
await prisma.customerProfile.update({ where: { userId: params.userId }, data: { preferredBarberId: params.staffId } });
return { success: true };
}
const user = await prisma.user.findUniqueOrThrow({ where: { id: params.userId } });
const [firstName, ...rest] = (user.name ?? "").trim().split(/\s+/).filter(Boolean);
await prisma.customerProfile.create({
data: {
userId: params.userId,
firstName: firstName || "Customer",
lastName: rest.join(" "),
email: user.email ?? "",
phone: user.phone ?? undefined,
preferredBarberId: params.staffId,
isGuest: false,
},
});
return { success: true };
}
@@ -0,0 +1,86 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { GalleryPlacement } from "@/generated/prisma/enums";
import {
addGalleryImage,
deleteGalleryImage,
listGalleryImages,
reorderGalleryImages,
updateGalleryImage,
} from "@/services/gallery/gallery.service";
import { cleanupTestBusiness, createTestBusiness, prisma } from "@/services/scheduling/__tests__/fixtures";
import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures";
describe("Gallery service (integration)", () => {
let business: TestBusiness;
beforeEach(async () => {
business = await createTestBusiness();
});
afterEach(async () => {
await cleanupTestBusiness(business);
});
it("keeps HERO and PORTFOLIO images in separate lists for the same business", async () => {
await addGalleryImage(prisma, { businessId: business.businessId, placement: GalleryPlacement.HERO, imageUrl: "/media/gallery/hero-1.png" });
await addGalleryImage(prisma, { businessId: business.businessId, placement: GalleryPlacement.PORTFOLIO, imageUrl: "/media/gallery/portfolio-1.png", category: "Fades" });
const hero = await listGalleryImages(prisma, { businessId: business.businessId, placement: GalleryPlacement.HERO });
const portfolio = await listGalleryImages(prisma, { businessId: business.businessId, placement: GalleryPlacement.PORTFOLIO });
expect(hero).toHaveLength(1);
expect(portfolio).toHaveLength(1);
expect(portfolio[0]?.category).toBe("Fades");
});
it("assigns increasing displayOrder on add, and reorder fully replaces it", async () => {
const a = await addGalleryImage(prisma, { businessId: business.businessId, placement: GalleryPlacement.PORTFOLIO, imageUrl: "/media/a.png" });
const b = await addGalleryImage(prisma, { businessId: business.businessId, placement: GalleryPlacement.PORTFOLIO, imageUrl: "/media/b.png" });
expect(a.displayOrder).toBe(0);
expect(b.displayOrder).toBe(1);
await reorderGalleryImages(prisma, { businessId: business.businessId, placement: GalleryPlacement.PORTFOLIO, orderedIds: [b.id, a.id] });
const reordered = await listGalleryImages(prisma, { businessId: business.businessId, placement: GalleryPlacement.PORTFOLIO });
expect(reordered.map((i) => i.id)).toEqual([b.id, a.id]);
});
it("updates category/caption/isActive, and activeOnly filtering respects isActive", async () => {
const image = await addGalleryImage(prisma, { businessId: business.businessId, placement: GalleryPlacement.PORTFOLIO, imageUrl: "/media/a.png" });
await updateGalleryImage(prisma, { id: image.id, businessId: business.businessId, category: "Beards", caption: "Client X", isActive: false });
const all = await listGalleryImages(prisma, { businessId: business.businessId, placement: GalleryPlacement.PORTFOLIO });
expect(all[0]?.category).toBe("Beards");
expect(all[0]?.caption).toBe("Client X");
expect(all[0]?.isActive).toBe(false);
const activeOnly = await listGalleryImages(prisma, { businessId: business.businessId, placement: GalleryPlacement.PORTFOLIO, activeOnly: true });
expect(activeOnly).toHaveLength(0);
});
it("never lets one business update or delete another business's gallery image", async () => {
const otherBusiness = await createTestBusiness();
try {
const image = await addGalleryImage(prisma, { businessId: otherBusiness.businessId, placement: GalleryPlacement.PORTFOLIO, imageUrl: "/media/other.png" });
const updateResult = await updateGalleryImage(prisma, { id: image.id, businessId: business.businessId, caption: "hijacked" });
expect(updateResult.success).toBe(false);
const deleteResult = await deleteGalleryImage(prisma, { id: image.id, businessId: business.businessId });
expect(deleteResult.success).toBe(false);
const stillThere = await prisma.businessGalleryImage.findUnique({ where: { id: image.id } });
expect(stillThere).not.toBeNull();
expect(stillThere?.caption).not.toBe("hijacked");
} finally {
await cleanupTestBusiness(otherBusiness);
}
});
it("deletes an image it owns", async () => {
const image = await addGalleryImage(prisma, { businessId: business.businessId, placement: GalleryPlacement.PORTFOLIO, imageUrl: "/media/a.png" });
const result = await deleteGalleryImage(prisma, { id: image.id, businessId: business.businessId });
expect(result.success).toBe(true);
expect(await prisma.businessGalleryImage.findUnique({ where: { id: image.id } })).toBeNull();
});
});
+108
View File
@@ -0,0 +1,108 @@
import type { PrismaClient } from "@/generated/prisma/client";
import { GalleryPlacement } from "@/generated/prisma/enums";
/**
* Owner-managed image galleries — one model/service backs both the Home
* page hero carousel (placement: HERO) and the Portfolio tab (placement:
* PORTFOLIO) rather than two near-duplicate "gallery" features. See
* docs/phase-9-summary.md, "Home Screen Redesign".
*/
export interface GalleryImageSummary {
id: string;
imageUrl: string;
category: string | null;
caption: string | null;
displayOrder: number;
isActive: boolean;
}
export async function listGalleryImages(
prisma: PrismaClient,
params: { businessId: string; placement: GalleryPlacement; activeOnly?: boolean },
): Promise<GalleryImageSummary[]> {
const images = await prisma.businessGalleryImage.findMany({
where: {
businessId: params.businessId,
placement: params.placement,
...(params.activeOnly ? { isActive: true } : {}),
},
orderBy: { displayOrder: "asc" },
});
return images.map((i) => ({
id: i.id,
imageUrl: i.imageUrl,
category: i.category,
caption: i.caption,
displayOrder: i.displayOrder,
isActive: i.isActive,
}));
}
export async function addGalleryImage(
prisma: PrismaClient,
params: { businessId: string; placement: GalleryPlacement; imageUrl: string; mediaAssetId?: string; category?: string },
): Promise<GalleryImageSummary> {
const maxOrder = await prisma.businessGalleryImage.aggregate({
where: { businessId: params.businessId, placement: params.placement },
_max: { displayOrder: true },
});
const image = await prisma.businessGalleryImage.create({
data: {
businessId: params.businessId,
placement: params.placement,
imageUrl: params.imageUrl,
mediaAssetId: params.mediaAssetId,
category: params.category?.trim() || null,
displayOrder: (maxOrder._max.displayOrder ?? -1) + 1,
},
});
return {
id: image.id,
imageUrl: image.imageUrl,
category: image.category,
caption: image.caption,
displayOrder: image.displayOrder,
isActive: image.isActive,
};
}
export async function updateGalleryImage(
prisma: PrismaClient,
params: { id: string; businessId: string; category?: string | null; caption?: string | null; isActive?: boolean },
): Promise<{ success: boolean }> {
const result = await prisma.businessGalleryImage.updateMany({
where: { id: params.id, businessId: params.businessId },
data: {
...(params.category !== undefined ? { category: params.category?.trim() || null } : {}),
...(params.caption !== undefined ? { caption: params.caption?.trim() || null } : {}),
...(params.isActive !== undefined ? { isActive: params.isActive } : {}),
},
});
return { success: result.count > 0 };
}
export async function deleteGalleryImage(
prisma: PrismaClient,
params: { id: string; businessId: string },
): Promise<{ success: boolean }> {
const result = await prisma.businessGalleryImage.deleteMany({ where: { id: params.id, businessId: params.businessId } });
return { success: result.count > 0 };
}
/** Full replacement of display order — same "send the whole ordered id list" pattern as reorderCategories/reorderServices in service-catalogue.service.ts. */
export async function reorderGalleryImages(
prisma: PrismaClient,
params: { businessId: string; placement: GalleryPlacement; orderedIds: string[] },
): Promise<void> {
await prisma.$transaction(
params.orderedIds.map((id, index) =>
prisma.businessGalleryImage.updateMany({
where: { id, businessId: params.businessId, placement: params.placement },
data: { displayOrder: index },
}),
),
);
}
@@ -0,0 +1,87 @@
import { describe, expect, it } from "vitest";
import { LoyaltyCardFont, LoyaltyCardTextSize, RewardPositionMode } from "@/generated/prisma/enums";
import { resolveRewardThreshold, validateProgrammeConfig } from "../loyalty.service";
import type { ProgrammeConfigInput } from "../loyalty.service";
const base: ProgrammeConfigInput = {
isEnabled: true,
stampsRequired: 10,
rewardPosition: RewardPositionMode.AFTER_ALL_STAMPS,
rewardType: "PERCENTAGE",
rewardValue: "50",
autoApplyReward: true,
requiresStaffApproval: false,
earnOnCancelled: false,
earnOnNoShow: false,
cardFont: LoyaltyCardFont.SANS,
cardTitleTextSize: LoyaltyCardTextSize.MD,
};
describe("validateProgrammeConfig", () => {
it("accepts a valid percentage-reward configuration", () => {
expect(validateProgrammeConfig(base).valid).toBe(true);
});
it("rejects a stamps-required of zero", () => {
const result = validateProgrammeConfig({ ...base, stampsRequired: 0 });
expect(result.valid).toBe(false);
expect(result.issues[0]?.code).toBe("INVALID_INPUT");
});
it("allows an incomplete configuration while disabled", () => {
const result = validateProgrammeConfig({ ...base, isEnabled: false, rewardValue: undefined });
expect(result.valid).toBe(true);
});
it.each([0, 101, -5])("rejects an out-of-range percentage when enabled (%s)", (pct) => {
const result = validateProgrammeConfig({ ...base, rewardValue: String(pct) });
expect(result.valid).toBe(false);
expect(result.issues[0]?.code).toBe("INVALID_PERCENTAGE");
});
it("rejects a missing percentage value when enabled", () => {
const result = validateProgrammeConfig({ ...base, rewardValue: undefined });
expect(result.valid).toBe(false);
expect(result.issues[0]?.code).toBe("INVALID_PERCENTAGE");
});
it("accepts a valid fixed-amount reward", () => {
const result = validateProgrammeConfig({ ...base, rewardType: "FIXED_AMOUNT", rewardValue: "10.00" });
expect(result.valid).toBe(true);
});
it("rejects a negative fixed-amount reward", () => {
const result = validateProgrammeConfig({ ...base, rewardType: "FIXED_AMOUNT", rewardValue: "-1" });
expect(result.valid).toBe(false);
expect(result.issues[0]?.code).toBe("INVALID_VALUE");
});
it("accepts a free-service reward with a service chosen", () => {
const result = validateProgrammeConfig({ ...base, rewardType: "FREE_SERVICE", freeServiceId: "svc-1" });
expect(result.valid).toBe(true);
});
it("rejects a free-service reward with no service chosen", () => {
const result = validateProgrammeConfig({ ...base, rewardType: "FREE_SERVICE", freeServiceId: undefined });
expect(result.valid).toBe(false);
expect(result.issues[0]?.code).toBe("FREE_SERVICE_REQUIRED");
});
});
describe("resolveRewardThreshold", () => {
it("AFTER_ALL_STAMPS requires every position filled", () => {
expect(resolveRewardThreshold({ stampsRequired: 10, rewardPosition: RewardPositionMode.AFTER_ALL_STAMPS })).toBe(10);
});
it("FINAL_SLOT_IS_REWARD is earned one stamp early — the last position is the reward, not a stamp to collect", () => {
expect(
resolveRewardThreshold({ stampsRequired: 10, rewardPosition: RewardPositionMode.FINAL_SLOT_IS_REWARD }),
).toBe(9);
});
it("clamps FINAL_SLOT_IS_REWARD at a minimum threshold of 1, even for a single-stamp card", () => {
expect(
resolveRewardThreshold({ stampsRequired: 1, rewardPosition: RewardPositionMode.FINAL_SLOT_IS_REWARD }),
).toBe(1);
});
});
@@ -0,0 +1,148 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { RewardType } from "@/generated/prisma/enums";
import { addStamp, awardStampForCompletedAppointment, redeemReward } from "../loyalty.service";
import { cleanupTestBusiness, createTestBusiness, createTestCustomer, createTestStaff, prisma } from "@/services/scheduling/__tests__/fixtures";
import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures";
/**
* Verifies loyalty.service.ts queues the right Notification rows on
* stamp-add/reward-earn/redemption — see loyalty-notifications.ts. Full
* channel/preference logic is covered elsewhere; this file is "does the
* loyalty workflow call notify() correctly, exactly once per real event".
*/
describe("loyalty notifications (integration)", () => {
let business: TestBusiness;
async function enableLoyalty(stampsRequired = 2) {
return prisma.loyaltyProgramme.create({
data: {
businessId: business.businessId,
isEnabled: true,
stampsRequired,
rewardType: RewardType.PERCENTAGE,
rewardValue: 50,
rewardLabel: "50% Off",
},
});
}
async function completedAppointment(staffId: string, customerId: string) {
return prisma.appointment.create({
data: {
businessId: business.businessId,
staffId,
customerId,
status: "COMPLETED",
startAt: new Date(),
endAt: new Date(Date.now() + 30 * 60_000),
services: { create: [{ serviceId: business.haircutServiceId, priceAtBooking: 20, durationAtBookingMinutes: 30 }] },
},
});
}
beforeEach(async () => {
business = await createTestBusiness();
});
afterEach(async () => {
await prisma.loyaltyTransaction.deleteMany({ where: { loyaltyAccount: { loyaltyProgramme: { businessId: business.businessId } } } }).catch(() => {});
await prisma.loyaltyReward.deleteMany({ where: { loyaltyAccount: { loyaltyProgramme: { businessId: business.businessId } } } }).catch(() => {});
await prisma.loyaltyAccount.deleteMany({ where: { loyaltyProgramme: { businessId: business.businessId } } }).catch(() => {});
await prisma.loyaltyProgramme.deleteMany({ where: { businessId: business.businessId } }).catch(() => {});
await cleanupTestBusiness(business);
});
it("a completed appointment below the reward threshold only notifies LOYALTY_STAMP_ADDED", async () => {
await enableLoyalty(5);
const { staffId } = await createTestStaff(business);
const { customerId, userId } = await createTestCustomer(business);
const appointment = await completedAppointment(staffId, customerId);
const result = await awardStampForCompletedAppointment(prisma, { appointmentId: appointment.id, staffId });
expect(result.success).toBe(true);
expect(result.rewardCreated).toBe(false);
const notifications = await prisma.notification.findMany({ where: { recipientUserId: userId } });
expect(notifications.map((n) => n.type)).toEqual(["LOYALTY_STAMP_ADDED"]);
});
it("crossing the reward threshold notifies both LOYALTY_STAMP_ADDED and LOYALTY_REWARD_EARNED, exactly once each", async () => {
await enableLoyalty(1);
const { staffId } = await createTestStaff(business);
const { customerId, userId } = await createTestCustomer(business);
const appointment = await completedAppointment(staffId, customerId);
const result = await awardStampForCompletedAppointment(prisma, { appointmentId: appointment.id, staffId });
expect(result.rewardCreated).toBe(true);
const notifications = await prisma.notification.findMany({ where: { recipientUserId: userId } });
expect(notifications.map((n) => n.type).sort()).toEqual(["LOYALTY_REWARD_EARNED", "LOYALTY_STAMP_ADDED"]);
const rewardNotification = notifications.find((n) => n.type === "LOYALTY_REWARD_EARNED");
expect(rewardNotification?.loyaltyRewardId).toBeTruthy();
// Calling award again for the same appointment is a no-op (see awardStampForCompletedAppointment's
// own alreadyAwarded check) — no duplicate notifications either.
const again = await awardStampForCompletedAppointment(prisma, { appointmentId: appointment.id, staffId });
expect(again.alreadyAwarded).toBe(true);
const stillJustTwo = await prisma.notification.count({ where: { recipientUserId: userId } });
expect(stillJustTwo).toBe(2);
});
it("a manual addStamp that crosses the threshold also notifies both types", async () => {
await enableLoyalty(3);
const { staffId } = await createTestStaff(business);
const { customerId, userId } = await createTestCustomer(business);
const result = await addStamp(prisma, { customerId, businessId: business.businessId, amount: 3, staffId });
expect(result.rewardCreated).toBe(true);
const notifications = await prisma.notification.findMany({ where: { recipientUserId: userId } });
expect(notifications.map((n) => n.type).sort()).toEqual(["LOYALTY_REWARD_EARNED", "LOYALTY_STAMP_ADDED"]);
});
it("redeeming a reward notifies LOYALTY_REWARD_REDEEMED exactly once, even if redemption is attempted twice", async () => {
await enableLoyalty(1);
const { staffId } = await createTestStaff(business);
const { customerId, userId } = await createTestCustomer(business);
const earningAppointment = await completedAppointment(staffId, customerId);
await awardStampForCompletedAppointment(prisma, { appointmentId: earningAppointment.id, staffId });
const reward = await prisma.loyaltyReward.findFirstOrThrow({
where: { loyaltyAccount: { customerId }, status: "AVAILABLE" },
});
const redemptionAppointment = await prisma.appointment.create({
data: {
businessId: business.businessId,
staffId,
customerId,
status: "CONFIRMED",
startAt: new Date(Date.now() + 86_400_000),
endAt: new Date(Date.now() + 86_400_000 + 30 * 60_000),
services: { create: [{ serviceId: business.haircutServiceId, priceAtBooking: 20, durationAtBookingMinutes: 30 }] },
},
});
const redeemed = await redeemReward(prisma, {
rewardId: reward.id,
appointmentId: redemptionAppointment.id,
staffId,
businessId: business.businessId,
});
expect(redeemed.success).toBe(true);
const redeemedAgain = await redeemReward(prisma, {
rewardId: reward.id,
appointmentId: redemptionAppointment.id,
staffId,
businessId: business.businessId,
});
expect(redeemedAgain.success).toBe(false); // already REDEEMED, not AVAILABLE
const redeemedNotifications = await prisma.notification.findMany({
where: { recipientUserId: userId, type: "LOYALTY_REWARD_REDEEMED" },
});
expect(redeemedNotifications).toHaveLength(1);
expect(redeemedNotifications[0]!.loyaltyRewardId).toBe(reward.id);
});
});
@@ -0,0 +1,345 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { LoyaltyCardFont, LoyaltyCardTextSize, RewardPositionMode } from "@/generated/prisma/enums";
import {
expireOverdueRewards,
redeemReward,
setEligibleServices,
updateProgrammeConfig,
} from "../loyalty.service";
import {
cleanupTestBusiness,
createTestBusiness,
createTestCustomer,
createTestStaff,
prisma,
} from "@/services/scheduling/__tests__/fixtures";
import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures";
describe("LoyaltyService — owner configuration and redemption (integration)", () => {
let business: TestBusiness;
beforeEach(async () => {
business = await createTestBusiness();
});
afterEach(async () => {
await cleanupTestBusiness(business);
});
describe("updateProgrammeConfig", () => {
it("creates the LoyaltyProgramme row on first save and updates it on the next", async () => {
const first = await updateProgrammeConfig(prisma, {
businessId: business.businessId,
input: {
isEnabled: true,
stampsRequired: 8,
rewardPosition: RewardPositionMode.AFTER_ALL_STAMPS,
rewardType: "PERCENTAGE",
rewardValue: "25",
autoApplyReward: true,
requiresStaffApproval: false,
earnOnCancelled: false,
earnOnNoShow: false,
cardFont: LoyaltyCardFont.SANS,
cardTitleTextSize: LoyaltyCardTextSize.MD,
},
});
expect(first.success).toBe(true);
const programme = await prisma.loyaltyProgramme.findUniqueOrThrow({ where: { businessId: business.businessId } });
expect(programme.stampsRequired).toBe(8);
expect(Number(programme.rewardValue)).toBe(25);
const second = await updateProgrammeConfig(prisma, {
businessId: business.businessId,
input: {
isEnabled: true,
stampsRequired: 10, // max allowed — see validateProgrammeConfig's 1-10 range (Phase 10)
rewardPosition: RewardPositionMode.AFTER_ALL_STAMPS,
rewardType: "PERCENTAGE",
rewardValue: "40",
autoApplyReward: true,
requiresStaffApproval: false,
earnOnCancelled: false,
earnOnNoShow: false,
cardFont: LoyaltyCardFont.SANS,
cardTitleTextSize: LoyaltyCardTextSize.MD,
},
});
expect(second.success).toBe(true);
const updated = await prisma.loyaltyProgramme.findUniqueOrThrow({ where: { businessId: business.businessId } });
expect(updated.stampsRequired).toBe(10);
expect(Number(updated.rewardValue)).toBe(40);
});
it("rejects enabling a FREE_SERVICE reward pointing at an inactive service", async () => {
const inactiveService = await prisma.service.update({
where: { id: business.beardTrimServiceId },
data: { isActive: false },
});
const result = await updateProgrammeConfig(prisma, {
businessId: business.businessId,
input: {
isEnabled: true,
stampsRequired: 10,
rewardPosition: RewardPositionMode.AFTER_ALL_STAMPS,
rewardType: "FREE_SERVICE",
freeServiceId: inactiveService.id,
autoApplyReward: true,
requiresStaffApproval: false,
earnOnCancelled: false,
earnOnNoShow: false,
cardFont: LoyaltyCardFont.SANS,
cardTitleTextSize: LoyaltyCardTextSize.MD,
},
});
expect(result.success).toBe(false);
expect(result.validation.issues[0]?.code).toBe("FREE_SERVICE_REQUIRED");
});
});
describe("setEligibleServices", () => {
it("replaces the eligible-services list wholesale", async () => {
const programme = await prisma.loyaltyProgramme.create({
data: { businessId: business.businessId, stampsRequired: 10, rewardType: "PERCENTAGE", rewardValue: 50 },
});
await setEligibleServices(prisma, { loyaltyProgrammeId: programme.id, serviceIds: [business.haircutServiceId] });
let rows = await prisma.loyaltyEligibleService.findMany({ where: { loyaltyProgrammeId: programme.id } });
expect(rows.map((r) => r.serviceId)).toEqual([business.haircutServiceId]);
await setEligibleServices(prisma, { loyaltyProgrammeId: programme.id, serviceIds: [business.beardTrimServiceId] });
rows = await prisma.loyaltyEligibleService.findMany({ where: { loyaltyProgrammeId: programme.id } });
expect(rows.map((r) => r.serviceId)).toEqual([business.beardTrimServiceId]);
});
});
describe("redeemReward", () => {
async function setUpRedeemableReward() {
const programme = await prisma.loyaltyProgramme.create({
data: { businessId: business.businessId, stampsRequired: 10, rewardType: "PERCENTAGE", rewardValue: 50 },
});
const { staffId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
const account = await prisma.loyaltyAccount.create({
data: { customerId, loyaltyProgrammeId: programme.id, currentStamps: 10 },
});
const reward = await prisma.loyaltyReward.create({
data: { loyaltyAccountId: account.id, rewardType: "PERCENTAGE", value: 50, label: "50% Off", status: "AVAILABLE" },
});
const appointment = await prisma.appointment.create({
data: {
businessId: business.businessId,
staffId,
customerId,
status: "CONFIRMED",
startAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
endAt: new Date(Date.now() + 24 * 60 * 60 * 1000 + 30 * 60_000),
services: {
create: [{ serviceId: business.haircutServiceId, priceAtBooking: 20, durationAtBookingMinutes: 30 }],
},
},
});
return { staffId, account, reward, appointment };
}
it("applies the discount, marks the reward REDEEMED, and deducts stamps — all atomically", async () => {
const { staffId, account, reward, appointment } = await setUpRedeemableReward();
const result = await redeemReward(prisma, {
rewardId: reward.id,
appointmentId: appointment.id,
staffId,
businessId: business.businessId,
});
expect(result.success).toBe(true);
expect(result.discountAmount).toBe("10.00"); // 50% of a 20.00 haircut
const updatedReward = await prisma.loyaltyReward.findUniqueOrThrow({ where: { id: reward.id } });
expect(updatedReward.status).toBe("REDEEMED");
expect(updatedReward.redeemedByStaffId).toBe(staffId);
expect(updatedReward.appliedToAppointmentId).toBe(appointment.id);
const updatedAccount = await prisma.loyaltyAccount.findUniqueOrThrow({ where: { id: account.id } });
expect(updatedAccount.currentStamps).toBe(0);
const updatedAppointment = await prisma.appointment.findUniqueOrThrow({ where: { id: appointment.id } });
expect(Number(updatedAppointment.discountAmount)).toBe(10);
});
it("prevents redeeming the same reward twice — the second attempt reports REWARD_NOT_AVAILABLE, not a double deduction", async () => {
const { staffId, account, reward, appointment } = await setUpRedeemableReward();
const first = await redeemReward(prisma, {
rewardId: reward.id,
appointmentId: appointment.id,
staffId,
businessId: business.businessId,
});
expect(first.success).toBe(true);
// A second appointment to apply to, since the first is now discounted.
const { customerId } = await createTestCustomer(business);
void customerId;
const secondAppointment = await prisma.appointment.create({
data: {
businessId: business.businessId,
staffId,
customerId: account.customerId,
status: "CONFIRMED",
startAt: new Date(Date.now() + 2 * 24 * 60 * 60 * 1000),
endAt: new Date(Date.now() + 2 * 24 * 60 * 60 * 1000 + 30 * 60_000),
services: {
create: [{ serviceId: business.haircutServiceId, priceAtBooking: 20, durationAtBookingMinutes: 30 }],
},
},
});
const second = await redeemReward(prisma, {
rewardId: reward.id,
appointmentId: secondAppointment.id,
staffId,
businessId: business.businessId,
});
expect(second.success).toBe(false);
expect(second.validation.issues[0]?.code).toBe("REWARD_NOT_AVAILABLE");
// Stamps weren't deducted a second time.
const account2 = await prisma.loyaltyAccount.findUniqueOrThrow({ where: { id: account.id } });
expect(account2.currentStamps).toBe(0);
// The second appointment was never discounted.
const unaffected = await prisma.appointment.findUniqueOrThrow({ where: { id: secondAppointment.id } });
expect(unaffected.discountAmount).toBeNull();
});
it("rejects applying a reward to an appointment that already has a discount", async () => {
const { staffId, reward, appointment } = await setUpRedeemableReward();
await prisma.appointment.update({ where: { id: appointment.id }, data: { discountAmount: 5 } });
const result = await redeemReward(prisma, {
rewardId: reward.id,
appointmentId: appointment.id,
staffId,
businessId: business.businessId,
});
expect(result.success).toBe(false);
expect(result.validation.issues[0]?.code).toBe("APPOINTMENT_NOT_ELIGIBLE");
});
it("rejects a reward past its own expiresAt even though its stored status still says AVAILABLE", async () => {
// Defense-in-depth against the periodic expiry sweep (expireOverdueRewards)
// not having run yet — see redeemReward's own expiresAt check.
const { staffId, reward, appointment } = await setUpRedeemableReward();
await prisma.loyaltyReward.update({
where: { id: reward.id },
data: { expiresAt: new Date(Date.now() - 60_000) },
});
const result = await redeemReward(prisma, {
rewardId: reward.id,
appointmentId: appointment.id,
staffId,
businessId: business.businessId,
});
expect(result.success).toBe(false);
expect(result.validation.issues[0]?.code).toBe("REWARD_NOT_AVAILABLE");
const unaffected = await prisma.appointment.findUniqueOrThrow({ where: { id: appointment.id } });
expect(unaffected.discountAmount).toBeNull();
});
it("rejects applying a reward to a different customer's appointment", async () => {
const { staffId, reward } = await setUpRedeemableReward();
const { customerId: otherCustomerId } = await createTestCustomer(business);
const otherAppointment = await prisma.appointment.create({
data: {
businessId: business.businessId,
staffId,
customerId: otherCustomerId,
status: "CONFIRMED",
startAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
endAt: new Date(Date.now() + 24 * 60 * 60 * 1000 + 30 * 60_000),
services: {
create: [{ serviceId: business.haircutServiceId, priceAtBooking: 20, durationAtBookingMinutes: 30 }],
},
},
});
const result = await redeemReward(prisma, {
rewardId: reward.id,
appointmentId: otherAppointment.id,
staffId,
businessId: business.businessId,
});
expect(result.success).toBe(false);
expect(result.validation.issues[0]?.code).toBe("REWARD_CUSTOMER_MISMATCH");
});
});
describe("expireOverdueRewards", () => {
async function setUpAccountWithReward(expiresAt: Date | null) {
const programme = await prisma.loyaltyProgramme.upsert({
where: { businessId: business.businessId },
update: {},
create: { businessId: business.businessId, stampsRequired: 10, rewardType: "PERCENTAGE", rewardValue: 50 },
});
const { customerId } = await createTestCustomer(business);
const account = await prisma.loyaltyAccount.create({
data: { customerId, loyaltyProgrammeId: programme.id, currentStamps: 10 },
});
const reward = await prisma.loyaltyReward.create({
data: { loyaltyAccountId: account.id, rewardType: "PERCENTAGE", value: 50, label: "50% Off", status: "AVAILABLE", expiresAt },
});
return { account, reward };
}
it("flips an AVAILABLE reward past its expiresAt to EXPIRED", async () => {
const { reward } = await setUpAccountWithReward(new Date(Date.now() - 60_000));
await expireOverdueRewards(prisma);
const updated = await prisma.loyaltyReward.findUniqueOrThrow({ where: { id: reward.id } });
expect(updated.status).toBe("EXPIRED");
});
it("leaves a reward with no expiry, or one not yet due, untouched", async () => {
const { reward: noExpiry } = await setUpAccountWithReward(null);
const { reward: futureExpiry } = await setUpAccountWithReward(new Date(Date.now() + 30 * 24 * 60 * 60 * 1000));
await expireOverdueRewards(prisma);
expect((await prisma.loyaltyReward.findUniqueOrThrow({ where: { id: noExpiry.id } })).status).toBe("AVAILABLE");
expect((await prisma.loyaltyReward.findUniqueOrThrow({ where: { id: futureExpiry.id } })).status).toBe("AVAILABLE");
});
it("creates a LOYALTY_REWARD_EXPIRING notification for a reward expiring within the next 7 days", async () => {
const { account, reward } = await setUpAccountWithReward(new Date(Date.now() + 3 * 24 * 60 * 60 * 1000));
await expireOverdueRewards(prisma);
const notification = await prisma.notification.findFirst({
where: { type: "LOYALTY_REWARD_EXPIRING", loyaltyRewardId: reward.id },
});
expect(notification).not.toBeNull();
expect(notification?.recipientCustomerId).toBe(account.customerId);
// Still AVAILABLE — only actually-overdue rewards get flipped to EXPIRED.
expect((await prisma.loyaltyReward.findUniqueOrThrow({ where: { id: reward.id } })).status).toBe("AVAILABLE");
});
it("is idempotent — a second run doesn't create a duplicate expiring-soon notification the same day", async () => {
const { reward } = await setUpAccountWithReward(new Date(Date.now() + 3 * 24 * 60 * 60 * 1000));
await expireOverdueRewards(prisma);
await expireOverdueRewards(prisma);
const count = await prisma.notification.count({
where: { type: "LOYALTY_REWARD_EXPIRING", loyaltyRewardId: reward.id },
});
expect(count).toBe(1);
});
});
});
@@ -0,0 +1,301 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { RewardPositionMode, RewardType } from "@/generated/prisma/enums";
import {
addStamp,
awardStampForCompletedAppointment,
correctStamp,
getLoyaltyHistory,
getLoyaltySummary,
redeemReward,
} from "../loyalty.service";
import {
cleanupTestBusiness,
createTestBusiness,
createTestCustomer,
createTestStaff,
prisma,
} from "@/services/scheduling/__tests__/fixtures";
import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures";
/**
* Covers Phase 5's ledger-discipline requirements against a real database:
* no duplicate auto-stamp per completed appointment, no reward stacking,
* eligibility rules, and the correction path's clamp + required reason.
*/
describe("LoyaltyService (integration)", () => {
let business: TestBusiness;
async function enableLoyalty(overrides: { stampsRequired?: number; rewardPosition?: RewardPositionMode } = {}) {
return prisma.loyaltyProgramme.create({
data: {
businessId: business.businessId,
isEnabled: true,
stampsRequired: overrides.stampsRequired ?? 5,
rewardPosition: overrides.rewardPosition ?? RewardPositionMode.AFTER_ALL_STAMPS,
rewardType: RewardType.PERCENTAGE,
rewardValue: 50,
rewardLabel: "50% Off",
},
});
}
async function completedAppointment(staffId: string, customerId: string) {
return prisma.appointment.create({
data: {
businessId: business.businessId,
staffId,
customerId,
status: "COMPLETED",
startAt: new Date(),
endAt: new Date(Date.now() + 30 * 60_000),
services: {
create: [
{
serviceId: business.haircutServiceId,
priceAtBooking: 20,
durationAtBookingMinutes: 30,
},
],
},
},
});
}
beforeEach(async () => {
business = await createTestBusiness();
});
afterEach(async () => {
await prisma.loyaltyTransaction.deleteMany({
where: { loyaltyAccount: { loyaltyProgramme: { businessId: business.businessId } } },
}).catch(() => {});
await prisma.loyaltyReward.deleteMany({
where: { loyaltyAccount: { loyaltyProgramme: { businessId: business.businessId } } },
}).catch(() => {});
await prisma.loyaltyAccount.deleteMany({
where: { loyaltyProgramme: { businessId: business.businessId } },
}).catch(() => {});
await cleanupTestBusiness(business);
});
it("awards exactly one stamp for a completed appointment, even if called twice", async () => {
await enableLoyalty();
const { staffId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
const appointment = await completedAppointment(staffId, customerId);
const first = await awardStampForCompletedAppointment(prisma, {
appointmentId: appointment.id,
staffId,
});
expect(first.success).toBe(true);
expect(first.alreadyAwarded).toBeUndefined();
const second = await awardStampForCompletedAppointment(prisma, {
appointmentId: appointment.id,
staffId,
});
expect(second.success).toBe(true);
expect(second.alreadyAwarded).toBe(true);
const summary = await getLoyaltySummary(prisma, { customerId, businessId: business.businessId });
expect(summary?.currentStamps).toBe(1);
});
it("refuses to award a stamp for a non-completed appointment", async () => {
await enableLoyalty();
const { staffId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
const appointment = await prisma.appointment.create({
data: {
businessId: business.businessId,
staffId,
customerId,
status: "CONFIRMED",
startAt: new Date(),
endAt: new Date(Date.now() + 30 * 60_000),
services: {
create: [{ serviceId: business.haircutServiceId, priceAtBooking: 20, durationAtBookingMinutes: 30 }],
},
},
});
const result = await awardStampForCompletedAppointment(prisma, { appointmentId: appointment.id, staffId });
expect(result.success).toBe(false);
expect(result.validation.issues[0]?.code).toBe("APPOINTMENT_NOT_COMPLETED");
});
it("creates exactly one available reward when the threshold is crossed, never stacking a second", async () => {
const programme = await enableLoyalty({ stampsRequired: 3 });
const { staffId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
const firstAdd = await addStamp(prisma, { customerId, businessId: business.businessId, amount: 3, staffId });
expect(firstAdd.rewardCreated).toBe(true);
const secondAdd = await addStamp(prisma, { customerId, businessId: business.businessId, amount: 2, staffId });
expect(secondAdd.rewardCreated).toBe(false); // already has an AVAILABLE reward — no stacking
const account = await prisma.loyaltyAccount.findUniqueOrThrow({
where: { customerId_loyaltyProgrammeId: { customerId, loyaltyProgrammeId: programme.id } },
});
const availableRewards = await prisma.loyaltyReward.count({
where: { loyaltyAccountId: account.id, status: "AVAILABLE" },
});
expect(availableRewards).toBe(1);
expect(account.currentStamps).toBe(5);
});
describe("reward position modes", () => {
it("FINAL_SLOT_IS_REWARD earns the reward one stamp early via a manual add — the same threshold AFTER_ALL_STAMPS would need one more stamp to cross", async () => {
const programme = await enableLoyalty({ stampsRequired: 3, rewardPosition: RewardPositionMode.FINAL_SLOT_IS_REWARD });
const { staffId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
const shortOfAfterAllStamps = await addStamp(prisma, { customerId, businessId: business.businessId, amount: 2, staffId });
expect(shortOfAfterAllStamps.rewardCreated).toBe(true); // 2 stamps == threshold (stampsRequired - 1)
const account = await prisma.loyaltyAccount.findUniqueOrThrow({
where: { customerId_loyaltyProgrammeId: { customerId, loyaltyProgrammeId: programme.id } },
});
expect(account.currentStamps).toBe(2);
});
it("FINAL_SLOT_IS_REWARD uses the same threshold for the automatic completed-appointment path as for a manual add", async () => {
await enableLoyalty({ stampsRequired: 3, rewardPosition: RewardPositionMode.FINAL_SLOT_IS_REWARD });
const { staffId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
// One manual stamp, then one automatic one from a completed appointment — together they
// cross the threshold (2), and both paths must agree that's enough to earn the reward.
await addStamp(prisma, { customerId, businessId: business.businessId, amount: 1, staffId });
const appointment = await completedAppointment(staffId, customerId);
const result = await awardStampForCompletedAppointment(prisma, { appointmentId: appointment.id, staffId });
expect(result.rewardCreated).toBe(true);
});
it("redemption deducts exactly the earning threshold, not the raw stampsRequired total, in FINAL_SLOT_IS_REWARD mode", async () => {
const programme = await enableLoyalty({ stampsRequired: 4, rewardPosition: RewardPositionMode.FINAL_SLOT_IS_REWARD });
const { staffId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
const appointment = await completedAppointment(staffId, customerId);
// 3 stamps == threshold (stampsRequired - 1) — earns the reward without ever reaching 4.
const added = await addStamp(prisma, { customerId, businessId: business.businessId, amount: 3, staffId });
expect(added.rewardCreated).toBe(true);
const account = await prisma.loyaltyAccount.findUniqueOrThrow({
where: { customerId_loyaltyProgrammeId: { customerId, loyaltyProgrammeId: programme.id } },
});
const reward = await prisma.loyaltyReward.findFirstOrThrow({
where: { loyaltyAccountId: account.id, status: "AVAILABLE" },
});
const redemption = await redeemReward(prisma, {
rewardId: reward.id,
appointmentId: appointment.id,
staffId,
businessId: business.businessId,
});
expect(redemption.success).toBe(true);
const afterRedemption = await prisma.loyaltyAccount.findUniqueOrThrow({ where: { id: account.id } });
// Deducting the raw stampsRequired (4) from a balance of 3 would have gone negative —
// clampedDeduction alone would mask the bug. Deducting the threshold (3) leaves exactly 0.
expect(afterRedemption.currentStamps).toBe(0);
});
});
it("rejects a manual stamp when loyalty is disabled for the business", async () => {
// No enableLoyalty() call — business has no loyalty programme at all.
const { staffId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
const result = await addStamp(prisma, { customerId, businessId: business.businessId, amount: 1, staffId });
expect(result.success).toBe(false);
expect(result.validation.issues[0]?.code).toBe("LOYALTY_DISABLED");
});
describe("eligibility", () => {
it("skips the stamp when the appointment's staff member isn't in an eligible-staff list", async () => {
const programme = await enableLoyalty();
const { staffId: eligibleStaffId } = await createTestStaff(business);
const { staffId: ineligibleStaffId } = await createTestStaff(business);
await prisma.loyaltyEligibleStaff.create({
data: { loyaltyProgrammeId: programme.id, staffId: eligibleStaffId },
});
const { customerId } = await createTestCustomer(business);
const appointment = await completedAppointment(ineligibleStaffId, customerId);
const result = await awardStampForCompletedAppointment(prisma, {
appointmentId: appointment.id,
staffId: ineligibleStaffId,
});
expect(result.success).toBe(false);
expect(result.validation.issues[0]?.code).toBe("NOT_ELIGIBLE_STAFF");
});
it("skips the stamp when none of the appointment's services are eligible", async () => {
const programme = await enableLoyalty();
const { staffId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
// Only the beard trim is eligible; the fixture appointment books a haircut.
await prisma.loyaltyEligibleService.create({
data: { loyaltyProgrammeId: programme.id, serviceId: business.beardTrimServiceId },
});
const appointment = await completedAppointment(staffId, customerId);
const result = await awardStampForCompletedAppointment(prisma, { appointmentId: appointment.id, staffId });
expect(result.success).toBe(false);
expect(result.validation.issues[0]?.code).toBe("NOT_ELIGIBLE_SERVICE");
});
});
describe("correctStamp", () => {
it("requires a reason", async () => {
const programme = await enableLoyalty();
const { staffId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
await addStamp(prisma, { customerId, businessId: business.businessId, amount: 2, staffId });
const account = await prisma.loyaltyAccount.findUniqueOrThrow({
where: { customerId_loyaltyProgrammeId: { customerId, loyaltyProgrammeId: programme.id } },
});
const result = await correctStamp(prisma, {
loyaltyAccountId: account.id,
amount: 1,
staffId,
reason: " ",
});
expect(result.success).toBe(false);
expect(result.validation.issues[0]?.code).toBe("REASON_REQUIRED");
});
it("clamps the removal at the current balance instead of going negative", async () => {
const programme = await enableLoyalty();
const { staffId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
await addStamp(prisma, { customerId, businessId: business.businessId, amount: 2, staffId });
const account = await prisma.loyaltyAccount.findUniqueOrThrow({
where: { customerId_loyaltyProgrammeId: { customerId, loyaltyProgrammeId: programme.id } },
});
const result = await correctStamp(prisma, {
loyaltyAccountId: account.id,
amount: 10, // more than the current balance of 2
staffId,
reason: "Accidental double stamp",
});
expect(result.success).toBe(true);
const updated = await prisma.loyaltyAccount.findUniqueOrThrow({ where: { id: account.id } });
expect(updated.currentStamps).toBe(0);
const history = await getLoyaltyHistory(prisma, { loyaltyAccountId: account.id });
const correction = history.find((t) => t.type === "STAMP_REMOVED");
expect(correction?.amount).toBe(-2); // ledger records what actually happened, not the requested 10
expect(correction?.reason).toBe("Accidental double stamp");
});
});
});
@@ -0,0 +1,94 @@
import type { PrismaClient } from "@/generated/prisma/client";
import { NotificationType } from "@/generated/prisma/enums";
import { notifySafely } from "@/services/notifications/notification.service";
/**
* Queues the Notification rows a loyalty-domain event produces — called
* by loyalty.service.ts strictly after the ledger transaction has already
* committed, using notifySafely so a notification failure can never roll
* back or fail an otherwise-successful stamp/redemption. See
* booking-notifications.ts for the same pattern on the booking side.
*/
async function resolveCustomerRecipient(prisma: PrismaClient, customerId: string) {
return prisma.customerProfile.findUnique({
where: { id: customerId },
select: { userId: true },
});
}
export async function notifyLoyaltyStampAdded(
prisma: PrismaClient,
params: { businessId: string; customerId: string; transactionId: string; appointmentId?: string },
): Promise<void> {
const customer = await resolveCustomerRecipient(prisma, params.customerId);
await notifySafely(prisma, {
businessId: params.businessId,
type: NotificationType.LOYALTY_STAMP_ADDED,
recipientUserId: customer?.userId ?? undefined,
recipientCustomerId: params.customerId,
appointmentId: params.appointmentId,
subject: "You've earned a loyalty stamp",
bodyText: "You've earned a loyalty stamp. Keep it up!",
deepLinkPath: "/loyalty",
idempotencyKey: `notify:loyalty-stamp-added:${params.transactionId}`,
});
}
export async function notifyLoyaltyRewardEarned(
prisma: PrismaClient,
params: { businessId: string; customerId: string; rewardId: string; rewardLabel: string },
): Promise<void> {
const customer = await resolveCustomerRecipient(prisma, params.customerId);
await notifySafely(prisma, {
businessId: params.businessId,
type: NotificationType.LOYALTY_REWARD_EARNED,
recipientUserId: customer?.userId ?? undefined,
recipientCustomerId: params.customerId,
loyaltyRewardId: params.rewardId,
subject: "You've earned a reward",
bodyText: `You've earned a reward: ${params.rewardLabel}. Show this to your barber on your next visit.`,
deepLinkPath: "/loyalty",
idempotencyKey: `notify:loyalty-reward-earned:${params.rewardId}`,
});
}
/** Idempotency includes the day, not just the reward — a reward within the expiring-soon window keeps getting swept every cron run, but this should only ever notify once per calendar day it's found. */
export async function notifyLoyaltyRewardExpiring(
prisma: PrismaClient,
params: { businessId: string; customerId: string; rewardId: string; rewardLabel: string; expiresAt: Date; now: Date },
): Promise<void> {
const customer = await resolveCustomerRecipient(prisma, params.customerId);
const dayStamp = params.now.toISOString().slice(0, 10);
const expiresLabel = params.expiresAt.toLocaleDateString("en-GB", { day: "numeric", month: "short" });
await notifySafely(prisma, {
businessId: params.businessId,
type: NotificationType.LOYALTY_REWARD_EXPIRING,
recipientUserId: customer?.userId ?? undefined,
recipientCustomerId: params.customerId,
loyaltyRewardId: params.rewardId,
subject: "Your reward is expiring soon",
bodyText: `Your reward (${params.rewardLabel}) expires on ${expiresLabel}. Redeem it on your next visit before then.`,
deepLinkPath: "/loyalty",
idempotencyKey: `notify:loyalty-reward-expiring:${params.rewardId}:${dayStamp}`,
});
}
export async function notifyLoyaltyRewardRedeemed(
prisma: PrismaClient,
params: { businessId: string; customerId: string; rewardId: string; rewardLabel: string; appointmentId: string },
): Promise<void> {
const customer = await resolveCustomerRecipient(prisma, params.customerId);
await notifySafely(prisma, {
businessId: params.businessId,
type: NotificationType.LOYALTY_REWARD_REDEEMED,
recipientUserId: customer?.userId ?? undefined,
recipientCustomerId: params.customerId,
loyaltyRewardId: params.rewardId,
appointmentId: params.appointmentId,
subject: "Reward applied",
bodyText: `Your reward has been applied: ${params.rewardLabel}.`,
deepLinkPath: "/loyalty",
idempotencyKey: `notify:loyalty-reward-redeemed:${params.rewardId}`,
});
}
+882
View File
@@ -0,0 +1,882 @@
import type { LoyaltyProgramme, PrismaClient } from "@/generated/prisma/client";
import { LoyaltyCardFont, LoyaltyCardTextSize, RewardPositionMode } from "@/generated/prisma/enums";
import { fail, ok } from "@/lib/validation-result";
import type { ValidationResult } from "@/lib/validation-result";
import {
notifyLoyaltyRewardEarned,
notifyLoyaltyRewardExpiring,
notifyLoyaltyRewardRedeemed,
notifyLoyaltyStampAdded,
} from "./loyalty-notifications";
import { MAX_STAMPS_REQUIRED, resolveRewardThreshold } from "./reward-threshold";
export { MAX_STAMPS_REQUIRED, resolveRewardThreshold } from "./reward-threshold";
/**
* Ledger-based loyalty, per the brief: the cached `LoyaltyAccount.currentStamps`
* is always derived from — and only ever changed alongside — a
* `LoyaltyTransaction` row. Nothing in this file writes `currentStamps`
* without creating the matching transaction in the same call, and nothing
* outside this file should touch either table directly.
*
* Reward redemption (Phase 6) follows the same discipline: redeeming a
* reward, deducting the consumed stamps from the cached total, and
* recording the ledger entry all happen inside one `$transaction` — see
* `redeemReward`. This was a documented, deliberate Phase 5 limitation,
* not an oversight; it's implemented now.
*/
export type LoyaltyErrorCode =
| "LOYALTY_DISABLED"
| "PROGRAMME_NOT_FOUND"
| "NOT_ELIGIBLE_SERVICE"
| "NOT_ELIGIBLE_STAFF"
| "REASON_REQUIRED"
| "INVALID_AMOUNT"
| "APPOINTMENT_NOT_FOUND"
| "APPOINTMENT_NOT_COMPLETED"
| "INVALID_INPUT"
| "INVALID_PERCENTAGE"
| "INVALID_VALUE"
| "FREE_SERVICE_REQUIRED"
| "REWARD_NOT_FOUND"
| "REWARD_NOT_AVAILABLE"
| "REWARD_CUSTOMER_MISMATCH"
| "APPOINTMENT_NOT_ELIGIBLE";
export interface LoyaltySummary {
isEnabled: boolean;
accountId: string | null;
currentStamps: number;
stampsRequired: number;
rewardPosition: RewardPositionMode;
availableReward: { id: string; label: string; createdAt: Date; expiresAt: Date | null } | null;
rewardLabel: string | null;
stampIcon: string | null;
cardBackgroundColor: string | null;
cardBackgroundImageUrl: string | null;
termsLine: string | null;
cardTitleText: string | null;
cardFont: LoyaltyCardFont;
cardTitleTextSize: LoyaltyCardTextSize;
cardTitleTextColor: string | null;
cardBorderColor: string | null;
cardMemberNumberColor: string | null;
}
/** The customer's loyalty standing for this business — null only if no loyalty programme has ever been configured (distinct from "configured but disabled", which still returns a summary with isEnabled: false). */
export async function getLoyaltySummary(
prisma: PrismaClient,
params: { customerId: string; businessId: string },
): Promise<LoyaltySummary | null> {
const programme = await prisma.loyaltyProgramme.findUnique({
where: { businessId: params.businessId },
});
if (!programme) return null;
const account = await prisma.loyaltyAccount.findUnique({
where: {
customerId_loyaltyProgrammeId: {
customerId: params.customerId,
loyaltyProgrammeId: programme.id,
},
},
include: {
rewards: { where: { status: "AVAILABLE" }, orderBy: { createdAt: "desc" }, take: 1 },
},
});
return {
isEnabled: programme.isEnabled,
accountId: account?.id ?? null,
currentStamps: account?.currentStamps ?? 0,
stampsRequired: programme.stampsRequired,
rewardPosition: programme.rewardPosition,
availableReward: account?.rewards[0]
? {
id: account.rewards[0].id,
label: account.rewards[0].label,
createdAt: account.rewards[0].createdAt,
expiresAt: account.rewards[0].expiresAt,
}
: null,
rewardLabel: programme.rewardLabel,
stampIcon: programme.stampIcon,
cardBackgroundColor: programme.cardBackgroundColor,
cardBackgroundImageUrl: programme.cardBackgroundImageUrl,
termsLine: programme.termsLine,
cardTitleText: programme.cardTitleText,
cardFont: programme.cardFont,
cardTitleTextSize: programme.cardTitleTextSize,
cardTitleTextColor: programme.cardTitleTextColor,
cardBorderColor: programme.cardBorderColor,
cardMemberNumberColor: programme.cardMemberNumberColor,
};
}
/**
* Batched version of getLoyaltySummary's "does this customer have a reward
* waiting" check, for rendering a loyalty indicator across a whole day's
* appointments (the calendar) without one query per customer. Returns an
* empty map if loyalty isn't configured/enabled at all.
*/
export async function getAvailableRewardFlags(
prisma: PrismaClient,
params: { businessId: string; customerIds: string[] },
): Promise<Map<string, boolean>> {
if (params.customerIds.length === 0) return new Map();
const programme = await prisma.loyaltyProgramme.findUnique({
where: { businessId: params.businessId },
});
if (!programme || !programme.isEnabled) return new Map();
const accounts = await prisma.loyaltyAccount.findMany({
where: { loyaltyProgrammeId: programme.id, customerId: { in: params.customerIds } },
select: {
customerId: true,
rewards: { where: { status: "AVAILABLE" }, select: { id: true }, take: 1 },
},
});
return new Map(accounts.map((a) => [a.customerId, a.rewards.length > 0]));
}
export interface CustomerLoyaltyBrief {
currentStamps: number;
/** How many stamps actually earn the reward — see resolveRewardThreshold. */
rewardThreshold: number;
hasAvailableReward: boolean;
}
/** Fuller batched summary — current stamps + threshold + reward flag — for the staff customer directory, one query for the whole list rather than one per row. */
export async function getLoyaltySummariesForCustomers(
prisma: PrismaClient,
params: { businessId: string; customerIds: string[] },
): Promise<Map<string, CustomerLoyaltyBrief>> {
if (params.customerIds.length === 0) return new Map();
const programme = await prisma.loyaltyProgramme.findUnique({
where: { businessId: params.businessId },
});
if (!programme || !programme.isEnabled) return new Map();
const accounts = await prisma.loyaltyAccount.findMany({
where: { loyaltyProgrammeId: programme.id, customerId: { in: params.customerIds } },
select: {
customerId: true,
currentStamps: true,
rewards: { where: { status: "AVAILABLE" }, select: { id: true }, take: 1 },
},
});
const rewardThreshold = resolveRewardThreshold(programme);
return new Map(
accounts.map((a) => [
a.customerId,
{
currentStamps: a.currentStamps,
rewardThreshold,
hasAvailableReward: a.rewards.length > 0,
},
]),
);
}
async function getOrCreateAccount(
prisma: PrismaClient,
params: { customerId: string; loyaltyProgrammeId: string },
) {
return prisma.loyaltyAccount.upsert({
where: {
customerId_loyaltyProgrammeId: {
customerId: params.customerId,
loyaltyProgrammeId: params.loyaltyProgrammeId,
},
},
create: { customerId: params.customerId, loyaltyProgrammeId: params.loyaltyProgrammeId },
update: {},
});
}
/**
* An empty eligible-services/eligible-staff list means "everyone/everything
* is eligible" (see schema.prisma) — an explicit row only exists to
* *restrict* eligibility. A visit counts if at least one of its services is
* eligible (the common "any qualifying service in this visit earns a
* stamp" rule), not only if every service is.
*/
async function checkEligibility(
prisma: PrismaClient,
params: { loyaltyProgrammeId: string; serviceIds: string[]; staffId: string },
): Promise<ValidationResult<LoyaltyErrorCode>> {
const [eligibleServices, eligibleStaff] = await Promise.all([
prisma.loyaltyEligibleService.findMany({
where: { loyaltyProgrammeId: params.loyaltyProgrammeId },
}),
prisma.loyaltyEligibleStaff.findMany({
where: { loyaltyProgrammeId: params.loyaltyProgrammeId },
}),
]);
if (eligibleStaff.length > 0 && !eligibleStaff.some((r) => r.staffId === params.staffId)) {
return fail({
code: "NOT_ELIGIBLE_STAFF",
message: "This staff member's visits don't earn loyalty stamps.",
});
}
if (
eligibleServices.length > 0 &&
!params.serviceIds.some((id) => eligibleServices.some((r) => r.serviceId === id))
) {
return fail({
code: "NOT_ELIGIBLE_SERVICE",
message: "None of the services on this visit earn a loyalty stamp.",
});
}
return ok();
}
interface AddStampResult {
success: boolean;
alreadyAwarded?: boolean;
rewardCreated?: boolean;
validation: ValidationResult<LoyaltyErrorCode>;
}
/**
* The auto-award path, called once by the "complete appointment" Server
* Action after AppointmentService has already transitioned the status —
* this function never changes appointment status itself. Idempotent: if a
* STAMP_ADDED transaction already references this appointment (e.g. the
* action is somehow triggered twice), it returns success without creating
* a second one — the brief's explicit "should not receive the same
* automatic stamp twice".
*/
export async function awardStampForCompletedAppointment(
prisma: PrismaClient,
params: { appointmentId: string; staffId: string },
): Promise<AddStampResult> {
const appointment = await prisma.appointment.findUnique({
where: { id: params.appointmentId },
include: { services: true },
});
if (!appointment)
return {
success: false,
validation: fail({ code: "APPOINTMENT_NOT_FOUND", message: "Appointment not found." }),
};
if (appointment.status !== "COMPLETED") {
return {
success: false,
validation: fail({
code: "APPOINTMENT_NOT_COMPLETED",
message: "Only a completed appointment can earn a stamp.",
}),
};
}
const programme = await prisma.loyaltyProgramme.findUnique({
where: { businessId: appointment.businessId },
});
if (!programme || !programme.isEnabled) {
return {
success: false,
validation: fail({
code: "LOYALTY_DISABLED",
message: "Loyalty isn't enabled for this business.",
}),
};
}
const eligibility = await checkEligibility(prisma, {
loyaltyProgrammeId: programme.id,
serviceIds: appointment.services.map((s) => s.serviceId),
staffId: appointment.staffId,
});
if (!eligibility.valid) return { success: false, validation: eligibility };
const account = await getOrCreateAccount(prisma, {
customerId: appointment.customerId,
loyaltyProgrammeId: programme.id,
});
const alreadyAwarded = await prisma.loyaltyTransaction.findFirst({
where: { loyaltyAccountId: account.id, appointmentId: appointment.id, type: "STAMP_ADDED" },
});
if (alreadyAwarded) return { success: true, alreadyAwarded: true, validation: ok() };
const { rewardCreated, rewardId, transactionId } = await addStampTransaction(prisma, {
loyaltyAccountId: account.id,
amount: 1, // one stamp per completed visit — see file header; not a per-service count
appointmentId: appointment.id,
authoredByStaffId: params.staffId,
reason: null,
programme,
});
await notifyLoyaltyStampAdded(prisma, {
businessId: appointment.businessId,
customerId: appointment.customerId,
transactionId,
appointmentId: appointment.id,
});
if (rewardCreated && rewardId) {
await notifyLoyaltyRewardEarned(prisma, {
businessId: appointment.businessId,
customerId: appointment.customerId,
rewardId,
rewardLabel: programme.rewardLabel ?? "Reward",
});
}
return { success: true, rewardCreated, validation: ok() };
}
/** The manual "Add Stamp" action a staff member triggers from Customer Details / Current Appointment — not tied to a completion event, so no duplicate-award check applies (every manual add is its own deliberate transaction). */
export async function addStamp(
prisma: PrismaClient,
params: {
customerId: string;
businessId: string;
amount: number;
staffId: string;
appointmentId?: string;
reason?: string;
},
): Promise<AddStampResult> {
if (!Number.isInteger(params.amount) || params.amount <= 0) {
return {
success: false,
validation: fail({ code: "INVALID_AMOUNT", message: "Enter a positive number of stamps." }),
};
}
const programme = await prisma.loyaltyProgramme.findUnique({
where: { businessId: params.businessId },
});
if (!programme || !programme.isEnabled) {
return {
success: false,
validation: fail({
code: "LOYALTY_DISABLED",
message: "Loyalty isn't enabled for this business.",
}),
};
}
const account = await getOrCreateAccount(prisma, {
customerId: params.customerId,
loyaltyProgrammeId: programme.id,
});
const { rewardCreated, rewardId, transactionId } = await addStampTransaction(prisma, {
loyaltyAccountId: account.id,
amount: params.amount,
appointmentId: params.appointmentId,
authoredByStaffId: params.staffId,
reason: params.reason ?? null,
programme,
});
await notifyLoyaltyStampAdded(prisma, {
businessId: params.businessId,
customerId: params.customerId,
transactionId,
appointmentId: params.appointmentId,
});
if (rewardCreated && rewardId) {
await notifyLoyaltyRewardEarned(prisma, {
businessId: params.businessId,
customerId: params.customerId,
rewardId,
rewardLabel: programme.rewardLabel ?? "Reward",
});
}
return { success: true, rewardCreated, validation: ok() };
}
async function addStampTransaction(
prisma: PrismaClient,
params: {
loyaltyAccountId: string;
amount: number;
appointmentId?: string;
authoredByStaffId: string;
reason: string | null;
programme: LoyaltyProgramme;
},
): Promise<{ rewardCreated: boolean; rewardId?: string; transactionId: string }> {
return prisma.$transaction(async (tx) => {
const account = await tx.loyaltyAccount.update({
where: { id: params.loyaltyAccountId },
data: { currentStamps: { increment: params.amount } },
});
const transaction = await tx.loyaltyTransaction.create({
data: {
loyaltyAccountId: params.loyaltyAccountId,
type: "STAMP_ADDED",
amount: params.amount,
reason: params.reason,
appointmentId: params.appointmentId,
authoredByStaffId: params.authoredByStaffId,
},
});
let rewardCreated = false;
let rewardId: string | undefined;
if (account.currentStamps >= resolveRewardThreshold(params.programme)) {
const existingAvailable = await tx.loyaltyReward.findFirst({
where: { loyaltyAccountId: account.id, status: "AVAILABLE" },
});
// Crossing the threshold again while a reward is already waiting to be
// redeemed doesn't stack a second one.
if (!existingAvailable) {
const reward = await tx.loyaltyReward.create({
data: {
loyaltyAccountId: account.id,
rewardType: params.programme.rewardType,
value: params.programme.rewardValue,
label: params.programme.rewardLabel ?? "Reward",
status: "AVAILABLE",
expiresAt: params.programme.rewardExpiresAfterDays
? new Date(Date.now() + params.programme.rewardExpiresAfterDays * 86_400_000)
: null,
},
});
rewardCreated = true;
rewardId = reward.id;
}
}
return { rewardCreated, rewardId, transactionId: transaction.id };
});
}
/**
* Corrects an accidental stamp — always removes (never adds; use `addStamp`
* for that), always requires a reason, and always creates a compensating
* STAMP_REMOVED transaction rather than editing or deleting the original
* entry. The cached total is clamped at zero so a correction can never make
* it negative.
*/
export async function correctStamp(
prisma: PrismaClient,
params: { loyaltyAccountId: string; amount: number; staffId: string; reason: string },
): Promise<{ success: boolean; validation: ValidationResult<LoyaltyErrorCode> }> {
if (!params.reason.trim()) {
return {
success: false,
validation: fail({
code: "REASON_REQUIRED",
message: "A reason is required to correct a stamp.",
}),
};
}
if (!Number.isInteger(params.amount) || params.amount <= 0) {
return {
success: false,
validation: fail({
code: "INVALID_AMOUNT",
message: "Enter a positive number of stamps to remove.",
}),
};
}
await prisma.$transaction(async (tx) => {
const account = await tx.loyaltyAccount.findUniqueOrThrow({
where: { id: params.loyaltyAccountId },
});
const clampedRemoval = Math.min(params.amount, account.currentStamps);
await tx.loyaltyAccount.update({
where: { id: params.loyaltyAccountId },
data: { currentStamps: { decrement: clampedRemoval } },
});
await tx.loyaltyTransaction.create({
data: {
loyaltyAccountId: params.loyaltyAccountId,
type: "STAMP_REMOVED",
amount: -clampedRemoval,
reason: params.reason,
authoredByStaffId: params.staffId,
},
});
});
return { success: true, validation: ok() };
}
export async function getLoyaltyHistory(
prisma: PrismaClient,
params: { loyaltyAccountId: string; limit?: number },
) {
return prisma.loyaltyTransaction.findMany({
where: { loyaltyAccountId: params.loyaltyAccountId },
orderBy: { createdAt: "desc" },
take: params.limit ?? 20,
include: {
authoredByStaff: { include: { user: { select: { name: true } } } },
appointment: { select: { id: true, startAt: true } },
},
});
}
// ── Owner: programme configuration ─────────────────────────────────────
export interface ProgrammeConfigInput {
isEnabled: boolean;
stampsRequired: number;
/** See resolveRewardThreshold's doc comment for what each mode means. */
rewardPosition: RewardPositionMode;
rewardType: "PERCENTAGE" | "FIXED_AMOUNT" | "FREE_SERVICE";
/** Decimal string — see ServiceCatalogueService's file header for why money is never a JS number. Required for PERCENTAGE/FIXED_AMOUNT, ignored for FREE_SERVICE. */
rewardValue?: string;
rewardLabel?: string;
maxRewardValue?: string;
freeServiceId?: string;
autoApplyReward: boolean;
requiresStaffApproval: boolean;
stampsExpireAfterDays?: number;
rewardExpiresAfterDays?: number;
earnOnCancelled: boolean;
earnOnNoShow: boolean;
cardBackgroundColor?: string;
cardBackgroundImageUrl?: string;
stampIcon?: string;
termsLine?: string;
/** Replaces the "Loyalty Card" title (top-left) when set — see schema.prisma's cardTitleText doc comment. */
cardTitleText?: string;
cardFont: LoyaltyCardFont;
cardTitleTextSize: LoyaltyCardTextSize;
cardTitleTextColor?: string;
cardBorderColor?: string;
cardMemberNumberColor?: string;
}
/** Only enforced when isEnabled — a half-configured, disabled draft is fine (the owner is still working on it); a half-configured, *enabled* programme would start awarding rewards nobody can redeem correctly. */
export function validateProgrammeConfig(input: ProgrammeConfigInput): ValidationResult<LoyaltyErrorCode> {
if (!Number.isInteger(input.stampsRequired) || input.stampsRequired < 1 || input.stampsRequired > MAX_STAMPS_REQUIRED) {
return fail({ code: "INVALID_INPUT", message: `Stamps required must be between 1 and ${MAX_STAMPS_REQUIRED}.` });
}
if (!input.isEnabled) return ok();
if (input.rewardType === "PERCENTAGE") {
const value = input.rewardValue !== undefined ? Number(input.rewardValue) : NaN;
if (!Number.isFinite(value) || value <= 0 || value > 100) {
return fail({ code: "INVALID_PERCENTAGE", message: "Percentage must be between 1 and 100." });
}
} else if (input.rewardType === "FIXED_AMOUNT") {
const value = input.rewardValue !== undefined ? Number(input.rewardValue) : NaN;
if (!Number.isFinite(value) || value < 0) {
return fail({ code: "INVALID_VALUE", message: "Enter a fixed reward amount of zero or more." });
}
} else if (input.rewardType === "FREE_SERVICE" && !input.freeServiceId) {
return fail({ code: "FREE_SERVICE_REQUIRED", message: "Choose which service is free." });
}
return ok();
}
export async function getProgrammeConfig(prisma: PrismaClient, businessId: string) {
return prisma.loyaltyProgramme.findUnique({
where: { businessId },
include: {
eligibleServices: { include: { service: true } },
eligibleStaff: true,
freeService: true,
},
});
}
export async function updateProgrammeConfig(
prisma: PrismaClient,
params: { businessId: string; input: ProgrammeConfigInput },
): Promise<{ success: boolean; validation: ValidationResult<LoyaltyErrorCode> }> {
const validation = validateProgrammeConfig(params.input);
if (!validation.valid) return { success: false, validation };
if (params.input.isEnabled && params.input.rewardType === "FREE_SERVICE" && params.input.freeServiceId) {
const service = await prisma.service.findFirst({
where: { id: params.input.freeServiceId, businessId: params.businessId, isActive: true },
});
if (!service) {
return {
success: false,
validation: fail({ code: "FREE_SERVICE_REQUIRED", message: "Choose an active service." }),
};
}
}
const data = {
isEnabled: params.input.isEnabled,
stampsRequired: params.input.stampsRequired,
rewardPosition: params.input.rewardPosition,
rewardType: params.input.rewardType,
rewardValue: params.input.rewardType === "FREE_SERVICE" ? null : (params.input.rewardValue ?? null),
rewardLabel: params.input.rewardLabel?.trim() || null,
maxRewardValue: params.input.maxRewardValue || null,
freeServiceId: params.input.rewardType === "FREE_SERVICE" ? (params.input.freeServiceId ?? null) : null,
autoApplyReward: params.input.autoApplyReward,
requiresStaffApproval: params.input.requiresStaffApproval,
stampsExpireAfterDays: params.input.stampsExpireAfterDays ?? null,
rewardExpiresAfterDays: params.input.rewardExpiresAfterDays ?? null,
earnOnCancelled: params.input.earnOnCancelled,
earnOnNoShow: params.input.earnOnNoShow,
cardBackgroundColor: params.input.cardBackgroundColor || null,
cardBackgroundImageUrl: params.input.cardBackgroundImageUrl || null,
stampIcon: params.input.stampIcon || null,
termsLine: params.input.termsLine?.trim() || null,
cardTitleText: params.input.cardTitleText?.trim() || null,
cardFont: params.input.cardFont,
cardTitleTextSize: params.input.cardTitleTextSize,
cardTitleTextColor: params.input.cardTitleTextColor || null,
cardBorderColor: params.input.cardBorderColor || null,
cardMemberNumberColor: params.input.cardMemberNumberColor || null,
};
await prisma.loyaltyProgramme.upsert({
where: { businessId: params.businessId },
create: { businessId: params.businessId, ...data },
update: data,
});
return { success: true, validation: ok() };
}
export async function setEligibleServices(
prisma: PrismaClient,
params: { loyaltyProgrammeId: string; serviceIds: string[] },
): Promise<{ success: true }> {
await prisma.$transaction([
prisma.loyaltyEligibleService.deleteMany({ where: { loyaltyProgrammeId: params.loyaltyProgrammeId } }),
prisma.loyaltyEligibleService.createMany({
data: params.serviceIds.map((serviceId) => ({ loyaltyProgrammeId: params.loyaltyProgrammeId, serviceId })),
}),
]);
return { success: true };
}
/** LoyaltyEligibleStaff.staffId has no FK relation in the schema (see schema.prisma) — callers that need staff names for display fetch StaffProfile rows separately and join by id in memory. */
export async function setEligibleStaff(
prisma: PrismaClient,
params: { loyaltyProgrammeId: string; staffIds: string[] },
): Promise<{ success: true }> {
await prisma.$transaction([
prisma.loyaltyEligibleStaff.deleteMany({ where: { loyaltyProgrammeId: params.loyaltyProgrammeId } }),
prisma.loyaltyEligibleStaff.createMany({
data: params.staffIds.map((staffId) => ({ loyaltyProgrammeId: params.loyaltyProgrammeId, staffId })),
}),
]);
return { success: true };
}
// ── Reward redemption ───────────────────────────────────────────────────
/** Appointments this reward could be applied to right now — same customer, not cancelled, no reward already applied. Shown so staff pick a real, current appointment rather than typing an id. */
export async function listEligibleAppointmentsForReward(
prisma: PrismaClient,
params: { customerId: string; businessId: string },
) {
return prisma.appointment.findMany({
where: {
customerId: params.customerId,
businessId: params.businessId,
status: { notIn: ["CANCELLED"] },
discountAmount: null,
},
include: { services: { include: { service: true } }, staff: { include: { user: { select: { name: true } } } } },
orderBy: { startAt: "desc" },
take: 10,
});
}
/**
* Redeems an AVAILABLE reward against a specific appointment — the one
* place `LoyaltyReward.status`, `LoyaltyAccount.currentStamps`, and
* `Appointment.discountAmount` ever change together. The status flip uses
* a conditional `updateMany` (`WHERE status = 'AVAILABLE'`) inside the
* transaction rather than a read-then-write, so two simultaneous
* redemption attempts can't both succeed — the loser sees
* REWARD_NOT_AVAILABLE, not a corrupted double-deduction.
*
* Neither the reward nor the stamps-required count is snapshotted at
* *earn* time — redemption always uses the loyalty programme's current
* configuration (current stampsRequired, current freeServiceId for a
* FREE_SERVICE reward). A documented simplification: if the owner
* reconfigures the programme between a reward being earned and redeemed,
* redemption reflects the new configuration. See docs/phase-6-summary.md.
*/
export async function redeemReward(
prisma: PrismaClient,
params: { rewardId: string; appointmentId: string; staffId: string; businessId: string },
): Promise<{ success: boolean; validation: ValidationResult<LoyaltyErrorCode>; discountAmount?: string }> {
const reward = await prisma.loyaltyReward.findUnique({
where: { id: params.rewardId },
include: { loyaltyAccount: { include: { loyaltyProgramme: true } } },
});
if (!reward || reward.loyaltyAccount.loyaltyProgramme.businessId !== params.businessId) {
return { success: false, validation: fail({ code: "REWARD_NOT_FOUND", message: "Reward not found." }) };
}
if (reward.status !== "AVAILABLE") {
return {
success: false,
validation: fail({ code: "REWARD_NOT_AVAILABLE", message: "This reward has already been redeemed or has expired." }),
};
}
// Belt-and-braces against the periodic expiry sweep (see
// expireOverdueRewards, run from the notification cron) not having run
// yet — a reward past its own expiresAt is never redeemable even if its
// stored status still says AVAILABLE.
if (reward.expiresAt && reward.expiresAt < new Date()) {
return {
success: false,
validation: fail({ code: "REWARD_NOT_AVAILABLE", message: "This reward has expired." }),
};
}
const appointment = await prisma.appointment.findFirst({
where: { id: params.appointmentId, businessId: params.businessId },
include: { services: true },
});
if (!appointment) {
return { success: false, validation: fail({ code: "APPOINTMENT_NOT_FOUND", message: "Appointment not found." }) };
}
if (appointment.customerId !== reward.loyaltyAccount.customerId) {
return {
success: false,
validation: fail({ code: "REWARD_CUSTOMER_MISMATCH", message: "This reward belongs to a different customer." }),
};
}
if (appointment.status === "CANCELLED") {
return {
success: false,
validation: fail({ code: "APPOINTMENT_NOT_ELIGIBLE", message: "Can't apply a reward to a cancelled appointment." }),
};
}
if (appointment.discountAmount !== null) {
return {
success: false,
validation: fail({ code: "APPOINTMENT_NOT_ELIGIBLE", message: "A reward has already been applied to this appointment." }),
};
}
const subtotal = appointment.services.reduce((sum, s) => sum + Number(s.priceAtBooking), 0);
let discountAmount: number;
if (reward.rewardType === "PERCENTAGE") {
discountAmount = Math.round(subtotal * (Number(reward.value ?? 0) / 100) * 100) / 100;
} else if (reward.rewardType === "FIXED_AMOUNT") {
discountAmount = Number(reward.value ?? 0);
} else {
const freeLine = appointment.services.find((s) => s.serviceId === reward.loyaltyAccount.loyaltyProgramme.freeServiceId);
if (!freeLine) {
return {
success: false,
validation: fail({
code: "APPOINTMENT_NOT_ELIGIBLE",
message: "This appointment doesn't include the service this reward covers.",
}),
};
}
discountAmount = Number(freeLine.priceAtBooking);
}
discountAmount = Math.min(Math.max(discountAmount, 0), subtotal);
const discountAmountString = discountAmount.toFixed(2);
// Deduct however many stamps actually earned this reward, not the raw
// stampsRequired total — in FINAL_SLOT_IS_REWARD mode those aren't the
// same number (see resolveRewardThreshold), and the ledger must only
// ever remove what was really collected.
const rewardThreshold = resolveRewardThreshold(reward.loyaltyAccount.loyaltyProgramme);
const { alreadyRedeemed } = await prisma.$transaction(async (tx) => {
const updated = await tx.loyaltyReward.updateMany({
where: { id: params.rewardId, status: "AVAILABLE" },
data: {
status: "REDEEMED",
redeemedAt: new Date(),
redeemedByStaffId: params.staffId,
appliedToAppointmentId: params.appointmentId,
},
});
if (updated.count === 0) return { alreadyRedeemed: true };
const account = await tx.loyaltyAccount.findUniqueOrThrow({ where: { id: reward.loyaltyAccountId } });
const clampedDeduction = Math.min(rewardThreshold, account.currentStamps);
await tx.loyaltyAccount.update({
where: { id: account.id },
data: { currentStamps: { decrement: clampedDeduction } },
});
await tx.loyaltyTransaction.create({
data: {
loyaltyAccountId: account.id,
type: "REWARD_REDEEMED",
amount: -clampedDeduction,
appointmentId: params.appointmentId,
authoredByStaffId: params.staffId,
},
});
await tx.appointment.update({
where: { id: params.appointmentId },
data: { discountAmount: discountAmountString },
});
return { alreadyRedeemed: false };
});
if (alreadyRedeemed) {
return {
success: false,
validation: fail({ code: "REWARD_NOT_AVAILABLE", message: "This reward has already been redeemed." }),
};
}
await notifyLoyaltyRewardRedeemed(prisma, {
businessId: params.businessId,
customerId: reward.loyaltyAccount.customerId,
rewardId: reward.id,
rewardLabel: reward.label,
appointmentId: params.appointmentId,
});
return { success: true, validation: ok(), discountAmount: discountAmountString };
}
const REWARD_EXPIRING_SOON_WINDOW_DAYS = 7;
/**
* Run from the notification cron (see /api/cron/process-notifications)
* alongside processDueNotificationDeliveries — one scheduled trigger covers
* both. Two independent jobs in one pass: flip any AVAILABLE reward whose
* expiresAt has passed to EXPIRED (redeemReward also defends against this
* directly, but the sweep is what makes "Expired" actually show up in
* history/UI instead of staying stuck at AVAILABLE forever), then notify
* once per day for rewards still AVAILABLE and expiring within the next
* week.
*/
export async function expireOverdueRewards(prisma: PrismaClient, now: Date = new Date()): Promise<void> {
await prisma.loyaltyReward.updateMany({
where: { status: "AVAILABLE", expiresAt: { lt: now } },
data: { status: "EXPIRED" },
});
const expiringSoon = await prisma.loyaltyReward.findMany({
where: {
status: "AVAILABLE",
expiresAt: { gte: now, lte: new Date(now.getTime() + REWARD_EXPIRING_SOON_WINDOW_DAYS * 86_400_000) },
},
include: { loyaltyAccount: { include: { loyaltyProgramme: true } } },
});
for (const reward of expiringSoon) {
if (!reward.expiresAt) continue;
await notifyLoyaltyRewardExpiring(prisma, {
businessId: reward.loyaltyAccount.loyaltyProgramme.businessId,
customerId: reward.loyaltyAccount.customerId,
rewardId: reward.id,
rewardLabel: reward.label,
expiresAt: reward.expiresAt,
now,
});
}
}
+38
View File
@@ -0,0 +1,38 @@
import type { LoyaltyProgramme } from "@/generated/prisma/client";
import { RewardPositionMode } from "@/generated/prisma/enums";
/**
* Stamps required for a reward is capped at 10 (Phase 10 follow-up brief) —
* mirrored by the LoyaltyProgramme_stampsRequired_range CHECK constraint,
* validateProgrammeConfig, and the owner form's own client-side validation.
* Lives here (not loyalty.service.ts) for the same reason
* resolveRewardThreshold does — see that function's doc comment.
*/
export const MAX_STAMPS_REQUIRED = 10;
/**
* The single place "how many stamps actually earn the reward" is decided —
* every caller that needs to know (auto/manual stamp awarding, redemption,
* progress text, the customer card, the owner's live preview) reads through
* this rather than each re-deriving its own answer, so they can't disagree.
* Deliberately its own tiny, side-effect-free module (not defined inline in
* loyalty.service.ts) so client components — the customer card, the owner's
* live preview — can import this one pure function without pulling in the
* rest of that file's Prisma-calling service code.
*
* AFTER_ALL_STAMPS: every one of `stampsRequired` positions is a normal
* stamp; the reward unlocks once all of them are filled — threshold ==
* stampsRequired.
*
* FINAL_SLOT_IS_REWARD: the last of the `stampsRequired` positions on the
* card *is* the reward, not a stamp to collect — so it's earned one stamp
* early, at stampsRequired - 1. (Clamped at 1 so a stampsRequired of 1 can
* never produce a threshold of 0.)
*/
export function resolveRewardThreshold(
programme: Pick<LoyaltyProgramme, "stampsRequired" | "rewardPosition">,
): number {
return programme.rewardPosition === RewardPositionMode.FINAL_SLOT_IS_REWARD
? Math.max(1, programme.stampsRequired - 1)
: programme.stampsRequired;
}
@@ -0,0 +1,54 @@
import { afterEach, describe, expect, it, vi } from "vitest";
const cookieSet = vi.hoisted(() => vi.fn());
vi.mock("next/headers", () => ({
cookies: vi.fn(async () => ({ set: cookieSet })),
}));
import { createManagerSession } from "../management-auth";
describe("Manager session cookie", () => {
const originalAppUrl = process.env.NEXT_PUBLIC_APP_URL;
const originalAdminEmail = process.env.MANAGER_ADMIN_EMAIL;
const originalAuthSecret = process.env.MANAGER_AUTH_SECRET;
afterEach(() => {
if (originalAppUrl === undefined) delete process.env.NEXT_PUBLIC_APP_URL;
else process.env.NEXT_PUBLIC_APP_URL = originalAppUrl;
if (originalAdminEmail === undefined) delete process.env.MANAGER_ADMIN_EMAIL;
else process.env.MANAGER_ADMIN_EMAIL = originalAdminEmail;
if (originalAuthSecret === undefined) delete process.env.MANAGER_AUTH_SECRET;
else process.env.MANAGER_AUTH_SECRET = originalAuthSecret;
cookieSet.mockReset();
});
it("does not mark the cookie Secure for an HTTP deployment", async () => {
process.env.NEXT_PUBLIC_APP_URL = "http://manager.example.com";
process.env.MANAGER_ADMIN_EMAIL = "admin@example.com";
process.env.MANAGER_AUTH_SECRET = "test-secret";
await createManagerSession();
expect(cookieSet).toHaveBeenCalledWith(
"nextchair_manager_session",
expect.any(String),
expect.objectContaining({ secure: false, path: "/" }),
);
expect(cookieSet).toHaveBeenCalledWith("nextchair_manager_session", "", { path: "/manager", maxAge: 0 });
});
it("marks the cookie Secure for an HTTPS deployment", async () => {
process.env.NEXT_PUBLIC_APP_URL = "https://manager.example.com";
process.env.MANAGER_ADMIN_EMAIL = "admin@example.com";
process.env.MANAGER_AUTH_SECRET = "test-secret";
await createManagerSession();
expect(cookieSet).toHaveBeenCalledWith(
"nextchair_manager_session",
expect.any(String),
expect.objectContaining({ secure: true, path: "/" }),
);
});
});
@@ -0,0 +1,79 @@
import { createHmac, timingSafeEqual } from "node:crypto";
import { cookies } from "next/headers";
import { isSecureDeployment } from "@/lib/security/cookie-security";
import { getManagerConfig } from "./manager-config";
const COOKIE_NAME = "nextchair_manager_session";
function signature(value: string, secret: string) {
return createHmac("sha256", secret).update(value).digest("hex");
}
export function isManagerConfigured() {
const config = getManagerConfig();
return Boolean(config.adminEmail && config.adminPassword && config.authSecret);
}
export function authenticateManager(email: string, password: string) {
const config = getManagerConfig();
if (!isManagerConfigured() || email.trim().toLowerCase() !== config.adminEmail.trim().toLowerCase()) return false;
const expected = Buffer.from(config.adminPassword);
const received = Buffer.from(password);
return expected.length === received.length && timingSafeEqual(expected, received);
}
export async function createManagerSession() {
const config = getManagerConfig();
const value = `${config.adminEmail.trim().toLowerCase()}.${Date.now()}`;
const store = await cookies();
// Remove sessions issued by older builds with Path=/manager. Browsers can
// retain both cookies, and the stale one may be selected first on /manager.
store.set(COOKIE_NAME, "", { path: "/manager", maxAge: 0 });
store.set(COOKIE_NAME, `${value}.${signature(value, config.authSecret)}`, {
httpOnly: true,
sameSite: "strict",
secure: isSecureDeployment(),
// The Manager UI is under /manager, but its authenticated API is under
// /api/manager, so the cookie must cover both paths.
path: "/",
maxAge: 60 * 60 * 8,
});
}
export async function clearManagerSession() {
const store = await cookies();
store.delete(COOKIE_NAME);
store.set(COOKIE_NAME, "", { path: "/manager", maxAge: 0 });
}
export async function isManagerAuthenticated() {
const config = getManagerConfig();
console.log("[manager-auth] auth secret:", config.authSecret ? "set" : "missing");
if (!config.authSecret) return false;
const cookieStore = await cookies();
const rawValue = cookieStore.get(COOKIE_NAME)?.value;
console.log("[manager-auth] cookie:", rawValue ? "received" : "missing");
const value = rawValue ? decodeURIComponent(rawValue) : undefined;
if (!value) return false;
const separator = value.lastIndexOf(".");
if (separator < 1) return false;
const payload = value.slice(0, separator);
const received = Buffer.from(value.slice(separator + 1));
const expected = Buffer.from(signature(payload, config.authSecret));
const valid =
received.length === expected.length &&
timingSafeEqual(received, expected);
console.log("[manager-auth] signature valid:", valid);
return valid;
}
+11
View File
@@ -0,0 +1,11 @@
export function getManagerConfig() {
const stackRoot = process.env.NEXTCHAIR_STACK_ROOT || `${process.cwd()}/stacks`;
return {
stackRoot,
caddySitesDir: process.env.NEXTCHAIR_CADDY_SITES_DIR || `${stackRoot}/caddy/sites`,
dockgeUrl: process.env.NEXTCHAIR_DOCKGE_URL || "",
adminEmail: process.env.MANAGER_ADMIN_EMAIL || "",
adminPassword: process.env.MANAGER_ADMIN_PASSWORD || "",
authSecret: process.env.MANAGER_AUTH_SECRET || process.env.AUTH_SECRET || "",
};
}
@@ -0,0 +1,125 @@
import { promises as fs } from "node:fs";
import path from "node:path";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import type { BusinessDeployment, BusinessStatus, ProvisionBusinessInput } from "./types";
import { getManagerConfig } from "./manager-config";
const run = promisify(execFile);
function registryPath() {
return path.join(getManagerConfig().stackRoot, "registry.json");
}
function containerName(slug: string) {
return `nextchair-${slug}`;
}
function healthState(value: string | undefined): BusinessStatus["health"] {
return value === "healthy" || value === "unhealthy" || value === "starting" ? value : "unknown";
}
async function readRegistry(): Promise<BusinessDeployment[]> {
try {
return JSON.parse(await fs.readFile(registryPath(), "utf8")) as BusinessDeployment[];
} catch {
return [];
}
}
async function writeRegistry(items: BusinessDeployment[]) {
await fs.mkdir(path.dirname(registryPath()), { recursive: true });
await fs.writeFile(registryPath(), `${JSON.stringify(items, null, 2)}\n`, { mode: 0o600 });
}
async function docker(args: string[]) {
return run("docker", args, { maxBuffer: 1024 * 1024 * 4 });
}
async function inspectContainer(slug: string) {
try {
const result = await docker(["inspect", "-f", "{{.State.Status}}|{{if .State.Health}}{{.State.Health.Status}}{{end}}", containerName(slug)]);
const [container, health] = result.stdout.trim().split("|");
return { container: container === "running" ? "running" : container ? "stopped" : "unknown", health: healthState(health) } as const;
} catch {
return { container: "unknown", health: "unknown" } as const;
}
}
async function databaseStatus() {
try {
const result = await docker(["inspect", "-f", "{{.State.Status}}", "nextchair-mysql"]);
return result.stdout.trim() === "running" ? "online" : "offline";
} catch {
return "unknown";
}
}
async function storageBytes(slug: string) {
try {
const volume = await docker(["volume", "inspect", "-f", "{{.Mountpoint}}", `nextchair-media-${slug}`]);
const mountpoint = volume.stdout.trim();
if (!mountpoint) return null;
const usage = await run("du", ["-sk", mountpoint], { maxBuffer: 1024 * 64 });
const kilobytes = usage.stdout.trim().split(/\s+/)[0];
if (!kilobytes) return null;
const bytes = Number.parseInt(kilobytes, 10) * 1024;
return Number.isFinite(bytes) ? bytes : null;
} catch {
return null;
}
}
export async function listBusinesses(): Promise<BusinessStatus[]> {
const [items, database] = await Promise.all([readRegistry(), databaseStatus()]);
return Promise.all(items.map(async (item) => {
const [runtime, storage] = await Promise.all([inspectContainer(item.slug), storageBytes(item.slug)]);
return { ...item, ...runtime, database, storageBytes: storage };
}));
}
export async function getBusiness(slug: string) {
const business = (await listBusinesses()).find((item) => item.slug === slug);
if (!business) throw new Error("Business deployment not found.");
return business;
}
export async function createBusiness(input: ProvisionBusinessInput) {
const existing = (await readRegistry()).some((item) => item.slug === input.slug);
if (existing) throw new Error("A business with this slug already exists.");
const script = path.join(process.cwd(), "scripts", "create-business.sh");
await run(script, [input.slug, input.domain, input.image, input.name], { maxBuffer: 1024 * 1024 * 4 });
return getBusiness(input.slug);
}
async function compose(slug: string, args: string[]) {
const composeFile = path.join(getManagerConfig().stackRoot, slug, "compose.yaml");
await run("docker", ["compose", "-f", composeFile, ...args], { maxBuffer: 1024 * 1024 * 4 });
}
export async function startBusiness(slug: string) { await compose(slug, ["up", "-d"]); return getBusiness(slug); }
export async function stopBusiness(slug: string) { await compose(slug, ["stop"]); return getBusiness(slug); }
export async function restartBusiness(slug: string) { await compose(slug, ["restart"]); return getBusiness(slug); }
export async function updateBusiness(slug: string) {
// A local tag may not exist in a registry, so a failed pull is allowed to
// fall through to Compose using the already-built local image.
try { await compose(slug, ["pull"]); } catch { /* local/self-hosted image */ }
await compose(slug, ["up", "-d"]);
return getBusiness(slug);
}
export async function getBusinessLogs(slug: string) {
const composeFile = path.join(getManagerConfig().stackRoot, slug, "compose.yaml");
const result = await run("docker", ["compose", "-f", composeFile, "logs", "--tail", "200", `nextchair-${slug}`], { maxBuffer: 1024 * 1024 * 4 });
return result.stdout;
}
export async function removeBusiness(slug: string) {
const items = await readRegistry();
const target = items.find((item) => item.slug === slug);
if (!target) throw new Error("Business deployment not found.");
await compose(slug, ["down"]);
await fs.rm(path.join(getManagerConfig().stackRoot, slug), { recursive: true, force: false });
await fs.rm(path.join(getManagerConfig().caddySitesDir, `${slug}.caddy`), { force: false });
await writeRegistry(items.filter((item) => item.slug !== slug));
}
+22
View File
@@ -0,0 +1,22 @@
export type BusinessDeployment = {
name: string;
slug: string;
domain: string;
image: string;
createdAt: string;
updatedAt: string;
};
export type BusinessStatus = BusinessDeployment & {
container: "running" | "stopped" | "unknown";
health: "healthy" | "unhealthy" | "starting" | "unknown";
database: "online" | "offline" | "unknown";
storageBytes: number | null;
};
export type ProvisionBusinessInput = {
name: string;
slug: string;
domain: string;
image: string;
};
@@ -0,0 +1,113 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createMediaAsset, deleteMediaAsset, listMediaAssets } from "@/services/media/media.service";
import { cleanupTestBusiness, createTestBusiness, prisma } from "@/services/scheduling/__tests__/fixtures";
import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures";
describe("Media Manager service (integration)", () => {
let business: TestBusiness;
beforeEach(async () => {
business = await createTestBusiness();
});
afterEach(async () => {
await cleanupTestBusiness(business);
});
it("creates a media asset and lists it back for its own business", async () => {
const asset = await createMediaAsset(prisma, {
businessId: business.businessId,
url: "/media/branding/test-1.png",
originalFilename: "logo.png",
mimeType: "image/png",
sizeBytes: 12_345,
width: 512,
height: 512,
category: "branding",
uploadedByUserId: null,
});
expect(asset.url).toBe("/media/branding/test-1.png");
const listed = await listMediaAssets(prisma, { businessId: business.businessId });
expect(listed.map((a) => a.id)).toContain(asset.id);
});
it("filters by category and by filename search", async () => {
await createMediaAsset(prisma, {
businessId: business.businessId,
url: "/media/branding/logo.png",
originalFilename: "shopfront-logo.png",
mimeType: "image/png",
sizeBytes: 100,
width: null,
height: null,
category: "branding",
uploadedByUserId: null,
});
await createMediaAsset(prisma, {
businessId: business.businessId,
url: "/media/portfolio/haircut.jpg",
originalFilename: "fresh-fade.jpg",
mimeType: "image/jpeg",
sizeBytes: 200,
width: null,
height: null,
category: "portfolio",
uploadedByUserId: null,
});
const brandingOnly = await listMediaAssets(prisma, { businessId: business.businessId, category: "branding" });
expect(brandingOnly).toHaveLength(1);
expect(brandingOnly[0]?.category).toBe("branding");
const searched = await listMediaAssets(prisma, { businessId: business.businessId, q: "fade" });
expect(searched).toHaveLength(1);
expect(searched[0]?.originalFilename).toBe("fresh-fade.jpg");
});
it("never lets one business delete another business's asset", async () => {
const otherBusiness = await createTestBusiness();
try {
const asset = await createMediaAsset(prisma, {
businessId: otherBusiness.businessId,
url: "/media/branding/other.png",
originalFilename: null,
mimeType: "image/png",
sizeBytes: 10,
width: null,
height: null,
category: "branding",
uploadedByUserId: null,
});
const result = await deleteMediaAsset(prisma, { id: asset.id, businessId: business.businessId });
expect(result.success).toBe(false);
const stillExists = await prisma.mediaAsset.findUnique({ where: { id: asset.id } });
expect(stillExists).not.toBeNull();
} finally {
await cleanupTestBusiness(otherBusiness);
}
});
it("deletes an asset it owns", async () => {
const asset = await createMediaAsset(prisma, {
businessId: business.businessId,
url: "/media/branding/to-delete.png",
originalFilename: null,
mimeType: "image/png",
sizeBytes: 10,
width: null,
height: null,
category: "branding",
uploadedByUserId: null,
});
const result = await deleteMediaAsset(prisma, { id: asset.id, businessId: business.businessId });
expect(result.success).toBe(true);
const gone = await prisma.mediaAsset.findUnique({ where: { id: asset.id } });
expect(gone).toBeNull();
});
});
+95
View File
@@ -0,0 +1,95 @@
import type { PrismaClient } from "@/generated/prisma/client";
import { deleteMediaFile } from "@/lib/media/media-storage";
/**
* The Media Manager's library — see docs/phase-9-summary.md. Backs every
* image field app-wide (branding, loyalty card, staff/service photos,
* gallery/portfolio) through one reusable picker rather than each
* settings page implementing its own uploader.
*/
export interface MediaAssetSummary {
id: string;
url: string;
originalFilename: string | null;
mimeType: string;
sizeBytes: number;
width: number | null;
height: number | null;
category: string | null;
createdAt: Date;
}
interface MediaAssetRow {
id: string;
url: string;
originalFilename: string | null;
mimeType: string;
sizeBytes: number;
width: number | null;
height: number | null;
category: string | null;
createdAt: Date;
}
function toSummary(asset: MediaAssetRow): MediaAssetSummary {
return {
id: asset.id,
url: asset.url,
originalFilename: asset.originalFilename,
mimeType: asset.mimeType,
sizeBytes: asset.sizeBytes,
width: asset.width,
height: asset.height,
category: asset.category,
createdAt: asset.createdAt,
};
}
export async function listMediaAssets(
prisma: PrismaClient,
params: { businessId: string; category?: string; q?: string },
): Promise<MediaAssetSummary[]> {
const assets = await prisma.mediaAsset.findMany({
where: {
businessId: params.businessId,
...(params.category ? { category: params.category } : {}),
...(params.q ? { originalFilename: { contains: params.q } } : {}),
},
orderBy: { createdAt: "desc" },
});
return assets.map(toSummary);
}
export async function createMediaAsset(
prisma: PrismaClient,
params: {
businessId: string;
url: string;
originalFilename: string | null;
mimeType: string;
sizeBytes: number;
width: number | null;
height: number | null;
category: string | null;
uploadedByUserId: string | null;
},
): Promise<MediaAssetSummary> {
const asset = await prisma.mediaAsset.create({ data: params });
return toSummary(asset);
}
/**
* Scoped to businessId so one business can never delete another's asset —
* not a real risk in v1's single-business deployment, but a free
* guarantee matching the rest of the schema's tenancy discipline.
*/
export async function deleteMediaAsset(
prisma: PrismaClient,
params: { id: string; businessId: string },
): Promise<{ success: boolean }> {
const asset = await prisma.mediaAsset.findFirst({ where: { id: params.id, businessId: params.businessId } });
if (!asset) return { success: false };
await prisma.mediaAsset.delete({ where: { id: asset.id } });
await deleteMediaFile(asset.url);
return { success: true };
}
@@ -0,0 +1,226 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { NotificationChannel, NotificationType } from "@/generated/prisma/enums";
import {
cleanupTestBusiness,
createTestBusiness,
createTestCustomer,
createTestGuestCustomer,
createTestStaff,
prisma,
} from "@/services/scheduling/__tests__/fixtures";
import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures";
import {
getNotificationPreferences,
getUnreadNotificationCount,
listInAppNotifications,
markAllNotificationsRead,
markNotificationRead,
notify,
setNotificationPreference,
} from "../notification.service";
describe("NotificationService (integration)", () => {
let business: TestBusiness;
let guestProfileIds: string[];
beforeEach(async () => {
business = await createTestBusiness();
guestProfileIds = [];
});
afterEach(async () => {
await cleanupTestBusiness(business);
if (guestProfileIds.length > 0) {
await prisma.customerProfile.deleteMany({ where: { id: { in: guestProfileIds } } }).catch(() => {});
}
});
describe("notify — creation and idempotency", () => {
it("creates a required notification with EMAIL and IN_APP deliveries for a registered customer", async () => {
const { customerId, userId } = await createTestCustomer(business);
const { created, notification } = await notify(prisma, {
businessId: business.businessId,
type: NotificationType.BOOKING_CONFIRMED,
recipientUserId: userId,
recipientCustomerId: customerId,
bodyText: "Your booking is confirmed.",
idempotencyKey: `test:${userId}:booking-confirmed`,
});
expect(created).toBe(true);
expect(notification.deliveries).toHaveLength(2);
const channels = notification.deliveries.map((d) => d.channel).sort();
expect(channels).toEqual([NotificationChannel.EMAIL, NotificationChannel.IN_APP].sort());
const inApp = notification.deliveries.find((d) => d.channel === NotificationChannel.IN_APP);
expect(inApp?.status).toBe("SENT");
const email = notification.deliveries.find((d) => d.channel === NotificationChannel.EMAIL);
expect(email?.status).toBe("PENDING");
});
it("creates only an EMAIL delivery for a guest customer (no account for IN_APP)", async () => {
const guest = await createTestGuestCustomer();
guestProfileIds.push(guest.customerId);
const { notification } = await notify(prisma, {
businessId: business.businessId,
type: NotificationType.BOOKING_CONFIRMED,
recipientCustomerId: guest.customerId,
bodyText: "Your booking is confirmed.",
idempotencyKey: `test:${guest.customerId}:booking-confirmed`,
});
expect(notification.deliveries).toHaveLength(1);
expect(notification.deliveries[0]!.channel).toBe(NotificationChannel.EMAIL);
});
it("a second notify() call with the same idempotencyKey is a no-op, not a duplicate", async () => {
const { customerId, userId } = await createTestCustomer(business);
const idempotencyKey = `test:${userId}:dedupe`;
const first = await notify(prisma, {
businessId: business.businessId,
type: NotificationType.BOOKING_CONFIRMED,
recipientUserId: userId,
recipientCustomerId: customerId,
bodyText: "Your booking is confirmed.",
idempotencyKey,
});
const second = await notify(prisma, {
businessId: business.businessId,
type: NotificationType.BOOKING_CONFIRMED,
recipientUserId: userId,
recipientCustomerId: customerId,
bodyText: "Your booking is confirmed. (retried)",
idempotencyKey,
});
expect(first.created).toBe(true);
expect(second.created).toBe(false);
expect(second.notification.id).toBe(first.notification.id);
// The retried call's different bodyText never overwrote the original.
expect(second.notification.bodyText).toBe("Your booking is confirmed.");
const count = await prisma.notification.count({ where: { idempotencyKey } });
expect(count).toBe(1);
});
it("throws if neither recipientUserId nor recipientCustomerId is supplied", async () => {
await expect(
notify(prisma, {
businessId: business.businessId,
type: NotificationType.OWNER_NEW_BOOKING,
bodyText: "x",
idempotencyKey: "test:no-recipient",
}),
).rejects.toThrow();
});
});
describe("notify — preference-controlled types", () => {
it("skips the EMAIL delivery for a reminder when the user has disabled email reminders", async () => {
const { customerId, userId } = await createTestCustomer(business);
await setNotificationPreference(prisma, {
userId,
type: NotificationType.BOOKING_REMINDER,
channel: NotificationChannel.EMAIL,
isEnabled: false,
});
const { notification } = await notify(prisma, {
businessId: business.businessId,
type: NotificationType.BOOKING_REMINDER,
recipientUserId: userId,
recipientCustomerId: customerId,
bodyText: "Reminder: your appointment is tomorrow.",
idempotencyKey: `test:${userId}:reminder-disabled`,
});
const channels = notification.deliveries.map((d) => d.channel);
expect(channels).not.toContain(NotificationChannel.EMAIL);
expect(channels).toContain(NotificationChannel.IN_APP);
});
it("still sends a required type's EMAIL even if a same-channel reminder preference happens to be disabled", async () => {
const { customerId, userId } = await createTestCustomer(business);
await setNotificationPreference(prisma, {
userId,
type: NotificationType.BOOKING_REMINDER,
channel: NotificationChannel.EMAIL,
isEnabled: false,
});
const { notification } = await notify(prisma, {
businessId: business.businessId,
type: NotificationType.BOOKING_CANCELLED,
recipientUserId: userId,
recipientCustomerId: customerId,
bodyText: "Your appointment was cancelled.",
idempotencyKey: `test:${userId}:cancelled-required`,
});
expect(notification.deliveries.map((d) => d.channel)).toContain(NotificationChannel.EMAIL);
});
it("setNotificationPreference refuses to touch a required (non-preference-controlled) type", async () => {
const { userId } = await createTestCustomer(business);
await expect(
setNotificationPreference(prisma, {
userId,
type: NotificationType.BOOKING_CONFIRMED,
channel: NotificationChannel.EMAIL,
isEnabled: false,
}),
).rejects.toThrow();
});
it("reminders default to enabled when no preference row exists yet", async () => {
const preferences = await getNotificationPreferences(prisma, (await createTestCustomer(business)).userId);
expect(preferences).toHaveLength(0); // no rows yet — resolveChannels() still includes EMAIL by default
});
});
describe("in-app notification centre", () => {
it("lists, counts, and marks a user's own notifications read — scoped so one user can't touch another's", async () => {
const { staffId, userId: staffUserId } = await createTestStaff(business);
const other = await createTestStaff(business);
const { notification: n1 } = await notify(prisma, {
businessId: business.businessId,
type: NotificationType.STAFF_NEW_APPOINTMENT,
recipientUserId: staffUserId,
bodyText: "New appointment booked.",
idempotencyKey: `test:${staffId}:appt-1`,
});
await notify(prisma, {
businessId: business.businessId,
type: NotificationType.STAFF_NEW_APPOINTMENT,
recipientUserId: staffUserId,
bodyText: "Another appointment booked.",
idempotencyKey: `test:${staffId}:appt-2`,
});
expect(await getUnreadNotificationCount(prisma, staffUserId)).toBe(2);
expect(await getUnreadNotificationCount(prisma, other.userId)).toBe(0);
const list = await listInAppNotifications(prisma, { userId: staffUserId });
expect(list).toHaveLength(2);
// Another user can't mark someone else's notification read.
const wrongUserResult = await markNotificationRead(prisma, { notificationId: n1.id, userId: other.userId });
expect(wrongUserResult).toBe(false);
expect(await getUnreadNotificationCount(prisma, staffUserId)).toBe(2);
const rightUserResult = await markNotificationRead(prisma, { notificationId: n1.id, userId: staffUserId });
expect(rightUserResult).toBe(true);
expect(await getUnreadNotificationCount(prisma, staffUserId)).toBe(1);
const markedAll = await markAllNotificationsRead(prisma, staffUserId);
expect(markedAll).toBe(1);
expect(await getUnreadNotificationCount(prisma, staffUserId)).toBe(0);
const unreadOnly = await listInAppNotifications(prisma, { userId: staffUserId, unreadOnly: true });
expect(unreadOnly).toHaveLength(0);
});
});
});
@@ -0,0 +1,88 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
cleanupTestBusiness,
createTestBusiness,
createTestCustomer,
prisma,
} from "@/services/scheduling/__tests__/fixtures";
import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures";
import { listPushSubscriptionsForUser, subscribeToPush, unsubscribeFromPush } from "../push-subscription.service";
describe("PushSubscription service (integration)", () => {
let business: TestBusiness;
beforeEach(async () => {
business = await createTestBusiness();
});
afterEach(async () => {
await cleanupTestBusiness(business);
});
it("subscribing twice with the same endpoint upserts rather than duplicating", async () => {
const { customerId, userId } = await createTestCustomer(business);
const endpoint = `https://push.example.com/${crypto.randomUUID()}`;
await subscribeToPush(prisma, {
businessId: business.businessId,
userId,
customerId,
endpoint,
p256dhKey: "key-1",
authKey: "auth-1",
deviceLabel: "iPhone",
});
await subscribeToPush(prisma, {
businessId: business.businessId,
userId,
customerId,
endpoint,
p256dhKey: "key-2",
authKey: "auth-2",
deviceLabel: "iPhone (updated)",
});
const rows = await prisma.pushSubscription.findMany({ where: { endpoint } });
expect(rows).toHaveLength(1);
expect(rows[0]!.p256dhKey).toBe("key-2");
expect(rows[0]!.deviceLabel).toBe("iPhone (updated)");
});
it("unsubscribe only affects the owning user's own subscription, never someone else's", async () => {
const owner = await createTestCustomer(business);
const other = await createTestCustomer(business);
const endpoint = `https://push.example.com/${crypto.randomUUID()}`;
await subscribeToPush(prisma, {
businessId: business.businessId,
userId: owner.userId,
customerId: owner.customerId,
endpoint,
p256dhKey: "key",
authKey: "auth",
});
const wrongUserResult = await unsubscribeFromPush(prisma, { endpoint, ownerUserId: other.userId });
expect(wrongUserResult).toBe(false);
let row = await prisma.pushSubscription.findUniqueOrThrow({ where: { endpoint } });
expect(row.revokedAt).toBeNull();
const rightUserResult = await unsubscribeFromPush(prisma, { endpoint, ownerUserId: owner.userId });
expect(rightUserResult).toBe(true);
row = await prisma.pushSubscription.findUniqueOrThrow({ where: { endpoint } });
expect(row.revokedAt).not.toBeNull();
});
it("listPushSubscriptionsForUser only returns active (non-revoked) subscriptions for that user", async () => {
const { userId, customerId } = await createTestCustomer(business);
const activeEndpoint = `https://push.example.com/${crypto.randomUUID()}`;
const revokedEndpoint = `https://push.example.com/${crypto.randomUUID()}`;
await subscribeToPush(prisma, { businessId: business.businessId, userId, customerId, endpoint: activeEndpoint, p256dhKey: "k", authKey: "a" });
await subscribeToPush(prisma, { businessId: business.businessId, userId, customerId, endpoint: revokedEndpoint, p256dhKey: "k", authKey: "a" });
await unsubscribeFromPush(prisma, { endpoint: revokedEndpoint, ownerUserId: userId });
const active = await listPushSubscriptionsForUser(prisma, userId);
expect(active.map((s) => s.endpoint)).toEqual([activeEndpoint]);
});
});
@@ -0,0 +1,261 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { NotificationType } from "@/generated/prisma/enums";
import {
cleanupTestBusiness,
createTestBusiness,
createTestCustomer,
prisma,
} from "@/services/scheduling/__tests__/fixtures";
import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures";
import { MockEmailProvider } from "../providers/mock-email-provider";
import { MockPushProvider } from "../providers/mock-push-provider";
import { notify } from "../notification.service";
import { subscribeToPush } from "../push-subscription.service";
import { processDueNotificationDeliveries } from "../reminder-worker";
/**
* Exercises the background processor end to end against a real database,
* with MockEmailProvider standing in for Brevo — no real network calls,
* matching the Phase 7 brief's "no real API calls in automated tests".
*/
describe("processDueNotificationDeliveries (integration)", () => {
let business: TestBusiness;
let provider: MockEmailProvider;
beforeEach(async () => {
business = await createTestBusiness();
provider = new MockEmailProvider();
});
afterEach(async () => {
await cleanupTestBusiness(business);
});
async function createDueEmailNotification(customerId: string, idempotencyKey: string) {
const { notification } = await notify(prisma, {
businessId: business.businessId,
type: NotificationType.LOYALTY_STAMP_ADDED,
recipientCustomerId: customerId,
bodyText: "You've earned a stamp.",
idempotencyKey,
});
return notification;
}
it("sends a due PENDING EMAIL delivery and marks it SENT with the provider message id", async () => {
const { customerId } = await createTestCustomer(business);
const notification = await createDueEmailNotification(customerId, "test:worker:sent");
const result = await processDueNotificationDeliveries(prisma, { emailProvider: provider, businessId: business.businessId });
expect(result.sent).toBe(1);
expect(provider.sentEmails).toHaveLength(1);
const delivery = await prisma.notificationDelivery.findFirstOrThrow({
where: { notificationId: notification.id, channel: "EMAIL" },
});
expect(delivery.status).toBe("SENT");
expect(delivery.providerMessageId).toBe("mock-message-id");
expect(delivery.providerName).toBe("mock");
expect(delivery.attemptCount).toBe(1);
});
it("does not send a delivery scheduled for the future", async () => {
const { customerId } = await createTestCustomer(business);
const { notification } = await notify(prisma, {
businessId: business.businessId,
type: NotificationType.BOOKING_REMINDER,
recipientCustomerId: customerId,
bodyText: "Reminder",
idempotencyKey: "test:worker:future",
scheduledFor: new Date(Date.now() + 60 * 60_000),
});
const result = await processDueNotificationDeliveries(prisma, { emailProvider: provider, businessId: business.businessId });
expect(result.claimed).toBe(0);
expect(provider.sentEmails).toHaveLength(0);
const delivery = await prisma.notificationDelivery.findFirstOrThrow({
where: { notificationId: notification.id, channel: "EMAIL" },
});
expect(delivery.status).toBe("SCHEDULED");
});
it("retries a retryable failure with backoff, then eventually gives up after MAX_ATTEMPTS", async () => {
const { customerId } = await createTestCustomer(business);
const notification = await createDueEmailNotification(customerId, "test:worker:retry");
provider.setDefaultResult({ status: "failed", retryable: true, reason: "Connection timed out" });
// Attempt 1: retried with backoff — scheduledFor pushed into the future, so it won't be "due" again immediately.
const first = await processDueNotificationDeliveries(prisma, { emailProvider: provider, now: new Date(), businessId: business.businessId });
expect(first.retried).toBe(1);
let delivery = await prisma.notificationDelivery.findFirstOrThrow({ where: { notificationId: notification.id, channel: "EMAIL" } });
expect(delivery.status).toBe("PENDING");
expect(delivery.attemptCount).toBe(1);
expect(delivery.scheduledFor).not.toBeNull();
// Simulate time passing past the backoff window on each subsequent attempt.
for (let i = 0; i < 2; i++) {
const future = new Date(delivery.scheduledFor!.getTime() + 1000);
const run = await processDueNotificationDeliveries(prisma, { emailProvider: provider, now: future, businessId: business.businessId });
expect(run.retried).toBe(1);
delivery = await prisma.notificationDelivery.findFirstOrThrow({ where: { notificationId: notification.id, channel: "EMAIL" } });
}
expect(delivery.attemptCount).toBe(3);
// 4th attempt (MAX_ATTEMPTS) exhausts the retry budget — permanent FAILED, not retried again.
const finalRun = await processDueNotificationDeliveries(prisma, {
emailProvider: provider,
now: new Date(delivery.scheduledFor!.getTime() + 1000),
businessId: business.businessId,
});
expect(finalRun.failed).toBe(1);
expect(finalRun.retried).toBe(0);
delivery = await prisma.notificationDelivery.findFirstOrThrow({ where: { notificationId: notification.id, channel: "EMAIL" } });
expect(delivery.status).toBe("FAILED");
expect(delivery.attemptCount).toBe(4);
});
it("never retries a permanent (non-retryable) failure, even on the first attempt", async () => {
const { customerId } = await createTestCustomer(business);
const notification = await createDueEmailNotification(customerId, "test:worker:permanent");
provider.setDefaultResult({ status: "failed", retryable: false, reason: "Mailbox does not exist" });
const result = await processDueNotificationDeliveries(prisma, { emailProvider: provider, businessId: business.businessId });
expect(result.failed).toBe(1);
expect(result.retried).toBe(0);
const delivery = await prisma.notificationDelivery.findFirstOrThrow({ where: { notificationId: notification.id, channel: "EMAIL" } });
expect(delivery.status).toBe("FAILED");
expect(delivery.attemptCount).toBe(1);
expect(delivery.failureReason).toBe("Mailbox does not exist");
});
it("fails a delivery cleanly when the recipient has no email address at all, without calling the provider", async () => {
// NotificationService.notify() never creates an EMAIL delivery for a recipient with no
// resolvable address in the first place (see resolveChannels) — so this scenario is only
// reachable if the address changes between creation and processing (e.g. a customer record
// edited afterward). Constructed directly here to exercise the worker's own defensive guard
// in isolation, bypassing notify()'s channel resolution.
const orphanCustomer = await prisma.customerProfile.create({
data: { firstName: "No", lastName: "Email", email: "", phone: null, isGuest: true },
});
try {
const notification = await prisma.notification.create({
data: {
businessId: business.businessId,
recipientCustomerId: orphanCustomer.id,
type: "LOYALTY_STAMP_ADDED",
bodyText: "You've earned a stamp.",
idempotencyKey: "test:worker:no-email",
deliveries: { create: [{ channel: "EMAIL", status: "PENDING" }] },
},
});
const result = await processDueNotificationDeliveries(prisma, { emailProvider: provider, businessId: business.businessId });
expect(result.failed).toBe(1);
expect(provider.sentEmails).toHaveLength(0);
const delivery = await prisma.notificationDelivery.findFirstOrThrow({ where: { notificationId: notification.id, channel: "EMAIL" } });
expect(delivery.status).toBe("FAILED");
} finally {
await prisma.notification.deleteMany({ where: { recipientCustomerId: orphanCustomer.id } });
await prisma.customerProfile.delete({ where: { id: orphanCustomer.id } });
}
});
it("does not double-send when a delivery has already been claimed (simulated concurrent worker)", async () => {
const { customerId } = await createTestCustomer(business);
const notification = await createDueEmailNotification(customerId, "test:worker:concurrent");
const delivery = await prisma.notificationDelivery.findFirstOrThrow({ where: { notificationId: notification.id, channel: "EMAIL" } });
// Simulate another worker having already claimed this row a moment ago.
await prisma.notificationDelivery.update({ where: { id: delivery.id }, data: { status: "PROCESSING" } });
const result = await processDueNotificationDeliveries(prisma, { emailProvider: provider, businessId: business.businessId });
expect(result.claimed).toBe(0);
expect(provider.sentEmails).toHaveLength(0);
});
it("raises OWNER_REPEATED_DELIVERY_FAILURE once several EMAIL deliveries fail for the same business in one run", async () => {
const role = await prisma.role.create({
data: { businessId: business.businessId, name: "Test Owner", permissions: { create: [{ permission: "VIEW_REPORTING" }] } },
});
const ownerUser = await prisma.user.create({
data: {
email: `${crypto.randomUUID()}@test.local`,
staffProfile: { create: { businessId: business.businessId, roleId: role.id } },
},
});
provider.setDefaultResult({ status: "failed", retryable: false, reason: "Rejected" });
const customers = await Promise.all([createTestCustomer(business), createTestCustomer(business), createTestCustomer(business)]);
for (const [i, c] of customers.entries()) {
await createDueEmailNotification(c.customerId, `test:worker:repeated-failure-${i}`);
}
const result = await processDueNotificationDeliveries(prisma, { emailProvider: provider, businessId: business.businessId });
expect(result.failed).toBe(3);
const ownerNotice = await prisma.notification.findFirst({
where: { recipientUserId: ownerUser.id, type: "OWNER_REPEATED_DELIVERY_FAILURE" },
});
expect(ownerNotice).not.toBeNull();
});
describe("PUSH channel", () => {
let pushProvider: MockPushProvider;
beforeEach(() => {
pushProvider = new MockPushProvider();
});
async function createDuePushNotification(customerId: string, userId: string, idempotencyKey: string) {
const { notification } = await notify(prisma, {
businessId: business.businessId,
type: NotificationType.LOYALTY_STAMP_ADDED,
recipientUserId: userId,
recipientCustomerId: customerId,
bodyText: "You've earned a stamp.",
idempotencyKey,
});
return notification;
}
it("fans out to every active subscription and marks the delivery SENT if at least one succeeds", async () => {
const { customerId, userId } = await createTestCustomer(business);
await subscribeToPush(prisma, { businessId: business.businessId, userId, customerId, endpoint: "https://push.example.com/device-1", p256dhKey: "k1", authKey: "a1" });
await subscribeToPush(prisma, { businessId: business.businessId, userId, customerId, endpoint: "https://push.example.com/device-2", p256dhKey: "k2", authKey: "a2" });
const notification = await createDuePushNotification(customerId, userId, "test:worker:push-fanout");
const result = await processDueNotificationDeliveries(prisma, { emailProvider: provider, pushProvider, businessId: business.businessId });
expect(pushProvider.sentPushes).toHaveLength(2);
const delivery = await prisma.notificationDelivery.findFirstOrThrow({ where: { notificationId: notification.id, channel: "PUSH" } });
expect(delivery.status).toBe("SENT");
expect(result.sent).toBeGreaterThanOrEqual(1);
});
it("revokes a subscription the push service reports as gone (410), independent of the overall delivery outcome", async () => {
const { customerId, userId } = await createTestCustomer(business);
await subscribeToPush(prisma, { businessId: business.businessId, userId, customerId, endpoint: "https://push.example.com/stale-device", p256dhKey: "k1", authKey: "a1" });
pushProvider.setDefaultResult({ status: "failed", retryable: false, subscriptionInvalid: true, reason: "Gone" });
await createDuePushNotification(customerId, userId, "test:worker:push-revoke");
await processDueNotificationDeliveries(prisma, { emailProvider: provider, pushProvider, businessId: business.businessId });
const subscription = await prisma.pushSubscription.findUniqueOrThrow({ where: { endpoint: "https://push.example.com/stale-device" } });
expect(subscription.revokedAt).not.toBeNull();
});
it("fails cleanly with no push provider configured, without touching subscriptions", async () => {
const { customerId, userId } = await createTestCustomer(business);
await subscribeToPush(prisma, { businessId: business.businessId, userId, customerId, endpoint: "https://push.example.com/no-provider", p256dhKey: "k", authKey: "a" });
const notification = await createDuePushNotification(customerId, userId, "test:worker:push-no-provider");
await processDueNotificationDeliveries(prisma, { emailProvider: provider, businessId: business.businessId });
const delivery = await prisma.notificationDelivery.findFirstOrThrow({ where: { notificationId: notification.id, channel: "PUSH" } });
expect(delivery.status).toBe("PENDING"); // retryable — a future run with a configured provider can still succeed
const subscription = await prisma.pushSubscription.findUniqueOrThrow({ where: { endpoint: "https://push.example.com/no-provider" } });
expect(subscription.revokedAt).toBeNull();
});
});
});
@@ -0,0 +1,129 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { NotificationType } from "@/generated/prisma/enums";
import {
cleanupTestBusiness,
createTestBusiness,
createTestCustomer,
prisma,
} from "@/services/scheduling/__tests__/fixtures";
import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures";
import { MockEmailProvider } from "../providers/mock-email-provider";
import { notify } from "../notification.service";
import { processDueNotificationDeliveries } from "../reminder-worker";
import { renderEmailForNotification } from "../render-email";
/**
* The follow-up-to-Phase-8 behaviour: every outgoing email's visible
* sender NAME is the installed business's own name, resolved fresh per
* send — never a global lookup, never cached across requests/business
* renames. See docs/phase-8-email-sender-summary.md.
*/
describe("business-name-as-sender (integration)", () => {
let business: TestBusiness;
let provider: MockEmailProvider;
beforeEach(async () => {
business = await createTestBusiness();
provider = new MockEmailProvider();
});
afterEach(async () => {
await cleanupTestBusiness(business);
});
it("resolves the installed business's name as the sender for a booking-confirmation-style email", async () => {
const { customerId } = await createTestCustomer(business);
const { notification } = await notify(prisma, {
businessId: business.businessId,
type: NotificationType.LOYALTY_STAMP_ADDED,
recipientCustomerId: customerId,
bodyText: "You've earned a stamp.",
idempotencyKey: "test:sender:booking",
});
const rendered = await renderEmailForNotification(prisma, notification);
expect(rendered?.sender.name).toBe("Test Barbershop");
expect(rendered?.sender.name).not.toBe("NextChair");
});
it("resolves the business name for a staff-invitation notification (falls through to the generic template, same sender resolution)", async () => {
const { userId } = await createTestCustomer(business);
const { notification } = await notify(prisma, {
businessId: business.businessId,
type: NotificationType.STAFF_INVITATION_RECEIVED,
recipientUserId: userId,
subject: "You've been invited",
bodyText: "You've been invited to join the team.",
idempotencyKey: "test:sender:staff-invite",
});
const rendered = await renderEmailForNotification(prisma, notification);
expect(rendered?.sender.name).toBe("Test Barbershop");
});
it("passes the resolved business name all the way through the background processor to the email provider", async () => {
const { customerId } = await createTestCustomer(business);
await notify(prisma, {
businessId: business.businessId,
type: NotificationType.LOYALTY_STAMP_ADDED,
recipientCustomerId: customerId,
bodyText: "You've earned a stamp.",
idempotencyKey: "test:sender:worker",
});
const result = await processDueNotificationDeliveries(prisma, { emailProvider: provider, businessId: business.businessId });
expect(result.sent).toBe(1);
expect(provider.sentEmails).toHaveLength(1);
expect(provider.sentEmails[0]?.sender.name).toBe("Test Barbershop");
});
it("uses the updated business name for the very next email after a rename — no restart, no stale cache", async () => {
const { customerId } = await createTestCustomer(business);
await notify(prisma, {
businessId: business.businessId,
type: NotificationType.LOYALTY_STAMP_ADDED,
recipientCustomerId: customerId,
bodyText: "First stamp.",
idempotencyKey: "test:sender:rename-before",
});
await processDueNotificationDeliveries(prisma, { emailProvider: provider, businessId: business.businessId });
expect(provider.sentEmails[0]?.sender.name).toBe("Test Barbershop");
await prisma.business.update({ where: { id: business.businessId }, data: { name: "Renamed Barbershop" } });
await notify(prisma, {
businessId: business.businessId,
type: NotificationType.LOYALTY_STAMP_ADDED,
recipientCustomerId: customerId,
bodyText: "Second stamp.",
idempotencyKey: "test:sender:rename-after",
});
await processDueNotificationDeliveries(prisma, { emailProvider: provider, businessId: business.businessId });
expect(provider.sentEmails).toHaveLength(2);
expect(provider.sentEmails[1]?.sender.name).toBe("Renamed Barbershop");
});
it("never lets one business's notification resolve another business's name (no accidental cross-business branding)", async () => {
const otherBusiness = await createTestBusiness();
try {
await prisma.business.update({ where: { id: otherBusiness.businessId }, data: { name: "Other Business Name" } });
const { customerId } = await createTestCustomer(business);
await notify(prisma, {
businessId: business.businessId,
type: NotificationType.LOYALTY_STAMP_ADDED,
recipientCustomerId: customerId,
bodyText: "Stamp for business A.",
idempotencyKey: "test:sender:isolation",
});
const result = await processDueNotificationDeliveries(prisma, { emailProvider: provider, businessId: business.businessId });
expect(result.sent).toBe(1);
expect(provider.sentEmails[0]?.sender.name).toBe("Test Barbershop");
expect(provider.sentEmails[0]?.sender.name).not.toBe("Other Business Name");
} finally {
await cleanupTestBusiness(otherBusiness);
}
});
});
@@ -0,0 +1,47 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { resolveSenderName } from "../sender-identity";
describe("resolveSenderName", () => {
const originalEmailFromName = process.env.EMAIL_FROM_NAME;
beforeEach(() => {
delete process.env.EMAIL_FROM_NAME;
});
afterEach(() => {
if (originalEmailFromName === undefined) delete process.env.EMAIL_FROM_NAME;
else process.env.EMAIL_FROM_NAME = originalEmailFromName;
});
it("uses the business name when present", () => {
expect(resolveSenderName("Blake Heritage Barbershop")).toBe("Blake Heritage Barbershop");
});
it("prefers the business name even when EMAIL_FROM_NAME is also set", () => {
process.env.EMAIL_FROM_NAME = "Some Configured Name";
expect(resolveSenderName("Riverside Barbershop")).toBe("Riverside Barbershop");
});
it("falls back to EMAIL_FROM_NAME only when no business name is available", () => {
process.env.EMAIL_FROM_NAME = "Emergency Fallback Name";
expect(resolveSenderName(null)).toBe("Emergency Fallback Name");
expect(resolveSenderName(undefined)).toBe("Emergency Fallback Name");
expect(resolveSenderName("")).toBe("Emergency Fallback Name");
expect(resolveSenderName(" ")).toBe("Emergency Fallback Name");
});
it("falls back to the neutral name when neither a business name nor EMAIL_FROM_NAME is available", () => {
expect(resolveSenderName(null)).toBe("Booking Notifications");
expect(resolveSenderName(undefined)).toBe("Booking Notifications");
});
it("never defaults to the internal product name at any fallback tier", () => {
// Tier 1 (business name present), tier 3 (nothing configured at all) —
// neither branch has any "NextChair" literal anywhere in this
// function's source, so this is really a regression guard: if someone
// later "helpfully" reintroduces a NextChair default, this fails.
expect(resolveSenderName("Blake Heritage Barbershop")).not.toBe("NextChair");
expect(resolveSenderName(null)).not.toBe("NextChair");
expect(resolveSenderName(undefined)).not.toBe("NextChair");
});
});
@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { NotificationType } from "@/generated/prisma/enums";
import { isPreferenceControlled, recipientKindOf } from "../types";
describe("recipientKindOf", () => {
it("classifies every type as exactly one of CUSTOMER/STAFF/OWNER", () => {
for (const type of Object.values(NotificationType)) {
expect(["CUSTOMER", "STAFF", "OWNER"]).toContain(recipientKindOf(type));
}
});
it("classifies a representative type from each group correctly", () => {
expect(recipientKindOf(NotificationType.BOOKING_CONFIRMED)).toBe("CUSTOMER");
expect(recipientKindOf(NotificationType.STAFF_NEW_APPOINTMENT)).toBe("STAFF");
expect(recipientKindOf(NotificationType.OWNER_NEW_BOOKING)).toBe("OWNER");
});
});
describe("isPreferenceControlled", () => {
it("only the two reminder types are preference-controlled", () => {
expect(isPreferenceControlled(NotificationType.BOOKING_REMINDER)).toBe(true);
expect(isPreferenceControlled(NotificationType.STAFF_APPOINTMENT_REMINDER)).toBe(true);
});
it("every required transactional type is not preference-controlled", () => {
const required = Object.values(NotificationType).filter(
(t) => t !== NotificationType.BOOKING_REMINDER && t !== NotificationType.STAFF_APPOINTMENT_REMINDER,
);
for (const type of required) {
expect(isPreferenceControlled(type)).toBe(false);
}
});
});
@@ -0,0 +1,289 @@
import { Prisma } from "@/generated/prisma/client";
import type { PrismaClient } from "@/generated/prisma/client";
import { NotificationChannel, NotificationDeliveryStatus, NotificationType } from "@/generated/prisma/enums";
import { isPreferenceControlled } from "./types";
/**
* The notification domain layer: creates Notification + NotificationDelivery
* rows, resolves which channels actually apply for a given recipient,
* applies preferences, and serves the in-app notification centre.
* Deliberately does NOT decide *when* something is notification-worthy —
* that's every appointment/loyalty workflow's job (see docs/phase-7-summary.md,
* "Notification provider architecture"); this file only owns what happens
* once a workflow has already decided "notify X about Y". Sending
* EMAIL/PUSH deliveries (the Brevo/web-push adapters) and processing the
* PENDING/SCHEDULED queue is a separate concern — see
* src/services/notifications/providers and reminder-worker.ts.
*/
export interface NotifyParams {
businessId: string;
type: NotificationType;
/** Set for staff/owner/registered-customer recipients. At least one of recipientUserId/recipientCustomerId must be set. */
recipientUserId?: string;
/** Set whenever the recipient IS a customer (guest or registered). */
recipientCustomerId?: string;
appointmentId?: string;
loyaltyRewardId?: string;
subject?: string;
bodyText: string;
deepLinkPath?: string;
/** Prevents duplicate creation of the same logical event — see Notification.idempotencyKey in schema.prisma. */
idempotencyKey: string;
/** Omit for "send as soon as the worker picks it up" (every non-reminder type). When set, EMAIL/PUSH deliveries are created directly as SCHEDULED for this time rather than PENDING — see reminder-scheduling.ts, which is the only caller that sets this. */
scheduledFor?: Date;
}
type NotificationWithDeliveries = Prisma.NotificationGetPayload<{ include: { deliveries: true } }>;
export interface NotifyResult {
/** False if a notification with this idempotencyKey already existed — notify() is then a no-op, not a duplicate. */
created: boolean;
notification: NotificationWithDeliveries;
}
function isUniqueConstraintViolation(error: unknown, target: string): boolean {
return (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === "P2002" &&
Array.isArray(error.meta?.target) &&
(error.meta.target as string[]).includes(target)
);
}
/** Exported for reminder-worker.ts, which needs the same "where does this recipient's email actually live" resolution to address the outgoing message — not just to decide whether an EMAIL delivery exists. */
export async function resolveEmailAddress(
prisma: PrismaClient,
params: Pick<NotifyParams, "recipientCustomerId" | "recipientUserId">,
): Promise<string | null> {
if (params.recipientCustomerId) {
const customer = await prisma.customerProfile.findUnique({
where: { id: params.recipientCustomerId },
select: { email: true },
});
if (customer?.email) return customer.email;
}
if (params.recipientUserId) {
const user = await prisma.user.findUnique({ where: { id: params.recipientUserId }, select: { email: true } });
return user?.email ?? null;
}
return null;
}
async function hasActivePushSubscription(
prisma: PrismaClient,
params: Pick<NotifyParams, "recipientCustomerId" | "recipientUserId">,
): Promise<boolean> {
if (!params.recipientUserId && !params.recipientCustomerId) return false;
const count = await prisma.pushSubscription.count({
where: {
revokedAt: null,
OR: [
...(params.recipientUserId ? [{ userId: params.recipientUserId }] : []),
...(params.recipientCustomerId ? [{ customerId: params.recipientCustomerId }] : []),
],
},
});
return count > 0;
}
/** Exported for reminder-worker.ts — every active (non-revoked) subscription for this recipient, since a person can have several devices and a push delivery fans out to all of them. */
export async function resolveActivePushSubscriptions(
prisma: PrismaClient,
params: Pick<NotifyParams, "recipientCustomerId" | "recipientUserId">,
) {
if (!params.recipientUserId && !params.recipientCustomerId) return [];
return prisma.pushSubscription.findMany({
where: {
revokedAt: null,
OR: [
...(params.recipientUserId ? [{ userId: params.recipientUserId }] : []),
...(params.recipientCustomerId ? [{ customerId: params.recipientCustomerId }] : []),
],
},
});
}
async function isChannelEnabled(
prisma: PrismaClient,
params: { userId: string | undefined; type: NotificationType; channel: NotificationChannel },
): Promise<boolean> {
if (!isPreferenceControlled(params.type)) return true; // required — no opt-out, see types.ts
if (!params.userId) return true; // guest, no account to store a preference against — reminders still send by default
const preference = await prisma.notificationPreference.findUnique({
where: { userId_type_channel: { userId: params.userId, type: params.type, channel: params.channel } },
});
return preference?.isEnabled ?? true; // sensible default: on unless the user explicitly turned it off
}
async function resolveChannels(
prisma: PrismaClient,
params: NotifyParams,
): Promise<NotificationChannel[]> {
const channels: NotificationChannel[] = [];
// IN_APP — only meaningful for a recipient with an account to sign into.
if (params.recipientUserId) {
channels.push(NotificationChannel.IN_APP);
}
const emailAddress = await resolveEmailAddress(prisma, params);
if (emailAddress && (await isChannelEnabled(prisma, { userId: params.recipientUserId, type: params.type, channel: NotificationChannel.EMAIL }))) {
channels.push(NotificationChannel.EMAIL);
}
if (
(await hasActivePushSubscription(prisma, params)) &&
(await isChannelEnabled(prisma, { userId: params.recipientUserId, type: params.type, channel: NotificationChannel.PUSH }))
) {
channels.push(NotificationChannel.PUSH);
}
return channels;
}
/**
* Creates a Notification and one NotificationDelivery per applicable
* channel — idempotent on `idempotencyKey`: a second call with the same
* key is a no-op that returns the original row, never a duplicate (see
* the unique index in schema.prisma). IN_APP deliveries are immediately
* SENT (there's no send step, just a DB row); EMAIL/PUSH are left PENDING
* for the background processor (see reminder-worker.ts) to pick up.
*/
export async function notify(prisma: PrismaClient, params: NotifyParams): Promise<NotifyResult> {
if (!params.recipientUserId && !params.recipientCustomerId) {
throw new Error("notify() requires at least one of recipientUserId or recipientCustomerId.");
}
const existing = await prisma.notification.findUnique({
where: { idempotencyKey: params.idempotencyKey },
include: { deliveries: true },
});
if (existing) return { created: false, notification: existing };
const channels = await resolveChannels(prisma, params);
try {
const notification = await prisma.notification.create({
data: {
businessId: params.businessId,
recipientUserId: params.recipientUserId,
recipientCustomerId: params.recipientCustomerId,
type: params.type,
appointmentId: params.appointmentId,
loyaltyRewardId: params.loyaltyRewardId,
subject: params.subject,
bodyText: params.bodyText,
deepLinkPath: params.deepLinkPath,
idempotencyKey: params.idempotencyKey,
deliveries: {
create: channels.map((channel) => {
if (channel === NotificationChannel.IN_APP) {
return { channel, status: NotificationDeliveryStatus.SENT, sentAt: new Date() };
}
return params.scheduledFor
? { channel, status: NotificationDeliveryStatus.SCHEDULED, scheduledFor: params.scheduledFor }
: { channel, status: NotificationDeliveryStatus.PENDING };
}),
},
},
include: { deliveries: true },
});
return { created: true, notification };
} catch (error) {
// A racing duplicate notify() call for the same event hits the unique
// constraint instead of the findUnique above seeing it — same outcome.
if (isUniqueConstraintViolation(error, "idempotencyKey")) {
const raced = await prisma.notification.findUniqueOrThrow({
where: { idempotencyKey: params.idempotencyKey },
include: { deliveries: true },
});
return { created: false, notification: raced };
}
throw error;
}
}
/**
* Same as notify(), except a failure here is caught and logged rather than
* thrown — for every call site in an appointment/loyalty workflow, where a
* notification failing to queue must never roll back or fail an otherwise-
* successful booking/cancellation/reschedule/loyalty transaction (an
* explicit Phase 7 requirement). Always call this, never notify()
* directly, from booking.service.ts/loyalty.service.ts/etc.
*/
export async function notifySafely(prisma: PrismaClient, params: NotifyParams): Promise<void> {
try {
await notify(prisma, params);
} catch (error) {
console.error(
`[NotificationService] notify() failed — type=${params.type} idempotencyKey=${params.idempotencyKey}`,
error,
);
}
}
// ── In-app notification centre ──────────────────────────────────────────
// Every query here is scoped by recipientUserId so one user's notifications
// can never be read or mutated through another user's session.
export async function listInAppNotifications(
prisma: PrismaClient,
params: { userId: string; unreadOnly?: boolean; limit?: number },
) {
return prisma.notification.findMany({
where: {
recipientUserId: params.userId,
deliveries: { some: { channel: NotificationChannel.IN_APP } },
...(params.unreadOnly ? { readAt: null } : {}),
},
orderBy: { createdAt: "desc" },
take: params.limit ?? 50,
});
}
export async function getUnreadNotificationCount(prisma: PrismaClient, userId: string): Promise<number> {
return prisma.notification.count({
where: { recipientUserId: userId, readAt: null, deliveries: { some: { channel: NotificationChannel.IN_APP } } },
});
}
/** Returns false (no-op) if the notification doesn't exist or doesn't belong to this user, rather than throwing — mirrors the rest of this codebase's "not found vs not yours" ambiguity being fine for a mark-read action. */
export async function markNotificationRead(
prisma: PrismaClient,
params: { notificationId: string; userId: string },
): Promise<boolean> {
const result = await prisma.notification.updateMany({
where: { id: params.notificationId, recipientUserId: params.userId },
data: { readAt: new Date() },
});
return result.count > 0;
}
export async function markAllNotificationsRead(prisma: PrismaClient, userId: string): Promise<number> {
const result = await prisma.notification.updateMany({
where: { recipientUserId: userId, readAt: null },
data: { readAt: new Date() },
});
return result.count;
}
// ── Preferences ──────────────────────────────────────────────────────────
export async function getNotificationPreferences(prisma: PrismaClient, userId: string) {
return prisma.notificationPreference.findMany({ where: { userId } });
}
export async function setNotificationPreference(
prisma: PrismaClient,
params: { userId: string; type: NotificationType; channel: NotificationChannel; isEnabled: boolean },
) {
if (!isPreferenceControlled(params.type)) {
throw new Error(`${params.type} is a required notification type and cannot be disabled.`);
}
return prisma.notificationPreference.upsert({
where: { userId_type_channel: { userId: params.userId, type: params.type, channel: params.channel } },
create: { userId: params.userId, type: params.type, channel: params.channel, isEnabled: params.isEnabled },
update: { isEnabled: params.isEnabled },
});
}
@@ -0,0 +1,88 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const sendMail = vi.fn();
vi.mock("nodemailer", () => ({
default: { createTransport: vi.fn(() => ({ sendMail })) },
}));
const { BrevoEmailProvider } = await import("../brevo-email-provider");
/**
* Exercises BrevoEmailProvider's retry classification (see isRetryable in
* brevo-email-provider.ts) with a mocked nodemailer transporter — no real
* SMTP connection is ever made in this test file, matching the Phase 7
* brief's "no real API calls in automated tests" requirement.
*/
describe("BrevoEmailProvider", () => {
const provider = new BrevoEmailProvider({
host: "smtp-relay.brevo.com",
port: 587,
username: "test-user",
password: "test-pass",
fromAddress: "noreply@example.com",
});
beforeEach(() => {
sendMail.mockReset();
});
const params = {
to: { email: "customer@example.com" },
sender: { name: "Blake Heritage Barbershop" },
subject: "Hi",
html: "<p>Hi</p>",
text: "Hi",
};
it("reports success with the provider message id on a clean send", async () => {
sendMail.mockResolvedValueOnce({ messageId: "<abc123@smtp-relay.brevo.com>" });
const result = await provider.sendEmail(params);
expect(result).toEqual({ status: "sent", providerMessageId: "<abc123@smtp-relay.brevo.com>" });
});
it("classifies a connection timeout as retryable", async () => {
sendMail.mockRejectedValueOnce({ code: "ETIMEDOUT", message: "Connection timed out" });
const result = await provider.sendEmail(params);
expect(result).toEqual({ status: "failed", retryable: true, reason: "Connection timed out" });
});
it("classifies a 4xx SMTP response (rate limit / temporary) as retryable", async () => {
sendMail.mockRejectedValueOnce({ responseCode: 421, message: "Too many connections" });
const result = await provider.sendEmail(params);
expect(result).toEqual({ status: "failed", retryable: true, reason: "Too many connections" });
});
it("classifies a 5xx SMTP response (invalid recipient / rejected) as permanent, not retryable", async () => {
sendMail.mockRejectedValueOnce({ responseCode: 550, message: "Mailbox unavailable" });
const result = await provider.sendEmail(params);
expect(result).toEqual({ status: "failed", retryable: false, reason: "Mailbox unavailable" });
});
it("classifies an auth failure (535, permanent per SMTP) as not retryable", async () => {
sendMail.mockRejectedValueOnce({ responseCode: 535, message: "Authentication failed" });
const result = await provider.sendEmail(params);
expect(result.status).toBe("failed");
expect((result as { retryable: boolean }).retryable).toBe(false);
});
it("defaults an unrecognized error to retryable, bounded later by the background processor's max-attempt cap", async () => {
sendMail.mockRejectedValueOnce({ message: "Something unexpected" });
const result = await provider.sendEmail(params);
expect(result).toEqual({ status: "failed", retryable: true, reason: "Something unexpected" });
});
it("never includes the SMTP username/password in a failure result", async () => {
sendMail.mockRejectedValueOnce({ responseCode: 535, message: "Authentication failed" });
const result = await provider.sendEmail(params);
expect(JSON.stringify(result)).not.toContain("test-pass");
expect(JSON.stringify(result)).not.toContain("test-user");
});
it("sends with the per-message sender name and the provider's own configured address, ignoring any sender.email", async () => {
sendMail.mockResolvedValueOnce({ messageId: "<abc123@smtp-relay.brevo.com>" });
await provider.sendEmail({ ...params, sender: { name: "Riverside Barbershop", email: "someone-else@other-domain.com" } });
expect(sendMail).toHaveBeenCalledWith(
expect.objectContaining({ from: { address: "noreply@example.com", name: "Riverside Barbershop" } }),
);
});
});
@@ -0,0 +1,62 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const sendNotification = vi.fn();
const setVapidDetails = vi.fn();
vi.mock("web-push", () => ({
default: { sendNotification, setVapidDetails },
}));
const { WebPushProvider } = await import("../web-push-provider");
describe("WebPushProvider", () => {
const provider = new WebPushProvider({
publicKey: "test-public-key",
privateKey: "test-private-key",
subject: "mailto:test@example.com",
});
beforeEach(() => {
sendNotification.mockReset();
});
const params = {
subscription: { endpoint: "https://push.example.com/abc", p256dhKey: "p256dh", authKey: "auth" },
title: "Hi",
body: "There",
};
it("configures VAPID details once, at construction", () => {
expect(setVapidDetails).toHaveBeenCalledWith("mailto:test@example.com", "test-public-key", "test-private-key");
});
it("reports success on a clean send", async () => {
sendNotification.mockResolvedValueOnce({});
const result = await provider.sendPush(params);
expect(result).toEqual({ status: "sent" });
});
it("flags a 410 (Gone) as a permanently invalid subscription, not retryable", async () => {
sendNotification.mockRejectedValueOnce({ statusCode: 410, message: "Gone" });
const result = await provider.sendPush(params);
expect(result).toEqual({ status: "failed", retryable: false, subscriptionInvalid: true, reason: "Subscription is no longer valid." });
});
it("flags a 404 the same way as a 410", async () => {
sendNotification.mockRejectedValueOnce({ statusCode: 404, message: "Not Found" });
const result = await provider.sendPush(params);
expect(result.status).toBe("failed");
expect((result as { subscriptionInvalid?: boolean }).subscriptionInvalid).toBe(true);
});
it("treats a 5xx push-service error as retryable, not a subscription problem", async () => {
sendNotification.mockRejectedValueOnce({ statusCode: 503, message: "Service unavailable" });
const result = await provider.sendPush(params);
expect(result).toEqual({ status: "failed", retryable: true, reason: "Service unavailable" });
});
it("treats any other error as permanent, not retryable, and not a subscription-invalid signal", async () => {
sendNotification.mockRejectedValueOnce({ statusCode: 400, message: "Bad payload" });
const result = await provider.sendPush(params);
expect(result).toEqual({ status: "failed", retryable: false, reason: "Bad payload" });
});
});
@@ -0,0 +1,141 @@
import nodemailer from "nodemailer";
import type { EmailProvider, EmailSendResult, SendEmailParams } from "./email-provider";
/**
* Brevo transactional email, sent over Brevo's SMTP relay via
* `nodemailer` — not Brevo's REST Transactional Email API
* (`POST /v3/smtp/email`). This was a deliberate choice, not a shortcut:
* the credentials supplied for this project are SMTP relay credentials
* (host smtp-relay.brevo.com, a username/password pair), not a Brevo API
* key. The Phase 7 brief is explicit that this codebase must never invent
* a provider's request/response contract when the real one isn't
* available — SMTP is a standard, RFC-defined protocol (not a
* Brevo-specific shape to guess at), so building against it here doesn't
* require guessing anything Brevo-specific, unlike their REST API's exact
* payload/response/error shape would have.
*
* What this means in practice, documented rather than silently assumed:
* - No Brevo "message ID" is returned the way their REST API would give
* one — `providerMessageId` below is the Message-ID nodemailer/the SMTP
* server generated, which is real and useful for support/log
* correlation, but is not the same identifier Brevo's own dashboard or
* a future webhook would key on.
* - No Brevo webhook integration exists (see reminder-worker.ts) — this
* provider only knows "the relay accepted the message for delivery",
* never "the recipient's inbox actually received it". See
* NotificationDeliveryStatus in schema.prisma: this app deliberately
* has no DELIVERED status for exactly this reason.
* - SMTP has no built-in request-level idempotency the way a REST API
* with a client-reference field would — `idempotencyKey` is attached
* as a custom `X-NextChair-Idempotency-Key` header for
* support/debugging purposes only; the REAL duplicate-send protection
* is app-side, via each NotificationDelivery row's own
* PENDING→PROCESSING→SENT status transition (see
* reminder-worker.ts) — this provider does not and cannot guarantee
* idempotency by itself.
* - If the real Brevo REST API key becomes available later, a second
* adapter implementing the same EmailProvider interface (e.g.
* BrevoApiEmailProvider) can be added without touching
* NotificationService or the background processor — that's the whole
* point of the provider abstraction.
*
* Sender identity (Phase 8 follow-up, see docs/phase-8-email-sender-summary.md):
* this config carries only the sender *address* (`fromAddress`) — a
* static provider setting, since it must be a domain/address actually
* verified with the SMTP relay. The visible sender *name* is no longer
* part of this config at all; it arrives per-message via
* `SendEmailParams.sender.name`, resolved fresh for every send by
* render-email.ts from the notification's own business. This file
* deliberately has no Prisma import and no business lookup of any kind —
* see that same doc for why the resolution lives one layer up.
*/
export interface BrevoEmailProviderConfig {
host: string;
port: number;
username: string;
password: string;
fromAddress: string;
}
export function loadBrevoEmailProviderConfigFromEnv(): BrevoEmailProviderConfig {
const host = process.env.EMAIL_SMTP_HOST;
const username = process.env.EMAIL_SMTP_USERNAME;
const password = process.env.EMAIL_SMTP_PASSWORD;
const fromAddress = process.env.EMAIL_FROM_ADDRESS;
if (!host || !username || !password || !fromAddress) {
throw new Error(
"Brevo SMTP is not configured — EMAIL_SMTP_HOST, EMAIL_SMTP_USERNAME, EMAIL_SMTP_PASSWORD, and EMAIL_FROM_ADDRESS must all be set. See .env.example.",
);
}
return {
host,
port: Number(process.env.EMAIL_SMTP_PORT ?? 587),
username,
password,
fromAddress,
};
}
interface SmtpError {
code?: string;
responseCode?: number;
message?: string;
}
const RETRYABLE_CONNECTION_CODES = new Set(["ETIMEDOUT", "ECONNECTION", "ECONNREFUSED", "ESOCKET", "EDNS"]);
/** SMTP response codes: 4xx is a temporary failure (retryable), 5xx is permanent (invalid recipient, rejected payload, auth failure) — never retry those. Connection-level errors (no response code at all) are treated as retryable, and so is anything unrecognized — the background processor's own max-attempt cap bounds the cost of guessing wrong. */
function isRetryable(error: SmtpError): boolean {
if (error.code && RETRYABLE_CONNECTION_CODES.has(error.code)) return true;
if (typeof error.responseCode === "number") {
if (error.responseCode >= 500) return false;
if (error.responseCode >= 400) return true;
}
return true;
}
export class BrevoEmailProvider implements EmailProvider {
readonly name = "brevo";
private readonly transporter: nodemailer.Transporter;
private readonly fromAddress: string;
constructor(config: BrevoEmailProviderConfig) {
this.fromAddress = config.fromAddress;
this.transporter = nodemailer.createTransport({
host: config.host,
port: config.port,
secure: config.port === 465, // 587/2525 use STARTTLS, not implicit TLS
auth: { user: config.username, pass: config.password },
connectionTimeout: 10_000,
greetingTimeout: 10_000,
socketTimeout: 20_000,
});
}
async sendEmail(params: SendEmailParams): Promise<EmailSendResult> {
try {
const info = await this.transporter.sendMail({
// The address is always this provider's own env-configured,
// verified-with-the-relay value — `params.sender.email` is
// deliberately ignored (see EmailSender's own header). Only the
// display name comes from the caller.
from: { address: this.fromAddress, name: params.sender.name },
to: { address: params.to.email, name: params.to.name },
subject: params.subject,
html: params.html,
text: params.text,
headers: params.idempotencyKey ? { "X-NextChair-Idempotency-Key": params.idempotencyKey } : undefined,
});
return { status: "sent", providerMessageId: info.messageId };
} catch (error) {
const smtpError = error as SmtpError;
// Never log the SMTP username/password (this.transporter carries
// them internally, never passed here) — only a short, safe summary.
return {
status: "failed",
retryable: isRetryable(smtpError),
reason: smtpError.message ?? "Unknown SMTP error",
};
}
}
}
@@ -0,0 +1,29 @@
import type { EmailProvider, EmailSendResult, SendEmailParams } from "./email-provider";
/**
* Dev-only — never actually sends anything, just prints what would have
* been sent so a developer can see confirmation/reminder emails locally
* without Brevo credentials configured. Deliberately logs the full
* subject/text here (unlike production structured logging elsewhere in
* this app — see docs/phase-7-summary.md, "Security hardening") because
* showing the content IS this provider's entire purpose; it must never be
* selected outside development (see providers/index.ts).
*/
export class ConsoleEmailProvider implements EmailProvider {
readonly name = "console";
async sendEmail(params: SendEmailParams): Promise<EmailSendResult> {
console.log(
[
"──── [ConsoleEmailProvider] would send an email (not actually sent) ────",
`From: ${params.sender.name}`,
`To: ${params.to.name ? `${params.to.name} <${params.to.email}>` : params.to.email}`,
`Subject: ${params.subject}`,
"",
params.text,
"──────────────────────────────────────────────────────────────────────",
].join("\n"),
);
return { status: "sent", providerMessageId: `console-${Date.now()}` };
}
}
@@ -0,0 +1,19 @@
import type { PushProvider, PushSendResult, SendPushParams } from "./push-provider";
/** Dev-only — see console-email-provider.ts for the same pattern and reasoning. */
export class ConsolePushProvider implements PushProvider {
readonly name = "console";
async sendPush(params: SendPushParams): Promise<PushSendResult> {
console.log(
[
"──── [ConsolePushProvider] would send a push (not actually sent) ────",
`Endpoint: ${params.subscription.endpoint.slice(0, 60)}...`,
`Title: ${params.title}`,
`Body: ${params.body}`,
"────────────────────────────────────────────────────────────────────",
].join("\n"),
);
return { status: "sent" };
}
}
@@ -0,0 +1,60 @@
/**
* Provider abstraction for sending email — only this file's shape is known
* to NotificationService/reminder-worker.ts; a specific vendor (Brevo,
* dev console, an in-memory test double) is a swappable implementation
* behind it, never something the caller branches on. See
* brevo-email-provider.ts for the one adapter allowed to know
* Brevo-specific details.
*/
export interface EmailAddress {
email: string;
name?: string;
}
/**
* The visible sender identity for one message — resolved per-send by the
* notification/rendering layer (see render-email.ts's `resolveSenderName`),
* never baked into static provider config. `email` is optional and, for
* BrevoEmailProvider specifically, ignored: the sender *address* stays
* exclusively environment-configured (`EMAIL_FROM_ADDRESS`) because it
* must match a domain/address actually verified with the SMTP relay — see
* that provider's own header. `name` is present here so a future provider
* that *can* vary its address per-message isn't blocked by this shape.
*/
export interface EmailSender {
name: string;
email?: string;
}
export interface SendEmailParams {
to: EmailAddress;
/** The resolved business name (or fallback) to show as the visible sender — see render-email.ts. */
sender: EmailSender;
subject: string;
html: string;
/** Plain-text fallback — required, not derived from `html`, so a broken tag can't silently produce an empty fallback. */
text: string;
/**
* The owning NotificationDelivery's id, passed through as the provider's
* client reference where supported — the app-side half of duplicate-send
* protection (see BrevoEmailProvider). Never treat this as a guarantee by
* itself; the background processor's own PENDING→PROCESSING transition
* is what actually prevents two workers from sending the same delivery.
*/
idempotencyKey?: string;
}
export type EmailSendResult =
| { status: "sent"; providerMessageId?: string }
| {
status: "failed";
/** True for a transient failure (timeout, 5xx, rate limit) worth retrying; false for a permanent one (invalid address, auth failure, rejected payload) that never will be. */
retryable: boolean;
reason: string;
};
export interface EmailProvider {
readonly name: string;
sendEmail(params: SendEmailParams): Promise<EmailSendResult>;
}
@@ -0,0 +1,53 @@
import "server-only";
import { BrevoEmailProvider, loadBrevoEmailProviderConfigFromEnv } from "./brevo-email-provider";
import { ConsoleEmailProvider } from "./console-email-provider";
import { ConsolePushProvider } from "./console-push-provider";
import type { EmailProvider } from "./email-provider";
import type { PushProvider } from "./push-provider";
import { WebPushProvider, loadWebPushProviderConfigFromEnv } from "./web-push-provider";
export type { EmailAddress, EmailProvider, EmailSendResult, SendEmailParams } from "./email-provider";
export type { PushProvider, PushSendResult, PushSubscriptionKeys, SendPushParams } from "./push-provider";
export { BrevoEmailProvider } from "./brevo-email-provider";
export { ConsoleEmailProvider } from "./console-email-provider";
export { MockEmailProvider } from "./mock-email-provider";
export { ConsolePushProvider } from "./console-push-provider";
export { MockPushProvider } from "./mock-push-provider";
export { WebPushProvider } from "./web-push-provider";
let cachedEmailProvider: EmailProvider | undefined;
let cachedPushProvider: PushProvider | undefined;
/**
* Selected once via `EMAIL_PROVIDER` and cached — never chosen per-call, so
* a single deployment always sends through exactly one path. Defaults to
* "console" whenever real SMTP credentials aren't present (e.g. local dev
* with no .env values filled in), so the app never accidentally tries a
* real send with empty credentials; this must never be the default in a
* deployed environment (see docs/phase-7-summary.md, "Environment
* variables required" — EMAIL_PROVIDER=brevo is required for production).
*/
export function getEmailProvider(): EmailProvider {
if (cachedEmailProvider) return cachedEmailProvider;
const selected = process.env.EMAIL_PROVIDER ?? "console";
if (selected === "brevo") {
cachedEmailProvider = new BrevoEmailProvider(loadBrevoEmailProviderConfigFromEnv());
} else {
cachedEmailProvider = new ConsoleEmailProvider();
}
return cachedEmailProvider;
}
/** Same selection pattern as getEmailProvider() — set PUSH_PROVIDER="web-push" once real VAPID keys are configured for a deployment; defaults to logging instead of sending. */
export function getPushProvider(): PushProvider {
if (cachedPushProvider) return cachedPushProvider;
const selected = process.env.PUSH_PROVIDER ?? "console";
if (selected === "web-push") {
cachedPushProvider = new WebPushProvider(loadWebPushProviderConfigFromEnv());
} else {
cachedPushProvider = new ConsolePushProvider();
}
return cachedPushProvider;
}
@@ -0,0 +1,36 @@
import type { EmailProvider, EmailSendResult, SendEmailParams } from "./email-provider";
/**
* In-memory test double — captures every call for assertions instead of
* sending anything. Supports scripting a specific result for the next N
* calls (e.g. "the first send fails with a retryable error, the retry
* succeeds"), which is how the retry-classification tests exercise the
* background processor without a real Brevo failure. Automated tests must
* never make a real Brevo API/SMTP call — this is what stands in for it.
*/
export class MockEmailProvider implements EmailProvider {
readonly name = "mock";
readonly sentEmails: SendEmailParams[] = [];
private scriptedResults: EmailSendResult[] = [];
private defaultResult: EmailSendResult = { status: "sent", providerMessageId: "mock-message-id" };
/** Queues results to return in order, one per sendEmail() call, before falling back to the default. */
scriptNextResults(...results: EmailSendResult[]): void {
this.scriptedResults.push(...results);
}
setDefaultResult(result: EmailSendResult): void {
this.defaultResult = result;
}
async sendEmail(params: SendEmailParams): Promise<EmailSendResult> {
this.sentEmails.push(params);
return this.scriptedResults.shift() ?? this.defaultResult;
}
reset(): void {
this.sentEmails.length = 0;
this.scriptedResults = [];
this.defaultResult = { status: "sent", providerMessageId: "mock-message-id" };
}
}
@@ -0,0 +1,28 @@
import type { PushProvider, PushSendResult, SendPushParams } from "./push-provider";
/** In-memory test double — see mock-email-provider.ts for the same pattern. */
export class MockPushProvider implements PushProvider {
readonly name = "mock";
readonly sentPushes: SendPushParams[] = [];
private scriptedResults: PushSendResult[] = [];
private defaultResult: PushSendResult = { status: "sent" };
scriptNextResults(...results: PushSendResult[]): void {
this.scriptedResults.push(...results);
}
setDefaultResult(result: PushSendResult): void {
this.defaultResult = result;
}
async sendPush(params: SendPushParams): Promise<PushSendResult> {
this.sentPushes.push(params);
return this.scriptedResults.shift() ?? this.defaultResult;
}
reset(): void {
this.sentPushes.length = 0;
this.scriptedResults = [];
this.defaultResult = { status: "sent" };
}
}
@@ -0,0 +1,34 @@
/**
* Provider abstraction for Web Push — mirrors email-provider.ts. Only
* web-push-provider.ts is allowed to know the actual Web Push protocol
* details (VAPID signing, the push service's HTTP response shape).
*/
export interface PushSubscriptionKeys {
endpoint: string;
p256dhKey: string;
authKey: string;
}
export interface SendPushParams {
subscription: PushSubscriptionKeys;
title: string;
body: string;
/** Relative path — the service worker's `notificationclick` handler opens this. */
deepLinkPath?: string;
}
export type PushSendResult =
| { status: "sent" }
| {
status: "failed";
retryable: boolean;
reason: string;
/** True for a 404/410 from the push service — this exact subscription is gone and should be revoked, not retried. */
subscriptionInvalid?: boolean;
};
export interface PushProvider {
readonly name: string;
sendPush(params: SendPushParams): Promise<PushSendResult>;
}
@@ -0,0 +1,62 @@
import webpush from "web-push";
import type { PushProvider, PushSendResult, SendPushParams } from "./push-provider";
/**
* Standards-based Web Push (RFC 8030 + VAPID) via the `web-push` package —
* works against any browser's push service (Chrome/FCM, Firefox, Safari)
* without a vendor-specific SDK. Nothing here is Brevo-specific; push and
* email are unrelated delivery channels with unrelated providers.
*/
export interface WebPushProviderConfig {
publicKey: string;
privateKey: string;
subject: string;
}
export function loadWebPushProviderConfigFromEnv(): WebPushProviderConfig {
const publicKey = process.env.VAPID_PUBLIC_KEY;
const privateKey = process.env.VAPID_PRIVATE_KEY;
const subject = process.env.VAPID_SUBJECT;
if (!publicKey || !privateKey || !subject) {
throw new Error("Web Push is not configured — VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY, and VAPID_SUBJECT must all be set. See .env.example.");
}
return { publicKey, privateKey, subject };
}
interface WebPushError {
statusCode?: number;
message?: string;
}
export class WebPushProvider implements PushProvider {
readonly name = "web-push";
constructor(config: WebPushProviderConfig) {
webpush.setVapidDetails(config.subject, config.publicKey, config.privateKey);
}
async sendPush(params: SendPushParams): Promise<PushSendResult> {
try {
await webpush.sendNotification(
{
endpoint: params.subscription.endpoint,
keys: { p256dh: params.subscription.p256dhKey, auth: params.subscription.authKey },
},
JSON.stringify({ title: params.title, body: params.body, url: params.deepLinkPath }),
);
return { status: "sent" };
} catch (error) {
const webPushError = error as WebPushError;
// 404/410: the push service itself says this subscription no longer
// exists (browser data cleared, permission revoked, uninstalled) —
// never retryable, and the caller should revoke the row.
if (webPushError.statusCode === 404 || webPushError.statusCode === 410) {
return { status: "failed", retryable: false, subscriptionInvalid: true, reason: "Subscription is no longer valid." };
}
if (typeof webPushError.statusCode === "number" && webPushError.statusCode >= 500) {
return { status: "failed", retryable: true, reason: webPushError.message ?? "Push service error." };
}
return { status: "failed", retryable: false, reason: webPushError.message ?? "Unknown push error." };
}
}
}
@@ -0,0 +1,57 @@
import type { PrismaClient } from "@/generated/prisma/client";
/**
* CRUD for PushSubscription rows — deliberately thin, no business rules
* beyond "one row per endpoint" (see the schema's `@@unique(endpoint)`,
* enforced here via upsert so re-subscribing the same browser/device
* updates the existing row instead of erroring).
*/
export interface SubscribeToPushInput {
businessId: string;
userId?: string;
customerId?: string;
endpoint: string;
p256dhKey: string;
authKey: string;
deviceLabel?: string;
}
export async function subscribeToPush(prisma: PrismaClient, input: SubscribeToPushInput) {
return prisma.pushSubscription.upsert({
where: { endpoint: input.endpoint },
create: {
businessId: input.businessId,
userId: input.userId,
customerId: input.customerId,
endpoint: input.endpoint,
p256dhKey: input.p256dhKey,
authKey: input.authKey,
deviceLabel: input.deviceLabel,
},
update: {
userId: input.userId,
customerId: input.customerId,
p256dhKey: input.p256dhKey,
authKey: input.authKey,
deviceLabel: input.deviceLabel,
revokedAt: null, // re-subscribing un-revokes — the browser just proved this endpoint is live again
},
});
}
/** Scoped by ownerUserId so a user can only ever revoke their own subscription, never guess someone else's endpoint and disable it. */
export async function unsubscribeFromPush(prisma: PrismaClient, params: { endpoint: string; ownerUserId: string }): Promise<boolean> {
const result = await prisma.pushSubscription.updateMany({
where: { endpoint: params.endpoint, userId: params.ownerUserId, revokedAt: null },
data: { revokedAt: new Date() },
});
return result.count > 0;
}
export async function listPushSubscriptionsForUser(prisma: PrismaClient, userId: string) {
return prisma.pushSubscription.findMany({
where: { userId, revokedAt: null },
orderBy: { createdAt: "desc" },
});
}
+20
View File
@@ -0,0 +1,20 @@
import type { PrismaClient } from "@/generated/prisma/client";
import { Permission } from "@/generated/prisma/enums";
/**
* There's no `Business.ownerUserId` column — "owner" is whoever holds a
* role with VIEW_REPORTING (the permission that gates the owner
* dashboard, see schema.prisma), which can be more than one active staff
* member. Every OWNER_* notification goes to all of them, not just one.
*/
export async function getOwnerUserIds(prisma: PrismaClient, businessId: string): Promise<string[]> {
const staff = await prisma.staffProfile.findMany({
where: {
businessId,
status: "ACTIVE",
role: { permissions: { some: { permission: Permission.VIEW_REPORTING } } },
},
select: { userId: true },
});
return staff.map((s) => s.userId);
}
@@ -0,0 +1,257 @@
import type { Prisma, PrismaClient } from "@/generated/prisma/client";
import { NotificationChannel, NotificationDeliveryStatus, NotificationType } from "@/generated/prisma/enums";
import { notifySafely, resolveActivePushSubscriptions, resolveEmailAddress } from "./notification.service";
import type { EmailProvider } from "./providers/email-provider";
import type { PushProvider } from "./providers/push-provider";
import { getOwnerUserIds } from "./recipients";
import { renderEmailForNotification } from "./render-email";
import { getBranding } from "@/lib/business/branding";
/**
* The background notification processor — the one place EMAIL and PUSH
* deliveries actually get sent. Deliberately NOT a Next.js server
* timer/interval (the brief's explicit "do not rely on in-memory Next.js
* server timers" — a serverless/multi-instance deployment has no single
* long-lived process to host one): this is a plain function meant to be
* invoked by an external scheduler hitting the authenticated route
* handler at src/app/api/cron/process-notifications/route.ts. See that
* file's header comment for the recommended trigger frequency and
* deployment notes.
*
* Locking: each due delivery is claimed with a single conditional
* `updateMany` (`WHERE id = ? AND status = <the status it was in when we
* read it>`) before doing any work. If two workers race for the same row,
* exactly one's claim affects a row (MySQL row-level locking during the
* UPDATE), and the loser sees `count: 0` and moves on — no separate lock
* table, no SELECT ... FOR UPDATE transaction held open across a slow
* network call to Brevo/a push service.
*
* Retry: only a `retryable` failure (timeout/rate-limit/5xx — see
* BrevoEmailProvider.isRetryable / WebPushProvider.sendPush) is retried,
* with a short exponential backoff (1 min, 5 min, 30 min) up to
* MAX_ATTEMPTS; a permanent failure (invalid recipient, rejected payload,
* auth failure, expired push subscription) or an exhausted retry budget
* both end at FAILED, never retried again.
*/
const MAX_ATTEMPTS = 4;
const BACKOFF_MINUTES = [1, 5, 30] as const;
/** Failed deliveries for one business within a single processing run at or above this count raises OWNER_REPEATED_DELIVERY_FAILURE — see NotificationType in schema.prisma. */
const REPEATED_FAILURE_THRESHOLD = 3;
export interface ProcessNotificationsResult {
claimed: number;
sent: number;
retried: number;
failed: number;
/** Another worker already claimed the row between our read and our claim attempt — not an error. */
skippedAlreadyClaimed: number;
}
type DueDelivery = Prisma.NotificationDeliveryGetPayload<{ include: { notification: true } }>;
function backoffMinutesFor(attemptNumber: number): number {
return BACKOFF_MINUTES[attemptNumber - 1] ?? BACKOFF_MINUTES[BACKOFF_MINUTES.length - 1] ?? 30;
}
export async function processDueNotificationDeliveries(
prisma: PrismaClient,
params: { emailProvider: EmailProvider; pushProvider?: PushProvider; now?: Date; batchSize?: number; businessId?: string },
): Promise<ProcessNotificationsResult> {
const now = params.now ?? new Date();
const batchSize = params.batchSize ?? 25;
const due = await prisma.notificationDelivery.findMany({
where: {
channel: { in: [NotificationChannel.EMAIL, NotificationChannel.PUSH] },
status: { in: [NotificationDeliveryStatus.PENDING, NotificationDeliveryStatus.SCHEDULED] },
OR: [{ scheduledFor: null }, { scheduledFor: { lte: now } }],
...(params.businessId ? { notification: { businessId: params.businessId } } : {}),
},
orderBy: [{ scheduledFor: "asc" }, { createdAt: "asc" }],
take: batchSize,
include: { notification: true },
});
const result: ProcessNotificationsResult = { claimed: 0, sent: 0, retried: 0, failed: 0, skippedAlreadyClaimed: 0 };
const failuresByBusiness = new Map<string, number>();
for (const delivery of due) {
const claim = await prisma.notificationDelivery.updateMany({
where: { id: delivery.id, status: delivery.status },
data: { status: NotificationDeliveryStatus.PROCESSING, lastAttemptedAt: now, attemptCount: { increment: 1 } },
});
if (claim.count === 0) {
result.skippedAlreadyClaimed++;
continue;
}
result.claimed++;
const attemptNumber = delivery.attemptCount + 1;
const outcome =
delivery.channel === NotificationChannel.EMAIL
? await processEmailDelivery(prisma, delivery, params.emailProvider)
: await processPushDelivery(prisma, delivery, params.pushProvider);
if (outcome.status === "sent") {
await prisma.notificationDelivery.update({
where: { id: delivery.id },
data: {
status: NotificationDeliveryStatus.SENT,
sentAt: new Date(),
providerName: outcome.providerName,
providerMessageId: outcome.providerMessageId,
},
});
result.sent++;
continue;
}
if (outcome.retryable && attemptNumber < MAX_ATTEMPTS) {
await prisma.notificationDelivery.update({
where: { id: delivery.id },
data: {
status: NotificationDeliveryStatus.PENDING,
scheduledFor: new Date(now.getTime() + backoffMinutesFor(attemptNumber) * 60_000),
failureReason: outcome.reason.slice(0, 500),
},
});
result.retried++;
} else {
await prisma.notificationDelivery.update({
where: { id: delivery.id },
data: { status: NotificationDeliveryStatus.FAILED, failureReason: outcome.reason.slice(0, 500) },
});
result.failed++;
failuresByBusiness.set(
delivery.notification.businessId,
(failuresByBusiness.get(delivery.notification.businessId) ?? 0) + 1,
);
}
}
for (const [businessId, count] of failuresByBusiness) {
if (count >= REPEATED_FAILURE_THRESHOLD) {
await raiseRepeatedFailureNotice(prisma, businessId, now);
}
}
return result;
}
type DeliveryOutcome =
| { status: "sent"; providerName: string; providerMessageId?: string }
| { status: "failed"; retryable: boolean; reason: string };
async function processEmailDelivery(prisma: PrismaClient, delivery: DueDelivery, emailProvider: EmailProvider): Promise<DeliveryOutcome> {
try {
const recipientEmail = await resolveEmailAddress(prisma, {
recipientUserId: delivery.notification.recipientUserId ?? undefined,
recipientCustomerId: delivery.notification.recipientCustomerId ?? undefined,
});
if (!recipientEmail) return { status: "failed", retryable: false, reason: "No email address on file for this recipient." };
const email = await renderEmailForNotification(prisma, delivery.notification);
if (!email) {
return {
status: "failed",
retryable: false,
reason: "Could not render email content — the business or referenced appointment/reward may have been deleted.",
};
}
const sendResult = await emailProvider.sendEmail({
to: { email: recipientEmail },
sender: email.sender,
subject: email.subject,
html: email.html,
text: email.text,
idempotencyKey: delivery.id,
});
if (sendResult.status === "sent") {
return { status: "sent", providerName: emailProvider.name, providerMessageId: sendResult.providerMessageId };
}
return { status: "failed", retryable: sendResult.retryable, reason: sendResult.reason };
} catch (error) {
return { status: "failed", retryable: true, reason: error instanceof Error ? error.message : "Unknown error while sending email." };
}
}
/**
* A PUSH delivery fans out to every active subscription this recipient
* has (see PushSubscription in schema.prisma — a person can have several
* devices, but there's only one PUSH NotificationDelivery row per
* notification). Counted "sent" overall if at least one subscription
* received it; any individual subscription the push service reports as
* gone (404/410) is revoked immediately, regardless of the others'
* outcome — that's real signal about that specific device, not about
* this notification.
*/
async function processPushDelivery(prisma: PrismaClient, delivery: DueDelivery, pushProvider: PushProvider | undefined): Promise<DeliveryOutcome> {
if (!pushProvider) return { status: "failed", retryable: true, reason: "No push provider configured." };
try {
const subscriptions = await resolveActivePushSubscriptions(prisma, {
recipientUserId: delivery.notification.recipientUserId ?? undefined,
recipientCustomerId: delivery.notification.recipientCustomerId ?? undefined,
});
if (subscriptions.length === 0) {
return { status: "failed", retryable: false, reason: "No active push subscription for this recipient." };
}
let anySent = false;
let lastFailureReason = "";
let anyRetryable = false;
// Every notify() call sets a subject (see notification.service.ts),
// so this fallback is defensive rather than a normal path — but a
// push notification's title is genuinely user-visible (shown by the
// OS), so it still needs the business name, not the internal product
// name, when reached.
const fallbackTitle = delivery.notification.subject ?? (await getBranding()).name;
for (const subscription of subscriptions) {
const sendResult = await pushProvider.sendPush({
subscription: { endpoint: subscription.endpoint, p256dhKey: subscription.p256dhKey, authKey: subscription.authKey },
title: fallbackTitle,
body: delivery.notification.bodyText,
deepLinkPath: delivery.notification.deepLinkPath ?? undefined,
});
if (sendResult.status === "sent") {
anySent = true;
await prisma.pushSubscription.update({ where: { id: subscription.id }, data: { lastUsedAt: new Date() } }).catch(() => {});
continue;
}
lastFailureReason = sendResult.reason;
if (sendResult.subscriptionInvalid) {
await prisma.pushSubscription.update({ where: { id: subscription.id }, data: { revokedAt: new Date() } }).catch(() => {});
} else if (sendResult.retryable) {
anyRetryable = true;
}
}
if (anySent) return { status: "sent", providerName: pushProvider.name };
return { status: "failed", retryable: anyRetryable, reason: lastFailureReason || "All subscriptions failed." };
} catch (error) {
return { status: "failed", retryable: true, reason: error instanceof Error ? error.message : "Unknown error while sending push." };
}
}
async function raiseRepeatedFailureNotice(prisma: PrismaClient, businessId: string, now: Date): Promise<void> {
const dayStamp = now.toISOString().slice(0, 10); // one notice per business per day, not one per processor run
const ownerUserIds = await getOwnerUserIds(prisma, businessId);
for (const ownerUserId of ownerUserIds) {
await notifySafely(prisma, {
businessId,
type: NotificationType.OWNER_REPEATED_DELIVERY_FAILURE,
recipientUserId: ownerUserId,
subject: "Some notifications failed to send",
bodyText: "Several notifications failed to send today. Check your notification settings, Brevo configuration, and push setup.",
deepLinkPath: "/owner/notifications",
idempotencyKey: `notify:owner-repeated-delivery-failure:${businessId}:${dayStamp}`,
});
}
}
+216
View File
@@ -0,0 +1,216 @@
import type { Prisma, PrismaClient } from "@/generated/prisma/client";
import { NotificationType } from "@/generated/prisma/enums";
import {
renderBookingCancelledEmail,
renderBookingConfirmedEmail,
renderBookingPendingApprovalEmail,
renderBookingReminderEmail,
renderBookingRescheduledEmail,
} from "./templates/booking-emails";
import type { AppointmentEmailData } from "./templates/booking-emails";
import { renderGenericNotificationEmail } from "./templates/generic-email";
import {
renderLoyaltyRewardEarnedEmail,
renderLoyaltyRewardRedeemedEmail,
} from "./templates/loyalty-emails";
import type { LoyaltyRewardEmailData } from "./templates/loyalty-emails";
import type { RenderedEmail } from "./templates/shared";
import { resolveSenderName } from "./sender-identity";
import type { EmailSender } from "./providers/email-provider";
export interface RenderedEmailWithSender extends RenderedEmail {
/** Resolved fresh on every call from the notification's own business — see resolveSenderName. */
sender: EmailSender;
}
/**
* Given a stored Notification row, builds the actual EMAIL to send —
* re-fetching whatever structured data (appointment, loyalty reward,
* business) the matching template needs, rather than storing rendered
* HTML on the row. Called by the background processor right before a
* PENDING EMAIL delivery is attempted (see reminder-worker.ts), never at
* notification-creation time, so it always reflects the current values
* (e.g. a since-updated business name) rather than a stale snapshot. This
* is the file that dispatches by NotificationType; the templates/ folder
* itself is pure and knows nothing about Prisma.
*
* The sender *name* (see RenderedEmailWithSender) is resolved here too,
* from this exact same `business` row — scoped by `notification.businessId`
* via `findUnique`, never a global `findFirst()`, so one business can
* never accidentally supply another's branding to a misdirected send.
*/
export async function renderEmailForNotification(
prisma: PrismaClient,
notification: {
id: string;
type: NotificationType;
businessId: string;
appointmentId: string | null;
loyaltyRewardId: string | null;
subject: string | null;
bodyText: string;
deepLinkPath: string | null;
recipientUserId: string | null;
recipientCustomerId: string | null;
},
): Promise<RenderedEmailWithSender | null> {
const business = await prisma.business.findUnique({
where: { id: notification.businessId },
include: { locations: true, settings: true },
});
if (!business) return null;
const sender: EmailSender = { name: resolveSenderName(business.name) };
const businessInfo = {
name: business.name,
logoUrl: business.logoUrl ?? undefined,
address: business.locations[0]
? `${business.locations[0].addressLine1}, ${business.locations[0].city} ${business.locations[0].postalCode}`
: undefined,
phone: business.phone ?? undefined,
cancellationPolicy: business.settings?.cancellationPolicy ?? undefined,
};
const recipientName = await resolveRecipientName(prisma, notification);
const rendered = await renderBody();
return { ...rendered, sender };
async function renderBody(): Promise<RenderedEmail> {
switch (notification.type) {
case NotificationType.BOOKING_CONFIRMED:
case NotificationType.BOOKING_PENDING_APPROVAL:
case NotificationType.BOOKING_RESCHEDULED:
case NotificationType.BOOKING_CANCELLED:
case NotificationType.BOOKING_REMINDER: {
const data = await buildAppointmentEmailData(prisma, notification, {
business: businessInfo,
recipientName,
manageAppointmentPath: notification.deepLinkPath ?? "/appointments",
});
if (!data) return renderFallback();
if (notification.type === NotificationType.BOOKING_CONFIRMED)
return renderBookingConfirmedEmail(data);
if (notification.type === NotificationType.BOOKING_PENDING_APPROVAL)
return renderBookingPendingApprovalEmail(data);
if (notification.type === NotificationType.BOOKING_RESCHEDULED)
return renderBookingRescheduledEmail(data);
if (notification.type === NotificationType.BOOKING_CANCELLED)
return renderBookingCancelledEmail(data);
return renderBookingReminderEmail(data);
}
case NotificationType.LOYALTY_REWARD_EARNED:
case NotificationType.LOYALTY_REWARD_REDEEMED: {
const data = await buildLoyaltyRewardEmailData(prisma, notification, {
business: businessInfo,
recipientName,
});
if (!data) return renderFallback();
return notification.type === NotificationType.LOYALTY_REWARD_EARNED
? renderLoyaltyRewardEarnedEmail(data)
: renderLoyaltyRewardRedeemedEmail(data);
}
default:
return renderFallback();
}
function renderFallback(): RenderedEmail {
return renderGenericNotificationEmail({
business: businessInfo,
recipientName,
subject: notification.subject ?? businessInfo.name,
bodyText: notification.bodyText,
deepLinkPath: notification.deepLinkPath ?? undefined,
});
}
}
}
async function resolveRecipientName(
prisma: PrismaClient,
notification: { recipientUserId: string | null; recipientCustomerId: string | null },
): Promise<string> {
if (notification.recipientCustomerId) {
const customer = await prisma.customerProfile.findUnique({
where: { id: notification.recipientCustomerId },
select: { firstName: true, lastName: true },
});
if (customer) return `${customer.firstName} ${customer.lastName}`.trim();
}
if (notification.recipientUserId) {
const user = await prisma.user.findUnique({
where: { id: notification.recipientUserId },
select: { name: true },
});
if (user?.name) return user.name;
}
return "there";
}
async function buildAppointmentEmailData(
prisma: PrismaClient,
notification: { appointmentId: string | null },
base: Pick<AppointmentEmailData, "business" | "recipientName" | "manageAppointmentPath">,
): Promise<AppointmentEmailData | null> {
if (!notification.appointmentId) return null;
const appointment = await prisma.appointment.findUnique({
where: { id: notification.appointmentId },
include: { services: { include: { service: true } }, staff: { include: { user: true } } },
});
if (!appointment) return null;
const totalPrice = appointment.services.reduce(
(sum: number, s: { priceAtBooking: Prisma.Decimal }) => sum + Number(s.priceAtBooking),
0,
);
const totalMinutes = appointment.services.reduce(
(sum: number, s: { durationAtBookingMinutes: number }) => sum + s.durationAtBookingMinutes,
0,
);
return {
...base,
staffName: appointment.staff.user.name ?? "your barber",
serviceNames: appointment.services.map((s: { service: { name: string } }) => s.service.name),
dateLabel: appointment.startAt.toLocaleDateString("en-GB", {
weekday: "short",
day: "numeric",
month: "short",
}),
timeLabel: appointment.startAt.toLocaleTimeString("en-GB", {
hour: "2-digit",
minute: "2-digit",
}),
durationLabel: `${totalMinutes} min`,
totalLabel: `£${totalPrice.toFixed(2)}`,
appointmentRef: appointment.id.slice(-8),
};
}
async function buildLoyaltyRewardEmailData(
prisma: PrismaClient,
notification: { loyaltyRewardId: string | null },
base: Pick<LoyaltyRewardEmailData, "business" | "recipientName">,
): Promise<LoyaltyRewardEmailData | null> {
if (!notification.loyaltyRewardId) return null;
const reward = await prisma.loyaltyReward.findUnique({
where: { id: notification.loyaltyRewardId },
});
if (!reward) return null;
return {
...base,
rewardLabel: reward.label,
rewardDescription: reward.label,
expiresLabel: reward.expiresAt
? reward.expiresAt.toLocaleDateString("en-GB", {
day: "numeric",
month: "short",
year: "numeric",
})
: undefined,
loyaltyCardPath: "/home",
};
}
@@ -0,0 +1,30 @@
/**
* Resolves the visible email sender *name* — pure and DB-free (the caller,
* render-email.ts, has already loaded the business); kept as its own tiny
* module so the priority order is independently unit-testable. Never
* returns the internal product name. See
* docs/phase-8-email-sender-summary.md for the full priority-order
* rationale.
*/
const NEUTRAL_FALLBACK_SENDER_NAME = "Booking Notifications";
/**
* Priority: 1) the installed business's own name, 2) EMAIL_FROM_NAME as an
* emergency-only fallback (never required to be kept in sync with the
* business name — see .env.example), 3) a neutral, non-product name. A
* normally-installed application always resolves tier 1 — every
* Notification is business-scoped (see prisma/schema.prisma,
* Notification.businessId is required) — so tiers 2/3 exist purely as
* defensive fallbacks for a business row somehow having a blank name, or
* for a future notification type with no business association at all.
*/
export function resolveSenderName(businessName: string | null | undefined): string {
const trimmedBusinessName = businessName?.trim();
if (trimmedBusinessName) return trimmedBusinessName;
const envFallback = process.env.EMAIL_FROM_NAME?.trim();
if (envFallback) return envFallback;
return NEUTRAL_FALLBACK_SENDER_NAME;
}
@@ -0,0 +1,150 @@
import { describe, expect, it } from "vitest";
import { escapeHtml } from "../layout";
import {
renderBookingCancelledEmail,
renderBookingConfirmedEmail,
renderBookingPendingApprovalEmail,
renderBookingReminderEmail,
renderBookingRescheduledEmail,
} from "../booking-emails";
import type { AppointmentEmailData } from "../booking-emails";
import { renderGenericNotificationEmail } from "../generic-email";
import { renderLoyaltyRewardEarnedEmail, renderLoyaltyRewardRedeemedEmail } from "../loyalty-emails";
import type { LoyaltyRewardEmailData } from "../loyalty-emails";
import { renderStaffInvitationEmail, renderStaffScheduleChangedEmail } from "../staff-emails";
describe("escapeHtml", () => {
it("escapes all five HTML-significant characters", () => {
expect(escapeHtml(`<script>alert("x & y's")</script>`)).toBe(
"&lt;script&gt;alert(&quot;x &amp; y&#39;s&quot;)&lt;/script&gt;",
);
});
});
const business = { name: "Blake Heritage Barbershop", address: "1 High St, London", phone: "020 1234 5678" };
const appointmentData: AppointmentEmailData = {
business,
recipientName: "Alex Chen",
staffName: "Marcus Reed",
serviceNames: ["Haircut & Style"],
dateLabel: "Mon 3 Aug",
timeLabel: "16:30",
durationLabel: "30 min",
totalLabel: "£22.00",
appointmentRef: "abc12345",
manageAppointmentPath: "/appointments/abc123?token=raw-token-value",
};
describe("booking email templates", () => {
it("renderBookingConfirmedEmail includes appointment details and a working action link", () => {
const email = renderBookingConfirmedEmail(appointmentData);
expect(email.subject).toContain("Booking confirmed");
expect(email.html).toContain("Marcus Reed");
expect(email.html).toContain("Haircut &amp; Style");
expect(email.html).toContain("/appointments/abc123?token=raw-token-value");
expect(email.text).toContain("Marcus Reed");
expect(email.text).not.toMatch(/<[a-z]/i); // no stray HTML tags in the plain-text fallback
});
it("renderBookingPendingApprovalEmail mentions approval is still needed", () => {
const email = renderBookingPendingApprovalEmail(appointmentData);
expect(email.html.toLowerCase()).toContain("approved");
});
it("renderBookingRescheduledEmail mentions the updated time", () => {
const email = renderBookingRescheduledEmail(appointmentData);
expect(email.html.toLowerCase()).toContain("rescheduled");
});
it("renderBookingCancelledEmail includes a late-cancellation note only when flagged", () => {
const late = renderBookingCancelledEmail(appointmentData, { wasLateCancellation: true });
expect(late.html.toLowerCase()).toContain("late cancellation");
const notLate = renderBookingCancelledEmail(appointmentData);
expect(notLate.html.toLowerCase()).not.toContain("late cancellation");
});
it("renderBookingCancelledEmail escapes a user-supplied cancellation reason", () => {
const email = renderBookingCancelledEmail(appointmentData, { reason: `<img src=x onerror="alert(1)">` });
expect(email.html).not.toContain("<img src=x");
expect(email.html).toContain("&lt;img");
});
it("renderBookingReminderEmail has a reminder-appropriate subject", () => {
const email = renderBookingReminderEmail(appointmentData);
expect(email.subject.toLowerCase()).toContain("reminder");
});
});
describe("loyalty email templates", () => {
const rewardData: LoyaltyRewardEmailData = {
business,
recipientName: "Sam Osei",
rewardLabel: "50% Off Any Service",
rewardDescription: "50% off your next visit",
loyaltyCardPath: "/home",
};
it("renderLoyaltyRewardEarnedEmail announces the new reward", () => {
const email = renderLoyaltyRewardEarnedEmail(rewardData);
expect(email.html).toContain("50% Off Any Service");
expect(email.subject.toLowerCase()).toContain("reward");
});
it("renderLoyaltyRewardRedeemedEmail confirms it was applied", () => {
const email = renderLoyaltyRewardRedeemedEmail(rewardData);
expect(email.html.toLowerCase()).toContain("applied");
});
});
describe("staff email templates", () => {
it("renderStaffInvitationEmail includes the accept link and role", () => {
const email = renderStaffInvitationEmail({
business,
staffName: "Jamie Wallace",
roleName: "Barber",
invitedByName: "Marcus Reed",
acceptInvitePath: "/invite/accept?token=abc",
});
expect(email.html).toContain("Barber");
expect(email.html).toContain("/invite/accept?token=abc");
});
it("renderStaffScheduleChangedEmail names who made the change", () => {
const email = renderStaffScheduleChangedEmail({
business,
staffName: "Leo Vance",
changedByName: "Marcus Reed",
whatChanged: "your working hours",
viewSchedulePath: "/staff/settings/working-hours",
});
expect(email.html).toContain("Marcus Reed");
expect(email.html).toContain("your working hours");
});
});
describe("generic fallback template", () => {
it("renders the notification's own subject/bodyText when no bespoke template exists", () => {
const email = renderGenericNotificationEmail({
business,
recipientName: "Marcus Reed",
subject: "New appointment booked",
bodyText: "A new appointment was booked for you.",
deepLinkPath: "/staff/appointments/xyz",
});
expect(email.subject).toBe("New appointment booked");
expect(email.html).toContain("A new appointment was booked for you.");
expect(email.html).toContain("/staff/appointments/xyz");
});
it("omits the action button when there's no deep link", () => {
const email = renderGenericNotificationEmail({
business,
recipientName: "Marcus Reed",
subject: "Heads up",
bodyText: "Something happened.",
});
expect(email.html).not.toContain("View Details");
});
});
@@ -0,0 +1,125 @@
import { detailRow, paragraph, renderEmailLayout, renderPlainTextLayout } from "./layout";
import { absoluteUrl, footerNoteHtml, footerNoteText } from "./shared";
import type { BusinessEmailInfo, RenderedEmail } from "./shared";
export interface AppointmentEmailData {
business: BusinessEmailInfo;
recipientName: string;
staffName: string;
serviceNames: string[];
dateLabel: string;
timeLabel: string;
durationLabel: string;
totalLabel: string;
appointmentRef: string;
/** Relative path — e.g. "/appointments/xyz?token=..." for a guest, "/appointments" for a registered customer. */
manageAppointmentPath: string;
}
function renderAppointmentEmail(
data: AppointmentEmailData,
copy: { subject: string; headline: string; intro: string; extraNote?: string; actionLabel?: string },
): RenderedEmail {
const actionUrl = absoluteUrl(data.manageAppointmentPath);
const actionLabel = copy.actionLabel ?? "View Appointment";
const detailsHtml = [
detailRow("Service", data.serviceNames.join(", ")),
detailRow("Barber", data.staffName),
detailRow("Date", data.dateLabel),
detailRow("Time", data.timeLabel),
detailRow("Duration", data.durationLabel),
detailRow("Total", data.totalLabel),
detailRow("Reference", data.appointmentRef),
].join("");
const bodyHtml = [
paragraph(`Hi ${data.recipientName},`),
paragraph(copy.intro),
detailsHtml,
copy.extraNote ? paragraph(copy.extraNote) : "",
].join("");
const html = renderEmailLayout({
businessName: data.business.name,
businessLogoUrl: data.business.logoUrl,
headline: copy.headline,
bodyHtml,
actionLabel,
actionUrl,
footerNote: footerNoteHtml(data.business),
});
const text = renderPlainTextLayout({
businessName: data.business.name,
headline: copy.headline,
bodyLines: [
`Hi ${data.recipientName},`,
"",
copy.intro,
"",
`Service: ${data.serviceNames.join(", ")}`,
`Barber: ${data.staffName}`,
`Date: ${data.dateLabel}`,
`Time: ${data.timeLabel}`,
`Duration: ${data.durationLabel}`,
`Total: ${data.totalLabel}`,
`Reference: ${data.appointmentRef}`,
...(copy.extraNote ? ["", copy.extraNote] : []),
],
actionLabel,
actionUrl,
footerNote: footerNoteText(data.business),
});
return { subject: copy.subject, html, text };
}
export function renderBookingConfirmedEmail(data: AppointmentEmailData): RenderedEmail {
return renderAppointmentEmail(data, {
subject: `Booking confirmed — ${data.business.name}`,
headline: "Your booking is confirmed",
intro: `We've confirmed your appointment with ${data.staffName}. Here are the details:`,
});
}
export function renderBookingPendingApprovalEmail(data: AppointmentEmailData): RenderedEmail {
return renderAppointmentEmail(data, {
subject: `Booking received — ${data.business.name}`,
headline: "Your booking request has been received",
intro: `We've received your appointment request with ${data.staffName}. It needs to be approved before it's confirmed — we'll let you know as soon as it is.`,
});
}
export function renderBookingRescheduledEmail(data: AppointmentEmailData): RenderedEmail {
return renderAppointmentEmail(data, {
subject: `Booking rescheduled — ${data.business.name}`,
headline: "Your appointment has been rescheduled",
intro: "Here's the updated time for your appointment:",
});
}
export function renderBookingCancelledEmail(
data: AppointmentEmailData,
options: { wasLateCancellation?: boolean; reason?: string } = {},
): RenderedEmail {
return renderAppointmentEmail(data, {
subject: `Booking cancelled — ${data.business.name}`,
headline: "Your appointment has been cancelled",
intro: "This appointment has been cancelled. It's kept below for your records.",
extraNote: options.wasLateCancellation
? "This was a late cancellation, outside the free-cancellation window. The business's cancellation policy may apply."
: options.reason
? `Reason: ${options.reason}`
: undefined,
actionLabel: "Book Another Appointment",
});
}
export function renderBookingReminderEmail(data: AppointmentEmailData): RenderedEmail {
return renderAppointmentEmail(data, {
subject: `Reminder: your appointment is coming up — ${data.business.name}`,
headline: "See you soon!",
intro: "This is a reminder about your upcoming appointment:",
});
}
@@ -0,0 +1,47 @@
import { paragraph, renderEmailLayout, renderPlainTextLayout } from "./layout";
import { absoluteUrl, footerNoteHtml, footerNoteText } from "./shared";
import type { BusinessEmailInfo, RenderedEmail } from "./shared";
export interface GenericNotificationEmailData {
business: BusinessEmailInfo;
recipientName: string;
subject: string;
/** The Notification's own canonical bodyText (see Notification.bodyText in schema.prisma) — this template is the fallback for any type without a bespoke renderer, so it has no more structured data than that. */
bodyText: string;
deepLinkPath?: string;
}
/**
* Fallback for every NotificationType that doesn't have one of the nine
* bespoke templates the Phase 7 brief named explicitly (booking
* confirmed/pending/rescheduled/cancelled/reminder, loyalty reward
* earned/redeemed, staff invitation, staff schedule changed) — mainly the
* barber/owner operational types (new appointment, no-show, etc.). Still
* branded, still escaped, still has a working action link; just without a
* bespoke layout for data this generic renderer was never given.
*/
export function renderGenericNotificationEmail(data: GenericNotificationEmailData): RenderedEmail {
const actionUrl = data.deepLinkPath ? absoluteUrl(data.deepLinkPath) : undefined;
const bodyHtml = [paragraph(`Hi ${data.recipientName},`), paragraph(data.bodyText)].join("");
return {
subject: data.subject,
html: renderEmailLayout({
businessName: data.business.name,
businessLogoUrl: data.business.logoUrl,
headline: data.subject,
bodyHtml,
actionLabel: actionUrl ? "View Details" : undefined,
actionUrl,
footerNote: footerNoteHtml(data.business),
}),
text: renderPlainTextLayout({
businessName: data.business.name,
headline: data.subject,
bodyLines: [`Hi ${data.recipientName},`, "", data.bodyText],
actionLabel: actionUrl ? "View Details" : undefined,
actionUrl,
footerNote: footerNoteText(data.business),
}),
};
}
@@ -0,0 +1,104 @@
/**
* Shared HTML/plain-text shell every email template renders through — the
* one place that owns escaping, inline-CSS email-client compatibility,
* and "still readable with images blocked". No template in this folder
* builds raw HTML strings on its own outside this file's helpers; that's
* the whole point of "reusable typed renderer functions... not
* concatenated HTML strings in Server Actions" from the Phase 7 brief.
*/
/** Escapes the five HTML-significant characters — every piece of user/business-supplied text (names, notes, addresses) must go through this before landing in a template's HTML output. */
export function escapeHtml(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
export interface EmailLayoutInput {
businessName: string;
/** Absolute URL — email clients can't resolve a relative path the way a browser can. Omitted entirely (not a broken `<img>`) if not supplied. */
businessLogoUrl?: string;
headline: string;
/** Pre-escaped/pre-built HTML fragment for the main content — see the per-type renderers, which build this from escaped pieces. */
bodyHtml: string;
actionLabel?: string;
/** Absolute URL — see NEXT_PUBLIC_APP_URL / renderAbsoluteLink in index.ts. */
actionUrl?: string;
footerNote?: string;
}
/**
* A deliberately plain, table-based, inline-styled layout — no external
* stylesheet (most email clients strip it), no background images (the
* business name is always rendered as real text right next to/instead of
* the logo, so the email is still identifiable and readable with images
* blocked, which is the default in most mail clients).
*/
export function renderEmailLayout(input: EmailLayoutInput): string {
const logo = input.businessLogoUrl
? `<img src="${escapeHtml(input.businessLogoUrl)}" alt="${escapeHtml(input.businessName)}" height="32" style="height:32px;vertical-align:middle;margin-right:8px;" />`
: "";
const action =
input.actionLabel && input.actionUrl
? `<tr><td style="padding:24px 0 0;">
<a href="${escapeHtml(input.actionUrl)}" style="display:inline-block;background:#171717;color:#ffffff;text-decoration:none;padding:12px 24px;border-radius:9999px;font-weight:bold;font-size:14px;">${escapeHtml(input.actionLabel)}</a>
</td></tr>`
: "";
const footer = input.footerNote
? `<tr><td style="padding-top:24px;border-top:1px solid #e5e5e5;color:#737373;font-size:12px;line-height:1.5;">${input.footerNote}</td></tr>`
: "";
return `<!doctype html>
<html>
<body style="margin:0;padding:0;background:#f5f5f4;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f5f5f4;padding:24px 0;">
<tr>
<td align="center">
<table role="presentation" width="480" cellpadding="0" cellspacing="0" style="background:#ffffff;border-radius:16px;padding:32px;max-width:480px;width:100%;">
<tr><td style="padding-bottom:16px;font-size:16px;font-weight:bold;color:#171717;">${logo}${escapeHtml(input.businessName)}</td></tr>
<tr><td style="font-size:20px;font-weight:bold;color:#171717;padding-bottom:12px;">${escapeHtml(input.headline)}</td></tr>
<tr><td style="font-size:14px;line-height:1.6;color:#404040;">${input.bodyHtml}</td></tr>
${action}
${footer}
</table>
</td>
</tr>
</table>
</body>
</html>`;
}
export interface PlainTextLayoutInput {
businessName: string;
headline: string;
bodyLines: string[];
actionLabel?: string;
actionUrl?: string;
footerNote?: string;
}
export function renderPlainTextLayout(input: PlainTextLayoutInput): string {
const lines = [input.businessName, "", input.headline, "", ...input.bodyLines];
if (input.actionLabel && input.actionUrl) {
lines.push("", `${input.actionLabel}: ${input.actionUrl}`);
}
if (input.footerNote) {
lines.push("", input.footerNote);
}
return lines.join("\n");
}
/** A `<p>`-wrapped, escaped line — the smallest building block the per-type renderers compose bodyHtml from. */
export function paragraph(text: string): string {
return `<p style="margin:0 0 12px;">${escapeHtml(text)}</p>`;
}
/** A label/value row — "Date: Mon 3 Aug", "Barber: Marcus Reed", etc. */
export function detailRow(label: string, value: string): string {
return `<p style="margin:0 0 4px;"><strong>${escapeHtml(label)}:</strong> ${escapeHtml(value)}</p>`;
}
@@ -0,0 +1,92 @@
import { detailRow, paragraph, renderEmailLayout, renderPlainTextLayout } from "./layout";
import { absoluteUrl, footerNoteHtml, footerNoteText } from "./shared";
import type { BusinessEmailInfo, RenderedEmail } from "./shared";
export interface LoyaltyRewardEmailData {
business: BusinessEmailInfo;
recipientName: string;
rewardLabel: string;
/** e.g. "50% off your next visit", already formatted for display. */
rewardDescription: string;
expiresLabel?: string;
/** Relative path to the customer's loyalty card. */
loyaltyCardPath: string;
}
export function renderLoyaltyRewardEarnedEmail(data: LoyaltyRewardEmailData): RenderedEmail {
const actionUrl = absoluteUrl(data.loyaltyCardPath);
const bodyHtml = [
paragraph(`Hi ${data.recipientName},`),
paragraph(`You've earned a reward: ${data.rewardLabel}!`),
detailRow("Reward", data.rewardDescription),
data.expiresLabel ? detailRow("Expires", data.expiresLabel) : "",
paragraph("Show this to your barber on your next visit to redeem it."),
].join("");
return {
subject: `You've earned a reward — ${data.business.name}`,
html: renderEmailLayout({
businessName: data.business.name,
businessLogoUrl: data.business.logoUrl,
headline: "You've earned a reward!",
bodyHtml,
actionLabel: "View Loyalty Card",
actionUrl,
footerNote: footerNoteHtml(data.business),
}),
text: renderPlainTextLayout({
businessName: data.business.name,
headline: "You've earned a reward!",
bodyLines: [
`Hi ${data.recipientName},`,
"",
`You've earned a reward: ${data.rewardLabel}!`,
`Reward: ${data.rewardDescription}`,
...(data.expiresLabel ? [`Expires: ${data.expiresLabel}`] : []),
"",
"Show this to your barber on your next visit to redeem it.",
],
actionLabel: "View Loyalty Card",
actionUrl,
footerNote: footerNoteText(data.business),
}),
};
}
export function renderLoyaltyRewardRedeemedEmail(data: LoyaltyRewardEmailData): RenderedEmail {
const actionUrl = absoluteUrl(data.loyaltyCardPath);
const bodyHtml = [
paragraph(`Hi ${data.recipientName},`),
paragraph(`Your reward has been applied: ${data.rewardLabel}.`),
detailRow("Reward", data.rewardDescription),
paragraph("Thanks for being a loyal customer!"),
].join("");
return {
subject: `Reward applied — ${data.business.name}`,
html: renderEmailLayout({
businessName: data.business.name,
businessLogoUrl: data.business.logoUrl,
headline: "Your reward has been applied",
bodyHtml,
actionLabel: "View Loyalty Card",
actionUrl,
footerNote: footerNoteHtml(data.business),
}),
text: renderPlainTextLayout({
businessName: data.business.name,
headline: "Your reward has been applied",
bodyLines: [
`Hi ${data.recipientName},`,
"",
`Your reward has been applied: ${data.rewardLabel}.`,
`Reward: ${data.rewardDescription}`,
"",
"Thanks for being a loyal customer!",
],
actionLabel: "View Loyalty Card",
actionUrl,
footerNote: footerNoteText(data.business),
}),
};
}
@@ -0,0 +1,37 @@
import { escapeHtml } from "./layout";
export interface BusinessEmailInfo {
name: string;
logoUrl?: string;
address?: string;
phone?: string;
cancellationPolicy?: string;
}
/** Absolute URL — email clients can't resolve a relative path, unlike a browser looking at the current origin. */
export function absoluteUrl(path: string): string {
const base = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000";
return path.startsWith("http") ? path : `${base}${path.startsWith("/") ? "" : "/"}${path}`;
}
export function footerNoteHtml(business: BusinessEmailInfo): string | undefined {
const parts: string[] = [];
if (business.address) parts.push(business.address);
if (business.phone) parts.push(business.phone);
if (business.cancellationPolicy) parts.push(business.cancellationPolicy);
return parts.length > 0 ? parts.map((p) => `<span>${escapeHtml(p)}</span>`).join("<br/>") : undefined;
}
export function footerNoteText(business: BusinessEmailInfo): string | undefined {
const parts: string[] = [];
if (business.address) parts.push(business.address);
if (business.phone) parts.push(business.phone);
if (business.cancellationPolicy) parts.push(business.cancellationPolicy);
return parts.length > 0 ? parts.join("\n") : undefined;
}
export interface RenderedEmail {
subject: string;
html: string;
text: string;
}
@@ -0,0 +1,90 @@
import { paragraph, renderEmailLayout, renderPlainTextLayout } from "./layout";
import { absoluteUrl, footerNoteHtml, footerNoteText } from "./shared";
import type { BusinessEmailInfo, RenderedEmail } from "./shared";
export interface StaffInvitationEmailData {
business: BusinessEmailInfo;
staffName: string;
roleName: string;
invitedByName: string;
/** Relative path — /invite/accept?token=... */
acceptInvitePath: string;
}
export function renderStaffInvitationEmail(data: StaffInvitationEmailData): RenderedEmail {
const actionUrl = absoluteUrl(data.acceptInvitePath);
const bodyHtml = [
paragraph(`Hi ${data.staffName},`),
paragraph(
`${data.invitedByName} has added you as ${data.roleName === "Owner" ? "an" : "a"} ${data.roleName} at ${data.business.name}. Set your password to get started.`,
),
].join("");
return {
subject: `You've been invited to join ${data.business.name}`,
html: renderEmailLayout({
businessName: data.business.name,
businessLogoUrl: data.business.logoUrl,
headline: "You've been invited",
bodyHtml,
actionLabel: "Set Your Password",
actionUrl,
footerNote: footerNoteHtml(data.business),
}),
text: renderPlainTextLayout({
businessName: data.business.name,
headline: "You've been invited",
bodyLines: [
`Hi ${data.staffName},`,
"",
`${data.invitedByName} has added you as a ${data.roleName} at ${data.business.name}. Set your password to get started.`,
],
actionLabel: "Set Your Password",
actionUrl,
footerNote: footerNoteText(data.business),
}),
};
}
export interface StaffScheduleChangedEmailData {
business: BusinessEmailInfo;
staffName: string;
changedByName: string;
/** e.g. "your working hours", "your breaks", "your time off" */
whatChanged: string;
/** Relative path — e.g. /staff/settings/working-hours */
viewSchedulePath: string;
}
export function renderStaffScheduleChangedEmail(data: StaffScheduleChangedEmailData): RenderedEmail {
const actionUrl = absoluteUrl(data.viewSchedulePath);
const bodyHtml = [
paragraph(`Hi ${data.staffName},`),
paragraph(`${data.changedByName} has updated ${data.whatChanged} at ${data.business.name}. Take a look to see what changed.`),
].join("");
return {
subject: `Your schedule was updated — ${data.business.name}`,
html: renderEmailLayout({
businessName: data.business.name,
businessLogoUrl: data.business.logoUrl,
headline: "Your schedule was updated",
bodyHtml,
actionLabel: "View Schedule",
actionUrl,
footerNote: footerNoteHtml(data.business),
}),
text: renderPlainTextLayout({
businessName: data.business.name,
headline: "Your schedule was updated",
bodyLines: [
`Hi ${data.staffName},`,
"",
`${data.changedByName} has updated ${data.whatChanged} at ${data.business.name}. Take a look to see what changed.`,
],
actionLabel: "View Schedule",
actionUrl,
footerNote: footerNoteText(data.business),
}),
};
}
+58
View File
@@ -0,0 +1,58 @@
import { NotificationType } from "@/generated/prisma/enums";
/**
* Static metadata about each NotificationType — who it's for, and whether
* it's the kind of thing a user gets to turn off. Kept as plain lookup
* tables here rather than columns on the enum/schema, so policy can change
* without a migration (see the NotificationPreference model comment in
* schema.prisma).
*/
export type NotificationRecipientKind = "CUSTOMER" | "STAFF" | "OWNER";
const RECIPIENT_KIND: Record<NotificationType, NotificationRecipientKind> = {
BOOKING_CONFIRMED: "CUSTOMER",
BOOKING_PENDING_APPROVAL: "CUSTOMER",
BOOKING_APPROVED: "CUSTOMER",
BOOKING_REMINDER: "CUSTOMER",
BOOKING_RESCHEDULED: "CUSTOMER",
BOOKING_CANCELLED: "CUSTOMER",
BOOKING_BARBER_CHANGED: "CUSTOMER",
BOOKING_COMPLETED: "CUSTOMER",
LOYALTY_STAMP_ADDED: "CUSTOMER",
LOYALTY_REWARD_EARNED: "CUSTOMER",
LOYALTY_REWARD_REDEEMED: "CUSTOMER",
LOYALTY_REWARD_EXPIRING: "CUSTOMER",
STAFF_NEW_APPOINTMENT: "STAFF",
STAFF_APPOINTMENT_RESCHEDULED: "STAFF",
STAFF_APPOINTMENT_CANCELLED: "STAFF",
STAFF_APPOINTMENT_CHECKED_IN: "STAFF",
STAFF_APPOINTMENT_REMINDER: "STAFF",
STAFF_SCHEDULE_CHANGED: "STAFF",
STAFF_INVITATION_RECEIVED: "STAFF",
OWNER_NEW_BOOKING: "OWNER",
OWNER_BOOKING_CANCELLED: "OWNER",
OWNER_NO_SHOW: "OWNER",
OWNER_STAFF_INVITATION_ACCEPTED: "OWNER",
OWNER_REPEATED_DELIVERY_FAILURE: "OWNER",
};
export function recipientKindOf(type: NotificationType): NotificationRecipientKind {
return RECIPIENT_KIND[type];
}
/**
* Every other type is REQUIRED — always sent on every channel it has a
* valid target for, with no user-facing toggle. Only these two are ever
* looked up in NotificationPreference (see NotificationService.notify).
* Deliberately a short, explicit list rather than a "category" column:
* the set of optional types is a product decision, not a schema fact.
*/
const PREFERENCE_CONTROLLED_TYPES = new Set<NotificationType>([
NotificationType.BOOKING_REMINDER,
NotificationType.STAFF_APPOINTMENT_REMINDER,
]);
export function isPreferenceControlled(type: NotificationType): boolean {
return PREFERENCE_CONTROLLED_TYPES.has(type);
}
@@ -0,0 +1,207 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
cleanupTestBusiness,
createTestBusiness,
createTestCustomer,
createTestStaff,
prisma,
} from "@/services/scheduling/__tests__/fixtures";
import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures";
import {
getAppointmentReport,
getCustomerReport,
getLoyaltyReport,
getNotificationReport,
getServiceReport,
getStaffReport,
} from "../reports.service";
import { notify } from "@/services/notifications/notification.service";
import { NotificationType } from "@/generated/prisma/enums";
describe("reports.service (integration)", () => {
let business: TestBusiness;
let staffId: string;
let customerId: string;
let customerUserId: string;
const range = { from: new Date(Date.now() - 24 * 60 * 60 * 1000), to: new Date(Date.now() + 60 * 60 * 1000) };
beforeEach(async () => {
business = await createTestBusiness();
const staff = await createTestStaff(business);
staffId = staff.staffId;
const customer = await createTestCustomer(business);
customerId = customer.customerId;
customerUserId = customer.userId;
});
afterEach(async () => {
await cleanupTestBusiness(business);
});
async function createAppointment(overrides: {
status: "COMPLETED" | "CANCELLED" | "NO_SHOW" | "CONFIRMED";
discountAmount?: string;
}) {
return prisma.appointment.create({
data: {
businessId: business.businessId,
staffId,
customerId,
status: overrides.status,
startAt: new Date(),
endAt: new Date(Date.now() + 30 * 60_000),
discountAmount: overrides.discountAmount,
services: {
create: [{ serviceId: business.haircutServiceId, priceAtBooking: 20, durationAtBookingMinutes: 30 }],
},
},
});
}
describe("getAppointmentReport", () => {
it("totals status counts and computes gross/discount/net revenue from completed appointments only", async () => {
await createAppointment({ status: "COMPLETED" });
await createAppointment({ status: "COMPLETED", discountAmount: "5.00" });
await createAppointment({ status: "CANCELLED" });
await createAppointment({ status: "NO_SHOW" });
const report = await getAppointmentReport(prisma, { businessId: business.businessId, range });
expect(report.totalAppointments).toBe(4);
expect(report.completed).toBe(2);
expect(report.cancelled).toBe(1);
expect(report.noShow).toBe(1);
// £20 + £20 = £40 gross across the two completed appointments; only one had a £5 discount.
expect(report.grossRevenue).toBe("40.00");
expect(report.discountsGiven).toBe("5.00");
expect(report.netRevenue).toBe("35.00");
});
it("returns all-zero figures when there are no appointments in range", async () => {
const report = await getAppointmentReport(prisma, { businessId: business.businessId, range });
expect(report.totalAppointments).toBe(0);
expect(report.grossRevenue).toBe("0.00");
});
});
describe("getServiceReport", () => {
it("aggregates revenue and completed count per service, ignoring non-completed appointments", async () => {
await createAppointment({ status: "COMPLETED" });
await createAppointment({ status: "COMPLETED" });
await createAppointment({ status: "CANCELLED" });
const rows = await getServiceReport(prisma, { businessId: business.businessId, range });
expect(rows).toHaveLength(1);
expect(rows[0]?.timesCompleted).toBe(2);
expect(rows[0]?.revenue).toBe("40.00");
});
});
describe("getStaffReport", () => {
it("attributes net revenue (subtotal minus discount) to the assigned staff member", async () => {
await createAppointment({ status: "COMPLETED", discountAmount: "5.00" });
await createAppointment({ status: "NO_SHOW" });
const rows = await getStaffReport(prisma, { businessId: business.businessId, range });
expect(rows).toHaveLength(1);
expect(rows[0]?.staffId).toBe(staffId);
expect(rows[0]?.completed).toBe(1);
expect(rows[0]?.noShow).toBe(1);
expect(rows[0]?.revenue).toBe("15.00");
});
});
describe("getCustomerReport", () => {
it("counts a customer with no prior visit as new, and lists them in topCustomers", async () => {
await createAppointment({ status: "COMPLETED" });
const report = await getCustomerReport(prisma, { businessId: business.businessId, range });
expect(report.newCustomers).toBe(1);
expect(report.returningCustomers).toBe(0);
expect(report.topCustomers).toHaveLength(1);
expect(report.topCustomers[0]?.customerId).toBe(customerId);
expect(report.topCustomers[0]?.spend).toBe("20.00");
});
it("counts a customer with a visit before the range as returning", async () => {
// A completed visit well before `range.from`.
await prisma.appointment.create({
data: {
businessId: business.businessId,
staffId,
customerId,
status: "COMPLETED",
startAt: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000),
endAt: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000 + 30 * 60_000),
services: { create: [{ serviceId: business.haircutServiceId, priceAtBooking: 20, durationAtBookingMinutes: 30 }] },
},
});
await createAppointment({ status: "COMPLETED" });
const report = await getCustomerReport(prisma, { businessId: business.businessId, range });
expect(report.newCustomers).toBe(0);
expect(report.returningCustomers).toBe(1);
});
});
describe("getLoyaltyReport", () => {
it("sums stamp transactions and counts rewards earned/redeemed within range", async () => {
const programme = await prisma.loyaltyProgramme.create({
data: { businessId: business.businessId, isEnabled: true, stampsRequired: 5, rewardLabel: "Free cut" },
});
const account = await prisma.loyaltyAccount.create({
data: { customerId, loyaltyProgrammeId: programme.id, currentStamps: 5 },
});
await prisma.loyaltyTransaction.createMany({
data: [
{ loyaltyAccountId: account.id, type: "STAMP_ADDED", amount: 1 },
{ loyaltyAccountId: account.id, type: "STAMP_ADDED", amount: 1 },
{ loyaltyAccountId: account.id, type: "STAMP_REMOVED", amount: -1 },
],
});
const reward = await prisma.loyaltyReward.create({
data: { loyaltyAccountId: account.id, rewardType: "FIXED_AMOUNT", value: 5, label: "Free cut", status: "REDEEMED", redeemedAt: new Date() },
});
const report = await getLoyaltyReport(prisma, { businessId: business.businessId, range });
expect(report.stampsIssued).toBe(2);
expect(report.stampsRemoved).toBe(1);
expect(report.rewardsEarned).toBe(1);
expect(report.rewardsRedeemed).toBe(1);
// Cleanup isn't handled by cleanupTestBusiness for loyalty rows — delete explicitly.
await prisma.loyaltyReward.delete({ where: { id: reward.id } });
await prisma.loyaltyTransaction.deleteMany({ where: { loyaltyAccountId: account.id } });
await prisma.loyaltyAccount.delete({ where: { id: account.id } });
await prisma.loyaltyProgramme.delete({ where: { id: programme.id } });
});
});
describe("getNotificationReport", () => {
it("splits sent/failed counts by notification type and channel", async () => {
const { notification } = await notify(prisma, {
businessId: business.businessId,
type: NotificationType.BOOKING_CONFIRMED,
recipientUserId: customerUserId,
recipientCustomerId: customerId,
subject: "Booking confirmed",
bodyText: "Your booking is confirmed.",
idempotencyKey: `test-notify-${customerId}`,
});
await prisma.notificationDelivery.updateMany({
where: { notificationId: notification.id },
data: { status: "SENT", sentAt: new Date() },
});
const report = await getNotificationReport(prisma, { businessId: business.businessId, range });
expect(report.totalSent).toBeGreaterThanOrEqual(1);
expect(report.byType.some((t) => t.type === "BOOKING_CONFIRMED" && t.sent >= 1)).toBe(true);
});
});
});
@@ -0,0 +1,293 @@
import type { PrismaClient } from "@/generated/prisma/client";
import {
calendarDateToUtcMidnight,
minutesFromMidnight,
weekdayOf,
} from "@/lib/time/business-time";
import type { CalendarDate } from "@/lib/time/business-time";
import { validateProgrammeConfig } from "@/services/loyalty/loyalty.service";
import { listAppointments } from "@/services/scheduling/appointment.service";
/**
* Read-only aggregation for the owner dashboard — every function here
* reads across services/appointments/staff and returns a typed summary;
* nothing in this file writes anything. Per the brief: only exists because
* the dashboard's queries are genuinely more than "call one existing
* service and render it" — this is not a general owner data-access layer,
* and no other feature should import from it.
*/
const ACTIVE_STATUSES = ["PENDING", "CONFIRMED", "CHECKED_IN", "IN_PROGRESS"] as const;
export interface DashboardTotals {
total: number;
completed: number;
cancelled: number;
noShow: number;
inProgress: number;
upcoming: number;
}
export interface PopularService {
serviceId: string;
name: string;
count: number;
}
export interface ConfigWarning {
code: string;
message: string;
}
export interface DashboardSummary {
totals: DashboardTotals;
staffWorkingToday: { staffId: string; name: string }[];
staffOnTimeOffToday: { staffId: string; name: string; reason: string | null }[];
/** Booked minutes vs. total working minutes across staff working today — an estimate, not a financial figure, and always labelled as such by the caller. Null when no staff are working today (division by zero). */
occupancy: { bookedMinutes: number; availableMinutes: number; percent: number } | null;
popularServices: PopularService[];
recentLoyaltyActivity: Awaited<ReturnType<typeof getRecentLoyaltyActivity>>;
customersWithAvailableRewards: number;
warnings: ConfigWarning[];
}
async function getRecentLoyaltyActivity(prisma: PrismaClient, businessId: string, limit: number) {
return prisma.loyaltyTransaction.findMany({
where: { loyaltyAccount: { loyaltyProgramme: { businessId } } },
orderBy: { createdAt: "desc" },
take: limit,
include: {
loyaltyAccount: { include: { customer: { select: { firstName: true, lastName: true } } } },
authoredByStaff: { include: { user: { select: { name: true } } } },
},
});
}
export async function getDashboardSummary(
prisma: PrismaClient,
params: { businessId: string; businessTimezone: string; date: CalendarDate },
): Promise<DashboardSummary> {
const appointments = await listAppointments(prisma, {
businessId: params.businessId,
fromDate: params.date,
toDate: params.date,
businessTimezone: params.businessTimezone,
});
const totals: DashboardTotals = {
total: appointments.length,
completed: appointments.filter((a) => a.status === "COMPLETED").length,
cancelled: appointments.filter((a) => a.status === "CANCELLED").length,
noShow: appointments.filter((a) => a.status === "NO_SHOW").length,
inProgress: appointments.filter((a) => a.status === "IN_PROGRESS").length,
upcoming: appointments.filter((a) => (ACTIVE_STATUSES as readonly string[]).includes(a.status)).length,
};
const weekday = weekdayOf(params.date);
const dateUtcMidnight = calendarDateToUtcMidnight(params.date);
const [activeStaff, workingHoursToday, timeOffToday] = await Promise.all([
prisma.staffProfile.findMany({
where: { businessId: params.businessId, status: "ACTIVE" },
include: { user: { select: { name: true } } },
}),
prisma.workingHours.findMany({
where: { staff: { businessId: params.businessId, status: "ACTIVE" }, dayOfWeek: weekday },
}),
prisma.timeOff.findMany({
where: {
staff: { businessId: params.businessId, status: "ACTIVE" },
status: "APPROVED",
startMinutes: null,
startDate: { lte: dateUtcMidnight },
endDate: { gte: dateUtcMidnight },
},
}),
]);
const workingHoursByStaffId = new Map(workingHoursToday.map((h) => [h.staffId, h]));
const timeOffStaffIds = new Set(timeOffToday.map((t) => t.staffId));
const nameByStaffId = new Map(activeStaff.map((s) => [s.id, s.user.name ?? "Staff"]));
const staffWorkingToday = activeStaff
.filter((s) => {
const hours = workingHoursByStaffId.get(s.id);
return hours && !hours.isOff && !timeOffStaffIds.has(s.id);
})
.map((s) => ({ staffId: s.id, name: nameByStaffId.get(s.id) ?? "Staff" }));
const staffOnTimeOffToday = timeOffToday.map((t) => ({
staffId: t.staffId,
name: nameByStaffId.get(t.staffId) ?? "Staff",
reason: t.reason,
}));
let occupancy: DashboardSummary["occupancy"] = null;
if (staffWorkingToday.length > 0) {
const availableMinutes = staffWorkingToday.reduce((sum, s) => {
const hours = workingHoursByStaffId.get(s.staffId);
if (!hours || hours.startMinutes === null || hours.endMinutes === null) return sum;
return sum + (hours.endMinutes - hours.startMinutes);
}, 0);
const bookedMinutes = appointments
.filter((a) => (ACTIVE_STATUSES as readonly string[]).includes(a.status) || a.status === "COMPLETED")
.reduce((sum, a) => sum + minutesFromMidnight(a.endAt, params.businessTimezone) - minutesFromMidnight(a.startAt, params.businessTimezone), 0);
occupancy = {
bookedMinutes,
availableMinutes,
percent: availableMinutes > 0 ? Math.round((bookedMinutes / availableMinutes) * 100) : 0,
};
}
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
const recentServiceLines = await prisma.appointmentService.findMany({
where: {
appointment: { businessId: params.businessId, status: "COMPLETED", startAt: { gte: thirtyDaysAgo } },
},
include: { service: { select: { id: true, name: true } } },
});
const serviceCounts = new Map<string, PopularService>();
for (const line of recentServiceLines) {
const existing = serviceCounts.get(line.serviceId);
if (existing) existing.count += 1;
else serviceCounts.set(line.serviceId, { serviceId: line.serviceId, name: line.service.name, count: 1 });
}
const popularServices = [...serviceCounts.values()].sort((a, b) => b.count - a.count).slice(0, 5);
const [recentLoyaltyActivity, customersWithAvailableRewards, warnings] = await Promise.all([
getRecentLoyaltyActivity(prisma, params.businessId, 5),
prisma.loyaltyReward.count({
where: { status: "AVAILABLE", loyaltyAccount: { loyaltyProgramme: { businessId: params.businessId } } },
}),
getConfigurationWarnings(prisma, params.businessId),
]);
return {
totals,
staffWorkingToday,
staffOnTimeOffToday,
occupancy,
popularServices,
recentLoyaltyActivity,
customersWithAvailableRewards,
warnings,
};
}
export async function getConfigurationWarnings(prisma: PrismaClient, businessId: string): Promise<ConfigWarning[]> {
const warnings: ConfigWarning[] = [];
const [businessHours, activeStaff, activeServices, loyaltyProgramme] = await Promise.all([
prisma.businessHours.findMany({ where: { businessId } }),
prisma.staffProfile.findMany({
where: { businessId, status: "ACTIVE" },
include: { user: { select: { name: true } }, workingHours: true, services: true },
}),
prisma.service.findMany({
where: { businessId, isActive: true },
include: { staff: { include: { staff: { select: { status: true } } } } },
}),
prisma.loyaltyProgramme.findUnique({ where: { businessId } }),
]);
if (businessHours.length === 0 || businessHours.every((h) => h.isClosed)) {
warnings.push({ code: "NO_OPENING_HOURS", message: "The business has no open days configured." });
}
for (const staff of activeStaff) {
const hasAnyOpenDay = staff.workingHours.some((h) => !h.isOff);
if (!hasAnyOpenDay) {
warnings.push({
code: "STAFF_NO_WORKING_HOURS",
message: `${staff.user.name ?? "A staff member"} has no working hours set — they can never be booked.`,
});
}
}
for (const service of activeServices) {
const hasActiveEligibleStaff = service.staff.some((s) => s.staff.status === "ACTIVE");
if (!hasActiveEligibleStaff) {
warnings.push({
code: "SERVICE_NO_ELIGIBLE_STAFF",
message: `"${service.name}" is active but no active staff member is eligible to perform it.`,
});
}
}
if (loyaltyProgramme?.isEnabled) {
const validation = validateProgrammeConfig({
isEnabled: loyaltyProgramme.isEnabled,
stampsRequired: loyaltyProgramme.stampsRequired,
rewardPosition: loyaltyProgramme.rewardPosition,
rewardType: loyaltyProgramme.rewardType,
rewardValue: loyaltyProgramme.rewardValue?.toString(),
freeServiceId: loyaltyProgramme.freeServiceId ?? undefined,
autoApplyReward: loyaltyProgramme.autoApplyReward,
requiresStaffApproval: loyaltyProgramme.requiresStaffApproval,
earnOnCancelled: loyaltyProgramme.earnOnCancelled,
earnOnNoShow: loyaltyProgramme.earnOnNoShow,
cardFont: loyaltyProgramme.cardFont,
cardTitleTextSize: loyaltyProgramme.cardTitleTextSize,
});
if (!validation.valid) {
warnings.push({
code: "LOYALTY_INCOMPLETE",
message: `The loyalty programme is enabled but incomplete: ${validation.issues[0]?.message}`,
});
}
}
const upcomingTimeOffConflicts = await findUpcomingTimeOffConflicts(prisma, businessId);
warnings.push(...upcomingTimeOffConflicts);
return warnings;
}
/** Whole-day or partial-day time off that overlaps a future, non-cancelled appointment for the same staff member — surfaced as a dashboard warning rather than blocking anything, since the time off may already have been added deliberately (e.g. via Phase 5's own conflict-acknowledge flow). */
async function findUpcomingTimeOffConflicts(prisma: PrismaClient, businessId: string): Promise<ConfigWarning[]> {
const upcomingTimeOff = await prisma.timeOff.findMany({
where: {
staff: { businessId },
status: "APPROVED",
endDate: { gte: calendarDateToUtcMidnight(new Date().toISOString().slice(0, 10)) },
},
include: { staff: { include: { user: { select: { name: true } } } } },
});
if (upcomingTimeOff.length === 0) return [];
// One query for every affected staff member's upcoming appointments,
// then the per-time-off overlap check happens in memory — rather than
// one `count()` round-trip per time-off row (see the Phase 7
// performance pass). Each time-off row has its own [startDate, endDate)
// window, so the ranges can't be folded into a single SQL WHERE; the
// one shared lower bound below still lets MySQL use the
// (staffId, startAt) index to avoid scanning appointments already in
// the past.
const staffIds = [...new Set(upcomingTimeOff.map((t) => t.staffId))];
const earliestStart = upcomingTimeOff.reduce(
(min, t) => (t.startDate < min ? t.startDate : min),
upcomingTimeOff[0]!.startDate,
);
const candidateAppointments = await prisma.appointment.findMany({
where: {
staffId: { in: staffIds },
status: { notIn: ["CANCELLED", "NO_SHOW", "COMPLETED"] },
startAt: { gte: earliestStart },
},
select: { staffId: true, startAt: true },
});
const warnings: ConfigWarning[] = [];
for (const timeOff of upcomingTimeOff) {
const conflictCount = candidateAppointments.filter(
(a) => a.staffId === timeOff.staffId && a.startAt >= timeOff.startDate && a.startAt < timeOff.endDate,
).length;
if (conflictCount > 0) {
warnings.push({
code: "TIME_OFF_CONFLICT",
message: `${timeOff.staff.user.name ?? "A staff member"} has ${conflictCount} upcoming appointment${conflictCount === 1 ? "" : "s"} during approved time off.`,
});
}
}
return warnings;
}
+322
View File
@@ -0,0 +1,322 @@
import type { PrismaClient } from "@/generated/prisma/client";
/**
* Date-range operational reports for the owner — distinct from
* owner-reporting.service.ts's single-day dashboard snapshot. Every
* function here is read-only and scoped to one businessId + [from, to]
* window; nothing writes anything.
*/
export interface ReportRange {
from: Date;
to: Date;
}
export interface AppointmentReport {
totalAppointments: number;
completed: number;
cancelled: number;
noShow: number;
completionRate: number;
cancellationRate: number;
noShowRate: number;
grossRevenue: string;
discountsGiven: string;
netRevenue: string;
}
export async function getAppointmentReport(
prisma: PrismaClient,
params: { businessId: string; range: ReportRange },
): Promise<AppointmentReport> {
const appointments = await prisma.appointment.findMany({
where: { businessId: params.businessId, startAt: { gte: params.range.from, lte: params.range.to } },
include: { services: true },
});
const completed = appointments.filter((a) => a.status === "COMPLETED");
const cancelled = appointments.filter((a) => a.status === "CANCELLED");
const noShow = appointments.filter((a) => a.status === "NO_SHOW");
const total = appointments.length;
const grossRevenue = completed.reduce(
(sum, a) => sum + a.services.reduce((s, line) => s + Number(line.priceAtBooking), 0),
0,
);
const discountsGiven = completed.reduce((sum, a) => sum + (a.discountAmount ? Number(a.discountAmount) : 0), 0);
return {
totalAppointments: total,
completed: completed.length,
cancelled: cancelled.length,
noShow: noShow.length,
completionRate: total > 0 ? Math.round((completed.length / total) * 100) : 0,
cancellationRate: total > 0 ? Math.round((cancelled.length / total) * 100) : 0,
noShowRate: total > 0 ? Math.round((noShow.length / total) * 100) : 0,
grossRevenue: grossRevenue.toFixed(2),
discountsGiven: discountsGiven.toFixed(2),
netRevenue: (grossRevenue - discountsGiven).toFixed(2),
};
}
export interface ServiceReportRow {
serviceId: string;
name: string;
timesCompleted: number;
revenue: string;
}
/** Only counts COMPLETED appointments — a cancelled booking never earned revenue for a service. */
export async function getServiceReport(
prisma: PrismaClient,
params: { businessId: string; range: ReportRange },
): Promise<ServiceReportRow[]> {
const lines = await prisma.appointmentService.findMany({
where: {
appointment: {
businessId: params.businessId,
status: "COMPLETED",
startAt: { gte: params.range.from, lte: params.range.to },
},
},
include: { service: { select: { id: true, name: true } } },
});
const byService = new Map<string, ServiceReportRow>();
for (const line of lines) {
const existing = byService.get(line.serviceId);
const revenue = Number(line.priceAtBooking);
if (existing) {
existing.timesCompleted += 1;
existing.revenue = (Number(existing.revenue) + revenue).toFixed(2);
} else {
byService.set(line.serviceId, {
serviceId: line.serviceId,
name: line.service.name,
timesCompleted: 1,
revenue: revenue.toFixed(2),
});
}
}
return [...byService.values()].sort((a, b) => Number(b.revenue) - Number(a.revenue));
}
export interface StaffReportRow {
staffId: string;
name: string;
completed: number;
cancelled: number;
noShow: number;
revenue: string;
}
/** Revenue attributed to a staff member is their completed appointments' net total (subtotal minus any redeemed reward), same figure the appointment detail pages show as "Total". */
export async function getStaffReport(
prisma: PrismaClient,
params: { businessId: string; range: ReportRange },
): Promise<StaffReportRow[]> {
const appointments = await prisma.appointment.findMany({
where: { businessId: params.businessId, startAt: { gte: params.range.from, lte: params.range.to } },
include: { services: true, staff: { include: { user: { select: { name: true } } } } },
});
const byStaff = new Map<string, StaffReportRow>();
for (const a of appointments) {
const row = byStaff.get(a.staffId) ?? {
staffId: a.staffId,
name: a.staff.user.name ?? "Staff",
completed: 0,
cancelled: 0,
noShow: 0,
revenue: "0.00",
};
if (a.status === "COMPLETED") {
row.completed += 1;
const subtotal = a.services.reduce((s, line) => s + Number(line.priceAtBooking), 0);
const discount = a.discountAmount ? Number(a.discountAmount) : 0;
row.revenue = (Number(row.revenue) + (subtotal - discount)).toFixed(2);
} else if (a.status === "CANCELLED") {
row.cancelled += 1;
} else if (a.status === "NO_SHOW") {
row.noShow += 1;
}
byStaff.set(a.staffId, row);
}
return [...byStaff.values()].sort((a, b) => Number(b.revenue) - Number(a.revenue));
}
export interface CustomerReport {
newCustomers: number;
returningCustomers: number;
topCustomers: { customerId: string; name: string; visits: number; spend: string }[];
}
/**
* "New" = a customer whose first-ever appointment with this business falls
* inside the range; "returning" = a customer who visited inside the range
* but already had an appointment before the range started. A customer with
* no appointment in the range at all counts as neither.
*/
export async function getCustomerReport(
prisma: PrismaClient,
params: { businessId: string; range: ReportRange },
): Promise<CustomerReport> {
const appointmentsInRange = await prisma.appointment.findMany({
where: {
businessId: params.businessId,
status: { notIn: ["CANCELLED"] },
startAt: { gte: params.range.from, lte: params.range.to },
},
include: { services: true, customer: { select: { firstName: true, lastName: true } } },
});
const customerIds = [...new Set(appointmentsInRange.map((a) => a.customerId))];
if (customerIds.length === 0) {
return { newCustomers: 0, returningCustomers: 0, topCustomers: [] };
}
const priorAppointments = await prisma.appointment.groupBy({
by: ["customerId"],
where: {
businessId: params.businessId,
customerId: { in: customerIds },
status: { notIn: ["CANCELLED"] },
startAt: { lt: params.range.from },
},
_count: true,
});
const hadPriorVisit = new Set(priorAppointments.map((p) => p.customerId));
let newCustomers = 0;
let returningCustomers = 0;
for (const customerId of customerIds) {
if (hadPriorVisit.has(customerId)) returningCustomers += 1;
else newCustomers += 1;
}
const byCustomer = new Map<string, { customerId: string; name: string; visits: number; spend: string }>();
for (const a of appointmentsInRange) {
const row = byCustomer.get(a.customerId) ?? {
customerId: a.customerId,
name: `${a.customer.firstName} ${a.customer.lastName}`,
visits: 0,
spend: "0.00",
};
row.visits += 1;
if (a.status === "COMPLETED") {
const subtotal = a.services.reduce((s, line) => s + Number(line.priceAtBooking), 0);
const discount = a.discountAmount ? Number(a.discountAmount) : 0;
row.spend = (Number(row.spend) + (subtotal - discount)).toFixed(2);
}
byCustomer.set(a.customerId, row);
}
const topCustomers = [...byCustomer.values()].sort((a, b) => Number(b.spend) - Number(a.spend)).slice(0, 10);
return { newCustomers, returningCustomers, topCustomers };
}
export interface LoyaltyReport {
stampsIssued: number;
stampsRemoved: number;
rewardsEarned: number;
rewardsRedeemed: number;
rewardsExpired: number;
}
export async function getLoyaltyReport(
prisma: PrismaClient,
params: { businessId: string; range: ReportRange },
): Promise<LoyaltyReport> {
const accountScope = { loyaltyAccount: { loyaltyProgramme: { businessId: params.businessId } } };
const inRange = { createdAt: { gte: params.range.from, lte: params.range.to } };
const [stampTransactions, rewardsEarned, rewardsRedeemed, rewardsExpired] = await Promise.all([
prisma.loyaltyTransaction.findMany({
where: { ...accountScope, ...inRange, type: { in: ["STAMP_ADDED", "STAMP_REMOVED"] } },
select: { amount: true },
}),
prisma.loyaltyReward.count({
where: { loyaltyAccount: { loyaltyProgramme: { businessId: params.businessId } }, ...inRange },
}),
prisma.loyaltyReward.count({
where: {
loyaltyAccount: { loyaltyProgramme: { businessId: params.businessId } },
redeemedAt: { gte: params.range.from, lte: params.range.to },
},
}),
// No expiredAt column exists — the periodic sweep only flips status, so
// this approximates "expired in range" by expiresAt falling in range
// for a reward that is now EXPIRED. Good enough for a trend, not exact
// to the second the sweep ran.
prisma.loyaltyReward.count({
where: {
loyaltyAccount: { loyaltyProgramme: { businessId: params.businessId } },
status: "EXPIRED",
expiresAt: { gte: params.range.from, lte: params.range.to },
},
}),
]);
return {
stampsIssued: stampTransactions.filter((t) => t.amount > 0).reduce((sum, t) => sum + t.amount, 0),
stampsRemoved: Math.abs(stampTransactions.filter((t) => t.amount < 0).reduce((sum, t) => sum + t.amount, 0)),
rewardsEarned,
rewardsRedeemed,
rewardsExpired,
};
}
export interface NotificationReportRow {
type: string;
sent: number;
failed: number;
}
export interface NotificationReport {
totalSent: number;
totalFailed: number;
byType: NotificationReportRow[];
byChannel: { channel: string; sent: number; failed: number }[];
}
export async function getNotificationReport(
prisma: PrismaClient,
params: { businessId: string; range: ReportRange },
): Promise<NotificationReport> {
const deliveries = await prisma.notificationDelivery.findMany({
where: {
notification: { businessId: params.businessId },
createdAt: { gte: params.range.from, lte: params.range.to },
},
select: { status: true, channel: true, notification: { select: { type: true } } },
});
const byType = new Map<string, NotificationReportRow>();
const byChannel = new Map<string, { channel: string; sent: number; failed: number }>();
let totalSent = 0;
let totalFailed = 0;
for (const d of deliveries) {
const isSent = d.status === "SENT";
const isFailed = d.status === "FAILED";
if (isSent) totalSent += 1;
if (isFailed) totalFailed += 1;
const typeRow = byType.get(d.notification.type) ?? { type: d.notification.type, sent: 0, failed: 0 };
if (isSent) typeRow.sent += 1;
if (isFailed) typeRow.failed += 1;
byType.set(d.notification.type, typeRow);
const channelRow = byChannel.get(d.channel) ?? { channel: d.channel, sent: 0, failed: 0 };
if (isSent) channelRow.sent += 1;
if (isFailed) channelRow.failed += 1;
byChannel.set(d.channel, channelRow);
}
return {
totalSent,
totalFailed,
byType: [...byType.values()].sort((a, b) => b.sent + b.failed - (a.sent + a.failed)),
byChannel: [...byChannel.values()].sort((a, b) => b.sent + b.failed - (a.sent + a.failed)),
};
}
@@ -0,0 +1,89 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { GooglePlaceRatingProvider, GooglePlacesProviderError } from "../google-places.provider";
/**
* Covers the Places API (New) request/response contract this provider
* depends on — worth a dedicated test since it was silently broken for a
* while by calling the deprecated legacy Place Details endpoint (which
* returns REQUEST_DENIED on any Cloud project without that legacy API
* enabled) instead of this one.
*/
describe("GooglePlaceRatingProvider (Places API New)", () => {
const originalKey = process.env.GOOGLE_PLACES_API_KEY;
beforeEach(() => {
process.env.GOOGLE_PLACES_API_KEY = "test-key";
});
afterEach(() => {
process.env.GOOGLE_PLACES_API_KEY = originalKey;
vi.unstubAllGlobals();
});
it("calls the New Places endpoint with the API key and a rating-only field mask as headers, not a query param", async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ rating: 4.5, userRatingCount: 10 }),
});
vi.stubGlobal("fetch", fetchMock);
await new GooglePlaceRatingProvider().fetchPlaceRating("ChIJabc123");
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, init] = fetchMock.mock.calls[0]!;
expect(url).toBe("https://places.googleapis.com/v1/places/ChIJabc123");
expect(url).not.toContain("key=");
expect(init.headers["X-Goog-Api-Key"]).toBe("test-key");
// No `reviews` in the mask — this provider only ever wants the
// aggregate, never individual reviews (see the file header).
expect(init.headers["X-Goog-FieldMask"]).toBe("rating,userRatingCount");
});
it("maps a real New-API response shape into the normalized aggregate shape", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ rating: 4.7, userRatingCount: 128 }),
}),
);
const result = await new GooglePlaceRatingProvider().fetchPlaceRating("ChIJabc123");
expect(result).toEqual({ rating: 4.7, userRatingsTotal: 128 });
});
it("returns nulls when Google omits rating/userRatingCount (e.g. a place with no ratings yet)", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) }),
);
const result = await new GooglePlaceRatingProvider().fetchPlaceRating("ChIJabc123");
expect(result).toEqual({ rating: null, userRatingsTotal: null });
});
it("surfaces the New API's standard error envelope (error.status/message), not the legacy status field", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: false,
json: async () => ({
error: { code: 403, message: "This API method requires billing to be enabled.", status: "PERMISSION_DENIED" },
}),
}),
);
await expect(new GooglePlaceRatingProvider().fetchPlaceRating("ChIJabc123")).rejects.toMatchObject({
message: expect.stringContaining("PERMISSION_DENIED"),
});
});
it("throws GooglePlacesProviderError when no API key is configured", async () => {
delete process.env.GOOGLE_PLACES_API_KEY;
await expect(new GooglePlaceRatingProvider().fetchPlaceRating("ChIJabc123")).rejects.toBeInstanceOf(
GooglePlacesProviderError,
);
});
});
@@ -0,0 +1,93 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { syncGoogleReviewsForAllBusinesses, syncGoogleReviewsForBusiness } from "@/services/reviews/review-sync.service";
import { DbReviewsProvider } from "@/services/reviews/reviews.service";
import { MockPlaceRatingProvider } from "@/services/reviews/mock-place-rating-provider";
import { cleanupTestBusiness, createTestBusiness, prisma } from "@/services/scheduling/__tests__/fixtures";
import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures";
describe("Google Reviews sync + DB-backed provider (integration)", () => {
let business: TestBusiness;
beforeEach(async () => {
business = await createTestBusiness();
});
afterEach(async () => {
await cleanupTestBusiness(business);
});
it("syncs the place-level aggregate rating, idempotently on re-sync — no individual review rows", async () => {
const mock = new MockPlaceRatingProvider();
await syncGoogleReviewsForBusiness(prisma, { businessId: business.businessId, placeId: "place-1" }, mock);
const businessRow = await prisma.business.findUniqueOrThrow({ where: { id: business.businessId } });
expect(Number(businessRow.googleRating)).toBeCloseTo(4.7);
expect(businessRow.googleRatingCount).toBe(128);
const reviewCount = await prisma.review.count({ where: { businessId: business.businessId } });
expect(reviewCount).toBe(0);
// Re-sync with updated data — overwrites, doesn't accumulate anything.
mock.result = { rating: 4.8, userRatingsTotal: 140 };
await syncGoogleReviewsForBusiness(prisma, { businessId: business.businessId, placeId: "place-1" }, mock);
const resynced = await prisma.business.findUniqueOrThrow({ where: { id: business.businessId } });
expect(Number(resynced.googleRating)).toBeCloseTo(4.8);
expect(resynced.googleRatingCount).toBe(140);
});
it("only syncs businesses with a configured googlePlaceId", async () => {
const otherBusiness = await createTestBusiness();
try {
await prisma.business.update({ where: { id: business.businessId }, data: { googlePlaceId: "place-configured" } });
// otherBusiness deliberately left without a googlePlaceId.
const mock = new MockPlaceRatingProvider();
const results = await syncGoogleReviewsForAllBusinesses(prisma, mock);
expect(results.map((r) => r.businessId)).toContain(business.businessId);
expect(results.map((r) => r.businessId)).not.toContain(otherBusiness.businessId);
} finally {
await cleanupTestBusiness(otherBusiness);
}
});
it("records a provider error without throwing", async () => {
const failingProvider = { fetchPlaceRating: async () => { throw new Error("boom"); } };
const result = await syncGoogleReviewsForBusiness(prisma, { businessId: business.businessId, placeId: "place-1" }, failingProvider);
expect(result.error).toBe("boom");
});
describe("DbReviewsProvider (customer-facing reads)", () => {
it("falls back to demo data when no reviews have ever been synced", async () => {
const provider = new DbReviewsProvider(prisma);
const summary = await provider.getBusinessReviews(business.businessId);
expect(summary.isDemoData).toBe(true);
});
it("returns the real synced aggregate once available, with an empty individual-review list", async () => {
const mock = new MockPlaceRatingProvider();
await syncGoogleReviewsForBusiness(prisma, { businessId: business.businessId, placeId: "place-1" }, mock);
const provider = new DbReviewsProvider(prisma);
const summary = await provider.getBusinessReviews(business.businessId);
expect(summary.isDemoData).toBe(false);
expect(summary.averageRating).toBeCloseTo(4.7);
expect(summary.totalReviews).toBe(128);
expect(summary.reviews).toHaveLength(0);
});
it("never lets one business's aggregate leak into another's summary", async () => {
const otherBusiness = await createTestBusiness();
try {
const mock = new MockPlaceRatingProvider();
await syncGoogleReviewsForBusiness(prisma, { businessId: business.businessId, placeId: "place-1" }, mock);
const provider = new DbReviewsProvider(prisma);
const otherSummary = await provider.getBusinessReviews(otherBusiness.businessId);
expect(otherSummary.isDemoData).toBe(true);
} finally {
await cleanupTestBusiness(otherBusiness);
}
});
});
});
@@ -0,0 +1,52 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createDemoReviewsProvider, getBusinessReviews, setReviewsProvider } from "../reviews.service";
import type { BusinessReviewSummary, ReviewsProvider } from "../reviews.service";
describe("reviews.service", () => {
// The module has no eager default provider (see reviews.service.ts's
// header — it lazily resolves a real, DB-backed provider on first use
// otherwise, which would need live database env vars). Every test here
// explicitly opts into the demo provider so none of them ever touch it.
beforeEach(() => {
setReviewsProvider(createDemoReviewsProvider());
});
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,81 @@
/**
* Google Places integration for the Google Reviews feature (Phase 9) —
* mirrors src/services/calendar/google-calendar.provider.ts's own
* pattern deliberately: a small documented-REST-contract interface, a
* plain-`fetch` real implementation, no SDK dependency, and an injectable
* mock for tests (see mock-place-rating-provider.ts). Never called from a
* customer-facing request — only from review-sync.service.ts, itself only
* reachable from the /api/cron/sync-google-reviews route.
*
* Uses Place Details (New) (places.googleapis.com/v1) — the legacy
* maps.googleapis.com/maps/api/place/details endpoint this used to call
* returns REQUEST_DENIED on any Cloud project created after Google
* deprecated it ("legacy API, which is not enabled for your project" —
* see https://developers.google.com/maps/legacy#LegacyApiNotActivatedMapError).
* The New API is a different Cloud API product (enable "Places API (New)",
* not "Places API", in Google Cloud Console) with a different auth style
* (API key as a header, not a query param).
*
* Deliberately fetches only the place-level aggregate (`rating`,
* `userRatingCount`), not individual reviews — Google's Place Details only
* ever returns up to 5 "most relevant" reviews per call regardless of how
* many the place actually has, with no pagination to get the rest (see
* https://developers.google.com/maps/documentation/places/web-service/place-details
* — "can return a maximum of five reviews per place"). Given that hard
* cap, downloading and displaying a tiny, non-representative review
* sample added complexity without adding real value; the aggregate
* rating/count alone is accurate and is all this app surfaces.
*/
export interface GooglePlaceRatingResult {
rating: number | null;
userRatingsTotal: number | null;
}
export interface PlaceRatingProvider {
fetchPlaceRating(placeId: string): Promise<GooglePlaceRatingResult>;
}
export class GooglePlacesProviderError extends Error {
constructor(message: string) {
super(message);
this.name = "GooglePlacesProviderError";
}
}
interface PlaceDetailsNewResponse {
rating?: number;
userRatingCount?: number;
error?: { code?: number; message?: string; status?: string };
}
export class GooglePlaceRatingProvider implements PlaceRatingProvider {
async fetchPlaceRating(placeId: string): Promise<GooglePlaceRatingResult> {
const apiKey = process.env.GOOGLE_PLACES_API_KEY;
if (!apiKey) {
throw new GooglePlacesProviderError("GOOGLE_PLACES_API_KEY is not configured on this deployment.");
}
const response = await fetch(`https://places.googleapis.com/v1/places/${encodeURIComponent(placeId)}`, {
headers: {
"X-Goog-Api-Key": apiKey,
// Places API (New) is opt-in per field for billing — omitting this
// header fails the request outright, it doesn't just return less.
"X-Goog-FieldMask": "rating,userRatingCount",
},
cache: "no-store",
});
const body = (await response.json()) as PlaceDetailsNewResponse;
if (!response.ok) {
throw new GooglePlacesProviderError(
`Google Places API returned ${body.error?.status ?? response.status}${body.error?.message ? `: ${body.error.message}` : ""}.`,
);
}
return {
rating: typeof body.rating === "number" ? body.rating : null,
userRatingsTotal: typeof body.userRatingCount === "number" ? body.userRatingCount : null,
};
}
}
@@ -0,0 +1,20 @@
import type { GooglePlaceRatingResult, PlaceRatingProvider } from "./google-places.provider";
/**
* In-memory `PlaceRatingProvider` for tests — never makes a real network
* call, same rationale as MockCalendarProvider (src/services/calendar).
* `result` is settable per-test; defaults to a small realistic sample.
*/
export class MockPlaceRatingProvider implements PlaceRatingProvider {
result: GooglePlaceRatingResult = {
rating: 4.7,
userRatingsTotal: 128,
};
fetchCallCount = 0;
async fetchPlaceRating(): Promise<GooglePlaceRatingResult> {
this.fetchCallCount += 1;
return this.result;
}
}
@@ -0,0 +1,57 @@
import type { PrismaClient } from "@/generated/prisma/client";
import { GooglePlaceRatingProvider } from "./google-places.provider";
import type { PlaceRatingProvider } from "./google-places.provider";
/**
* The 24h scheduled sync (see /api/cron/sync-google-reviews) — the only
* place that ever calls the Google Places API. Only syncs the place-level
* aggregate rating/count, not individual reviews — see
* google-places.provider.ts's header for why. Everything customer-facing
* reads Business.googleRating/googleRatingCount instead (see
* reviews.service.ts's DbReviewsProvider), never Google directly.
*/
function getDefaultProvider(): PlaceRatingProvider {
return new GooglePlaceRatingProvider();
}
export interface BusinessReviewSyncResult {
businessId: string;
error?: string;
}
/** One business's sync — refreshes the place-level aggregate rating/count. */
export async function syncGoogleReviewsForBusiness(
prisma: PrismaClient,
params: { businessId: string; placeId: string },
provider: PlaceRatingProvider = getDefaultProvider(),
): Promise<BusinessReviewSyncResult> {
try {
const result = await provider.fetchPlaceRating(params.placeId);
await prisma.business.update({
where: { id: params.businessId },
data: { googleRating: result.rating, googleRatingCount: result.userRatingsTotal },
});
return { businessId: params.businessId };
} catch (error) {
return { businessId: params.businessId, error: error instanceof Error ? error.message : "Unknown error" };
}
}
/** Every business with a configured Google Place ID — businesses without one are silently skipped (review sync is opt-in, see Business.googlePlaceId). */
export async function syncGoogleReviewsForAllBusinesses(
prisma: PrismaClient,
provider: PlaceRatingProvider = getDefaultProvider(),
): Promise<BusinessReviewSyncResult[]> {
const businesses = await prisma.business.findMany({
where: { googlePlaceId: { not: null } },
select: { id: true, googlePlaceId: true },
});
const results: BusinessReviewSyncResult[] = [];
for (const business of businesses) {
if (!business.googlePlaceId) continue;
results.push(await syncGoogleReviewsForBusiness(prisma, { businessId: business.id, placeId: business.googlePlaceId }, provider));
}
return results;
}
+127
View File
@@ -0,0 +1,127 @@
import type { PrismaClient } from "@/generated/prisma/client";
/**
* Reviews behind a provider abstraction, per the original Phase 4 brief —
* "a real Google Places/Business Profile integration can implement this
* same interface later without any caller (Customer Home, etc.)
* changing." DbReviewsProvider below reads Business.googleRating/
* googleRatingCount (populated by the 24h sync job — see
* review-sync.service.ts) instead of calling Google directly. Only the
* place-level aggregate is synced, never individual reviews (Google's
* Place Details caps those at 5 per place with no way to get the rest —
* see google-places.provider.ts's header) — `reviews` below is always
* empty for real (non-demo) data. Falls back to demo data only when a
* business has never synced at all, so a fresh install doesn't show an
* empty Reviews tab.
*/
export interface ReviewItem {
id: string;
authorName: string;
rating: number; // 1–5
text: string;
relativeDate: string; // pre-formatted, e.g. "2 weeks ago"
}
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",
},
];
export 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();
}
/** The real, DB-backed provider — see this file's header. Never calls Google; only ever reads what review-sync.service.ts already wrote. */
export class DbReviewsProvider implements ReviewsProvider {
constructor(private prisma: PrismaClient) {}
async getBusinessReviews(businessId: string): Promise<BusinessReviewSummary> {
const business = await this.prisma.business.findUnique({
where: { id: businessId },
select: { googleRating: true, googleRatingCount: true },
});
// Whether a sync has ever run is signalled by the place-level
// aggregate — this is the only thing review-sync.service.ts ever
// writes (see this file's header).
if (business?.googleRating === null || business?.googleRating === undefined) {
return new DemoReviewsProvider().getBusinessReviews(businessId);
}
return {
averageRating: Number(business.googleRating),
totalReviews: business.googleRatingCount ?? 0,
reviews: [],
isDemoData: false,
};
}
}
/**
* `null` = no explicit override; resolved lazily to a real DbReviewsProvider
* on first use. Deliberately NOT constructed eagerly at module load — this
* file must stay importable from a pure unit-test context with no
* database env vars set (see reviews.service.test.ts), and src/lib/db/prisma.ts
* throws at import time if they're unset.
*/
let provider: ReviewsProvider | null = null;
let cachedDefaultProvider: ReviewsProvider | null = null;
/** Swap the provider (e.g. back to demo data, or a test double) without touching any caller. */
export function setReviewsProvider(next: ReviewsProvider): void {
provider = next;
}
async function resolveDefaultProvider(): Promise<ReviewsProvider> {
if (!cachedDefaultProvider) {
const { prisma } = await import("@/lib/db/prisma");
cachedDefaultProvider = new DbReviewsProvider(prisma);
}
return cachedDefaultProvider;
}
export async function getBusinessReviews(businessId: string): Promise<BusinessReviewSummary> {
const active = provider ?? (await resolveDefaultProvider());
return active.getBusinessReviews(businessId);
}
@@ -0,0 +1,130 @@
import { describe, expect, it } from "vitest";
import { AppointmentStatus } from "@/generated/prisma/enums";
import { canTransition, selectCurrentAppointment, validateTransition } from "../appointment.service";
const ALL_STATUSES = Object.values(AppointmentStatus);
describe("canTransition", () => {
it("allows the documented happy path", () => {
expect(canTransition(AppointmentStatus.PENDING, AppointmentStatus.CONFIRMED)).toBe(true);
expect(canTransition(AppointmentStatus.CONFIRMED, AppointmentStatus.CHECKED_IN)).toBe(true);
expect(canTransition(AppointmentStatus.CHECKED_IN, AppointmentStatus.IN_PROGRESS)).toBe(true);
expect(canTransition(AppointmentStatus.IN_PROGRESS, AppointmentStatus.COMPLETED)).toBe(true);
});
it("allows cancellation from PENDING, CONFIRMED, and CHECKED_IN", () => {
expect(canTransition(AppointmentStatus.PENDING, AppointmentStatus.CANCELLED)).toBe(true);
expect(canTransition(AppointmentStatus.CONFIRMED, AppointmentStatus.CANCELLED)).toBe(true);
expect(canTransition(AppointmentStatus.CHECKED_IN, AppointmentStatus.CANCELLED)).toBe(true);
});
it("allows NO_SHOW only from CONFIRMED — once checked in, the customer clearly did show up", () => {
expect(canTransition(AppointmentStatus.CONFIRMED, AppointmentStatus.NO_SHOW)).toBe(true);
expect(canTransition(AppointmentStatus.CHECKED_IN, AppointmentStatus.NO_SHOW)).toBe(false);
expect(canTransition(AppointmentStatus.PENDING, AppointmentStatus.NO_SHOW)).toBe(false);
expect(canTransition(AppointmentStatus.IN_PROGRESS, AppointmentStatus.NO_SHOW)).toBe(false);
});
it("treats COMPLETED, CANCELLED, and NO_SHOW as terminal — no transitions out", () => {
for (const terminal of [AppointmentStatus.COMPLETED, AppointmentStatus.CANCELLED, AppointmentStatus.NO_SHOW]) {
for (const target of ALL_STATUSES) {
expect(canTransition(terminal, target)).toBe(false);
}
}
});
it("never allows a no-op transition to the same status", () => {
for (const status of ALL_STATUSES) {
expect(canTransition(status, status)).toBe(false);
}
});
it("disallows skipping straight from PENDING to CHECKED_IN or later", () => {
expect(canTransition(AppointmentStatus.PENDING, AppointmentStatus.CHECKED_IN)).toBe(false);
expect(canTransition(AppointmentStatus.PENDING, AppointmentStatus.IN_PROGRESS)).toBe(false);
expect(canTransition(AppointmentStatus.PENDING, AppointmentStatus.COMPLETED)).toBe(false);
});
it("disallows going backwards", () => {
expect(canTransition(AppointmentStatus.IN_PROGRESS, AppointmentStatus.CHECKED_IN)).toBe(false);
expect(canTransition(AppointmentStatus.CHECKED_IN, AppointmentStatus.CONFIRMED)).toBe(false);
expect(canTransition(AppointmentStatus.COMPLETED, AppointmentStatus.IN_PROGRESS)).toBe(false);
});
});
describe("validateTransition", () => {
it("returns a valid result for an allowed transition", () => {
const result = validateTransition(AppointmentStatus.CONFIRMED, AppointmentStatus.CHECKED_IN);
expect(result.valid).toBe(true);
expect(result.issues).toEqual([]);
});
it("returns a specific error code for a disallowed transition", () => {
const result = validateTransition(AppointmentStatus.COMPLETED, AppointmentStatus.CANCELLED);
expect(result.valid).toBe(false);
expect(result.issues[0]?.code).toBe("INVALID_STATUS_TRANSITION");
});
});
describe("selectCurrentAppointment", () => {
const now = new Date("2026-08-02T12:00:00.000Z");
interface Fixture {
id: string;
status: AppointmentStatus;
startAt: Date;
endAt: Date;
}
function appt(id: string, status: AppointmentStatus, startOffsetMin: number, durationMin = 30): Fixture {
const startAt = new Date(now.getTime() + startOffsetMin * 60_000);
return { id, status, startAt, endAt: new Date(startAt.getTime() + durationMin * 60_000) };
}
it("returns null for an empty list", () => {
expect(selectCurrentAppointment([], now)).toBeNull();
});
it("tier 1: prefers IN_PROGRESS over everything else", () => {
const inProgress = appt("in-progress", AppointmentStatus.IN_PROGRESS, -10, 30);
const checkedIn = appt("checked-in", AppointmentStatus.CHECKED_IN, 5, 30);
const scheduledNow = appt("scheduled-now", AppointmentStatus.CONFIRMED, -5, 30);
const result = selectCurrentAppointment([checkedIn, scheduledNow, inProgress], now);
expect(result?.id).toBe("in-progress");
});
it("tier 2: CHECKED_IN wins when nothing is IN_PROGRESS", () => {
const checkedIn = appt("checked-in", AppointmentStatus.CHECKED_IN, 5, 30);
const upcoming = appt("upcoming", AppointmentStatus.CONFIRMED, 120, 30);
const result = selectCurrentAppointment([upcoming, checkedIn], now);
expect(result?.id).toBe("checked-in");
});
it("tier 3: a CONFIRMED appointment whose window contains now, but never checked in", () => {
const scheduledNow = appt("scheduled-now", AppointmentStatus.CONFIRMED, -5, 30);
const upcoming = appt("upcoming", AppointmentStatus.CONFIRMED, 120, 30);
const result = selectCurrentAppointment([upcoming, scheduledNow], now);
expect(result?.id).toBe("scheduled-now");
});
it("tier 4: falls back to the next upcoming appointment today, earliest first", () => {
const later = appt("later", AppointmentStatus.CONFIRMED, 180, 30);
const sooner = appt("sooner", AppointmentStatus.PENDING, 60, 30);
const result = selectCurrentAppointment([later, sooner], now);
expect(result?.id).toBe("sooner");
});
it("tier 5: null when every appointment is terminal or in the past", () => {
const completed = appt("completed", AppointmentStatus.COMPLETED, -120, 30);
const cancelled = appt("cancelled", AppointmentStatus.CANCELLED, 60, 30);
const noShow = appt("no-show", AppointmentStatus.NO_SHOW, -60, 30);
const result = selectCurrentAppointment([completed, cancelled, noShow], now);
expect(result).toBeNull();
});
it("ignores CANCELLED and NO_SHOW rows even when their window contains now", () => {
const cancelledNow = appt("cancelled-now", AppointmentStatus.CANCELLED, -5, 30);
const result = selectCurrentAppointment([cancelledNow], now);
expect(result).toBeNull();
});
});
@@ -0,0 +1,686 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { Weekday } from "@/generated/prisma/enums";
import { addDays, todayIn, wallClockToInstant, weekdayOf } from "@/lib/time/business-time";
import type { CalendarDate } from "@/lib/time/business-time";
import { getAvailableDates, getAvailableStaff, getAvailableTimeSlots, validateBookingSlot } from "../availability.service";
import { cancelBooking, createBooking, rescheduleBooking } from "../booking.service";
import {
cleanupTestBusiness,
createTestBusiness,
createTestCustomer,
createTestStaff,
prisma,
} from "./fixtures";
import type { TestBusiness } from "./fixtures";
/** The next date (at least `minDaysAhead` out) that falls on `target` — avoids hardcoding a calendar date that could go stale or land on a closed day depending on when the suite runs. */
function nextWeekday(timezone: string, target: Weekday, minDaysAhead = 7): CalendarDate {
let date = addDays(todayIn(timezone), minDaysAhead);
for (let i = 0; i < 8; i++) {
if (weekdayOf(date) === target) return date;
date = addDays(date, 1);
}
throw new Error("unreachable");
}
describe("booking engine (integration)", () => {
let business: TestBusiness;
let monday: CalendarDate;
let sunday: CalendarDate;
beforeEach(async () => {
business = await createTestBusiness();
monday = nextWeekday(business.timezone, Weekday.MONDAY);
sunday = addDays(monday, 6); // the business is closed on Sunday — see fixtures.ts
});
afterEach(async () => {
await cleanupTestBusiness(business);
});
describe("availability calculation", () => {
it("offers slots for a working staff member on a working day", async () => {
const { staffId } = await createTestStaff(business);
const { slots, validation } = await getAvailableTimeSlots(prisma, {
businessId: business.businessId,
serviceIds: [business.haircutServiceId],
date: monday,
});
expect(validation.valid).toBe(true);
expect(slots.length).toBeGreaterThan(0);
expect(slots[0]!.staffOptions).toContain(staffId);
// 09:00 business open, 30 min slot interval — the very first slot should be 09:00.
expect(slots[0]!.startMinutes).toBe(9 * 60);
});
it("lists a date as available only if it actually has openings", async () => {
await createTestStaff(business);
const { dates } = await getAvailableDates(prisma, {
businessId: business.businessId,
serviceIds: [business.haircutServiceId],
fromDate: monday,
toDate: sunday,
});
expect(dates).toContain(monday);
expect(dates).not.toContain(sunday); // business closed Sundays
});
it("getAvailableStaff only returns staff who can perform every requested service", async () => {
const { staffId } = await createTestStaff(business);
// A second staff member who can only do haircuts, not beard trims.
const role = await prisma.role.create({ data: { businessId: business.businessId, name: "Junior" } });
const user = await prisma.user.create({
data: {
email: `junior-${Date.now()}@test.local`,
staffProfile: {
create: {
businessId: business.businessId,
roleId: role.id,
services: { create: [{ serviceId: business.haircutServiceId }] },
},
},
},
include: { staffProfile: true },
});
business.createdUserIds.push(user.id);
const staffForHaircutOnly = await getAvailableStaff(prisma, {
businessId: business.businessId,
serviceIds: [business.haircutServiceId],
});
const staffIds = staffForHaircutOnly.map((s) => s.id);
expect(staffIds).toContain(staffId);
expect(staffIds).toContain(user.staffProfile!.id);
const staffForBoth = await getAvailableStaff(prisma, {
businessId: business.businessId,
serviceIds: [business.haircutServiceId, business.beardTrimServiceId],
});
const bothIds = staffForBoth.map((s) => s.id);
expect(bothIds).toContain(staffId);
expect(bothIds).not.toContain(user.staffProfile!.id); // junior can't do beard trims
});
});
describe("business closed", () => {
it("offers no slots on a day the business is closed", async () => {
await createTestStaff(business);
const { slots } = await getAvailableTimeSlots(prisma, {
businessId: business.businessId,
serviceIds: [business.haircutServiceId],
date: sunday,
});
expect(slots).toEqual([]);
});
it("validateBookingSlot reports BUSINESS_CLOSED for a Sunday booking attempt", async () => {
const { staffId } = await createTestStaff(business);
const startAt = wallClockToInstant(sunday, 10 * 60, business.timezone);
const { validation } = await validateBookingSlot(prisma, {
businessId: business.businessId,
serviceIds: [business.haircutServiceId],
staffId,
startAt,
});
expect(validation.valid).toBe(false);
expect(validation.issues.map((i) => i.code)).toContain("BUSINESS_CLOSED");
});
});
describe("staff working hours", () => {
it("STAFF_NOT_WORKING when the staff member doesn't work that day, even though the business is open", async () => {
const { staffId } = await createTestStaff(business, { workingDays: [Weekday.TUESDAY] }); // never works Monday
const startAt = wallClockToInstant(monday, 10 * 60, business.timezone);
const { validation } = await validateBookingSlot(prisma, {
businessId: business.businessId,
serviceIds: [business.haircutServiceId],
staffId,
startAt,
});
expect(validation.valid).toBe(false);
expect(validation.issues.map((i) => i.code)).toContain("STAFF_NOT_WORKING");
});
});
describe("staff breaks", () => {
it("excludes a lunch break from available slots and flags a candidate inside it", async () => {
const { staffId } = await createTestStaff(business);
const lunchBreak = await prisma.break.create({
data: {
staffId,
label: "Lunch",
startMinutes: 12 * 60,
endMinutes: 13 * 60,
days: { create: [{ dayOfWeek: Weekday.MONDAY }] },
},
});
const { slots } = await getAvailableTimeSlots(prisma, {
businessId: business.businessId,
serviceIds: [business.haircutServiceId],
date: monday,
});
const duringLunch = slots.some((s) => s.startMinutes >= 12 * 60 && s.startMinutes < 13 * 60);
expect(duringLunch).toBe(false);
const { validation } = await validateBookingSlot(prisma, {
businessId: business.businessId,
serviceIds: [business.haircutServiceId],
staffId,
startAt: wallClockToInstant(monday, 12 * 60 + 15, business.timezone),
});
expect(validation.valid).toBe(false);
expect(validation.issues.map((i) => i.code)).toContain("STAFF_ON_BREAK");
await prisma.break.delete({ where: { id: lunchBreak.id } });
});
});
describe("time off", () => {
it("blocks the whole day when the staff member has approved leave", async () => {
const { staffId } = await createTestStaff(business);
await prisma.timeOff.create({
data: {
staffId,
startDate: new Date(`${monday}T00:00:00.000Z`),
endDate: new Date(`${monday}T00:00:00.000Z`),
status: "APPROVED",
},
});
const { slots } = await getAvailableTimeSlots(prisma, {
businessId: business.businessId,
serviceIds: [business.haircutServiceId],
date: monday,
});
expect(slots).toEqual([]);
const { validation } = await validateBookingSlot(prisma, {
businessId: business.businessId,
serviceIds: [business.haircutServiceId],
staffId,
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
});
expect(validation.issues.map((i) => i.code)).toContain("STAFF_ON_LEAVE");
});
it("does not block a day the leave doesn't cover", async () => {
const { staffId } = await createTestStaff(business);
const tuesday = addDays(monday, 1);
await prisma.timeOff.create({
data: {
staffId,
startDate: new Date(`${tuesday}T00:00:00.000Z`),
endDate: new Date(`${tuesday}T00:00:00.000Z`),
status: "APPROVED",
},
});
const { slots } = await getAvailableTimeSlots(prisma, {
businessId: business.businessId,
serviceIds: [business.haircutServiceId],
date: monday,
});
expect(slots.length).toBeGreaterThan(0);
});
it("a partial-day (Block Time) entry only blocks its own window, not the whole day", async () => {
const { staffId } = await createTestStaff(business);
await prisma.timeOff.create({
data: {
staffId,
startDate: new Date(`${monday}T00:00:00.000Z`),
endDate: new Date(`${monday}T00:00:00.000Z`),
status: "APPROVED",
reason: "Team meeting",
startMinutes: 12 * 60,
endMinutes: 13 * 60,
},
});
const { slots } = await getAvailableTimeSlots(prisma, {
businessId: business.businessId,
serviceIds: [business.haircutServiceId],
date: monday,
});
// Slots outside the block still exist — this is not a whole-day block.
expect(slots.length).toBeGreaterThan(0);
const duringBlock = slots.some((s) => s.startMinutes >= 12 * 60 && s.startMinutes < 13 * 60);
expect(duringBlock).toBe(false);
const { validation: insideBlock } = await validateBookingSlot(prisma, {
businessId: business.businessId,
serviceIds: [business.haircutServiceId],
staffId,
startAt: wallClockToInstant(monday, 12 * 60 + 15, business.timezone),
});
expect(insideBlock.valid).toBe(false);
expect(insideBlock.issues.map((i) => i.code)).toContain("STAFF_ON_LEAVE");
const { validation: outsideBlock } = await validateBookingSlot(prisma, {
businessId: business.businessId,
serviceIds: [business.haircutServiceId],
staffId,
startAt: wallClockToInstant(monday, 14 * 60, business.timezone),
});
expect(outsideBlock.valid).toBe(true);
});
});
describe("multiple services", () => {
it("combines durations and requires a window long enough for all requested services", async () => {
await createTestStaff(business);
const { slots: singleService } = await getAvailableTimeSlots(prisma, {
businessId: business.businessId,
serviceIds: [business.haircutServiceId], // 30 min
date: monday,
});
const { slots: combined } = await getAvailableTimeSlots(prisma, {
businessId: business.businessId,
serviceIds: [business.haircutServiceId, business.beardTrimServiceId], // 30 + 15 = 45 min
date: monday,
});
expect(combined[0]!.endMinutes - combined[0]!.startMinutes).toBe(45);
// The combined-duration slot list can't offer more start times deep near closing than the shorter single-service one.
const lastSingle = singleService[singleService.length - 1]!.startMinutes;
const lastCombined = combined[combined.length - 1]!.startMinutes;
expect(lastCombined).toBeLessThanOrEqual(lastSingle);
});
it("creates one AppointmentService row per requested service, with price/duration snapshotted", async () => {
const { staffId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
const result = await createBooking(prisma, {
businessId: business.businessId,
customerId,
staffId,
serviceIds: [business.haircutServiceId, business.beardTrimServiceId],
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
});
expect(result.success).toBe(true);
if (!result.success) return;
expect(result.appointment.services).toHaveLength(2);
const total = result.appointment.services.reduce((sum, s) => sum + Number(s.priceAtBooking), 0);
expect(total).toBe(30); // £20 haircut + £10 beard trim
const durationTotal = result.appointment.services.reduce((sum, s) => sum + s.durationAtBookingMinutes, 0);
expect(durationTotal).toBe(45);
});
});
describe("buffer times", () => {
it("blocks a slot that would start inside another appointment's buffer", async () => {
const { staffId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
// Give this business a 15-minute buffer after every appointment.
await prisma.bookingRule.update({
where: { businessId: business.businessId },
data: { bufferAfterMinutes: 15 },
});
const first = await createBooking(prisma, {
businessId: business.businessId,
customerId,
staffId,
serviceIds: [business.haircutServiceId], // 09:00–09:30
startAt: wallClockToInstant(monday, 9 * 60, business.timezone),
});
expect(first.success).toBe(true);
// 09:30 is technically free of the first appointment itself, but inside its 15-min buffer.
const { validation } = await validateBookingSlot(prisma, {
businessId: business.businessId,
serviceIds: [business.haircutServiceId],
staffId,
startAt: wallClockToInstant(monday, 9 * 60 + 30, business.timezone),
});
expect(validation.valid).toBe(false);
expect(validation.issues.map((i) => i.code)).toContain("SLOT_UNAVAILABLE");
// 09:45 is clear of the buffer.
const { validation: laterValidation } = await validateBookingSlot(prisma, {
businessId: business.businessId,
serviceIds: [business.haircutServiceId],
staffId,
startAt: wallClockToInstant(monday, 9 * 60 + 45, business.timezone),
});
expect(laterValidation.valid).toBe(true);
});
// Regression test for a real bug found via a MySQL E2E run (see
// docs/phase-7-summary.md): with an asymmetric buffer
// (bufferBeforeMinutes 0, bufferAfterMinutes > 0 — the seeded
// production default), assertNoConflict in scheduling.service.ts was
// applying the wrong side's buffer to the candidate appointment,
// wrongly rejecting a slot placed immediately BEFORE an existing
// appointment using that appointment's *after* buffer instead of its
// (zero) *before* buffer. The test above only exercises
// validateBookingSlot (AvailabilityService) for a candidate placed
// AFTER an existing appointment; createBooking's actual DB-level
// conflict check (assertNoConflict) was never exercised for the
// "before" direction until now.
it("does not apply a later appointment's after-buffer to a candidate slot placed immediately before it", async () => {
const { staffId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
await prisma.bookingRule.update({
where: { businessId: business.businessId },
data: { bufferBeforeMinutes: 0, bufferAfterMinutes: 5 },
});
// Book the later appointment first: 10:30–11:00.
const later = await createBooking(prisma, {
businessId: business.businessId,
customerId,
staffId,
serviceIds: [business.haircutServiceId], // 30 min
startAt: wallClockToInstant(monday, 10 * 60 + 30, business.timezone),
});
expect(later.success).toBe(true);
// A candidate ending exactly when the later appointment starts (10:00–10:30) needs no gap —
// only the LATER appointment's own trailing (after) buffer applies once IT finishes, not
// before it starts, since bufferBeforeMinutes is 0.
const earlier = await createBooking(prisma, {
businessId: business.businessId,
customerId,
staffId,
serviceIds: [business.haircutServiceId],
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
});
expect(earlier.success).toBe(true);
});
});
describe("booking validation", () => {
it("rejects a booking below the minimum notice period", async () => {
const { staffId } = await createTestStaff(business);
await prisma.bookingRule.update({ where: { businessId: business.businessId }, data: { minNoticeMinutes: 120 } });
const { validation } = await validateBookingSlot(prisma, {
businessId: business.businessId,
serviceIds: [business.haircutServiceId],
staffId,
startAt: new Date(Date.now() + 5 * 60_000), // 5 minutes from now
});
expect(validation.valid).toBe(false);
expect(validation.issues.map((i) => i.code)).toContain("BELOW_MIN_NOTICE");
});
it("rejects a booking beyond the maximum advance window", async () => {
const { staffId } = await createTestStaff(business);
await prisma.bookingRule.update({ where: { businessId: business.businessId }, data: { maxAdvanceDays: 7 } });
const farFuture = addDays(todayIn(business.timezone), 60);
const { validation } = await validateBookingSlot(prisma, {
businessId: business.businessId,
serviceIds: [business.haircutServiceId],
staffId,
startAt: wallClockToInstant(farFuture, 10 * 60, business.timezone),
});
expect(validation.valid).toBe(false);
expect(validation.issues.map((i) => i.code)).toContain("BEYOND_MAX_ADVANCE");
});
it("rejects once the business's daily appointment cap is reached", async () => {
const { staffId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
await prisma.bookingRule.update({ where: { businessId: business.businessId }, data: { maxAppointmentsPerDay: 1 } });
const first = await createBooking(prisma, {
businessId: business.businessId,
customerId,
staffId,
serviceIds: [business.haircutServiceId],
startAt: wallClockToInstant(monday, 9 * 60, business.timezone),
});
expect(first.success).toBe(true);
const second = await createBooking(prisma, {
businessId: business.businessId,
customerId,
staffId,
serviceIds: [business.haircutServiceId],
startAt: wallClockToInstant(monday, 11 * 60, business.timezone),
});
expect(second.success).toBe(false);
if (second.success) return;
expect(second.validation.issues.map((i) => i.code)).toContain("BUSINESS_DAILY_LIMIT_REACHED");
});
it("rejects services that don't belong to the business", async () => {
const { staffId } = await createTestStaff(business);
const { validation } = await validateBookingSlot(prisma, {
businessId: business.businessId,
serviceIds: ["not-a-real-service-id"],
staffId,
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
});
expect(validation.valid).toBe(false);
});
});
describe("double booking prevention", () => {
it("rejects a second booking for the exact same staff/time", async () => {
const { staffId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
const startAt = wallClockToInstant(monday, 10 * 60, business.timezone);
const first = await createBooking(prisma, {
businessId: business.businessId,
customerId,
staffId,
serviceIds: [business.haircutServiceId],
startAt,
});
const second = await createBooking(prisma, {
businessId: business.businessId,
customerId,
staffId,
serviceIds: [business.haircutServiceId],
startAt,
});
expect(first.success).toBe(true);
expect(second.success).toBe(false);
if (second.success) return;
expect(second.validation.issues.map((i) => i.code)).toContain("SLOT_UNAVAILABLE");
});
it("under real concurrency, exactly one of two simultaneous requests for the same slot succeeds", async () => {
const { staffId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
const startAt = wallClockToInstant(monday, 14 * 60, business.timezone);
const [a, b] = await Promise.all([
createBooking(prisma, {
businessId: business.businessId,
customerId,
staffId,
serviceIds: [business.haircutServiceId],
startAt,
}),
createBooking(prisma, {
businessId: business.businessId,
customerId,
staffId,
serviceIds: [business.haircutServiceId],
startAt,
}),
]);
const successes = [a, b].filter((r) => r.success);
const failures = [a, b].filter((r) => !r.success);
expect(successes).toHaveLength(1);
expect(failures).toHaveLength(1);
const appointmentCount = await prisma.appointment.count({
where: { businessId: business.businessId, staffId, status: { notIn: ["CANCELLED", "NO_SHOW"] } },
});
expect(appointmentCount).toBe(1);
});
});
describe("rescheduling", () => {
it("moves the appointment to the new time and applies the same validation as a new booking", async () => {
const { staffId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
const created = await createBooking(prisma, {
businessId: business.businessId,
customerId,
staffId,
serviceIds: [business.haircutServiceId],
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
});
expect(created.success).toBe(true);
if (!created.success) return;
const rescheduled = await rescheduleBooking(prisma, {
appointmentId: created.appointment.id,
startAt: wallClockToInstant(monday, 15 * 60, business.timezone),
});
expect(rescheduled.success).toBe(true);
if (!rescheduled.success) return;
expect(rescheduled.appointment.id).toBe(created.appointment.id);
expect(rescheduled.appointment.startAt.toISOString()).toBe(
wallClockToInstant(monday, 15 * 60, business.timezone).toISOString(),
);
});
it("does not conflict with itself when rescheduled to overlap its own original time", async () => {
const { staffId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
const created = await createBooking(prisma, {
businessId: business.businessId,
customerId,
staffId,
serviceIds: [business.haircutServiceId],
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
});
expect(created.success).toBe(true);
if (!created.success) return;
// "Reschedule" to 10 minutes later — overlaps its own current slot, must not be treated as a conflict with itself.
const rescheduled = await rescheduleBooking(prisma, {
appointmentId: created.appointment.id,
startAt: wallClockToInstant(monday, 10 * 60 + 10, business.timezone),
});
expect(rescheduled.success).toBe(true);
});
it("rejects rescheduling a completed appointment", async () => {
const { staffId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
const created = await createBooking(prisma, {
businessId: business.businessId,
customerId,
staffId,
serviceIds: [business.haircutServiceId],
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
});
if (!created.success) throw new Error("setup failed");
await prisma.appointment.update({ where: { id: created.appointment.id }, data: { status: "COMPLETED" } });
const rescheduled = await rescheduleBooking(prisma, {
appointmentId: created.appointment.id,
startAt: wallClockToInstant(monday, 15 * 60, business.timezone),
});
expect(rescheduled.success).toBe(false);
});
});
describe("cancellation", () => {
it("never deletes — sets status, cancelledAt, cancelledByUserId, and reason", async () => {
const { staffId } = await createTestStaff(business);
const { customerId, userId: customerUserId } = await createTestCustomer(business);
const created = await createBooking(prisma, {
businessId: business.businessId,
customerId,
staffId,
serviceIds: [business.haircutServiceId],
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
});
if (!created.success) throw new Error("setup failed");
const result = await cancelBooking(prisma, {
appointmentId: created.appointment.id,
cancelledByUserId: customerUserId,
reason: "Change of plans",
});
expect(result.success).toBe(true);
expect(result.appointment?.status).toBe("CANCELLED");
expect(result.appointment?.cancelledByUserId).toBe(customerUserId);
expect(result.appointment?.cancellationReason).toBe("Change of plans");
expect(result.appointment?.cancelledAt).toBeInstanceOf(Date);
// Still in the database — not deleted.
const stillExists = await prisma.appointment.findUnique({ where: { id: created.appointment.id } });
expect(stillExists).not.toBeNull();
});
it("frees the slot up for a new booking", async () => {
const { staffId } = await createTestStaff(business);
const { customerId, userId: customerUserId } = await createTestCustomer(business);
const startAt = wallClockToInstant(monday, 10 * 60, business.timezone);
const created = await createBooking(prisma, {
businessId: business.businessId,
customerId,
staffId,
serviceIds: [business.haircutServiceId],
startAt,
});
if (!created.success) throw new Error("setup failed");
await cancelBooking(prisma, { appointmentId: created.appointment.id, cancelledByUserId: customerUserId });
const rebooked = await createBooking(prisma, {
businessId: business.businessId,
customerId,
staffId,
serviceIds: [business.haircutServiceId],
startAt,
});
expect(rebooked.success).toBe(true);
});
it("rejects cancelling an already-completed appointment", async () => {
const { staffId } = await createTestStaff(business);
const { customerId, userId: customerUserId } = await createTestCustomer(business);
const created = await createBooking(prisma, {
businessId: business.businessId,
customerId,
staffId,
serviceIds: [business.haircutServiceId],
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
});
if (!created.success) throw new Error("setup failed");
await prisma.appointment.update({ where: { id: created.appointment.id }, data: { status: "COMPLETED" } });
const result = await cancelBooking(prisma, { appointmentId: created.appointment.id, cancelledByUserId: customerUserId });
expect(result.success).toBe(false);
});
});
});
@@ -0,0 +1,208 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { Weekday } from "@/generated/prisma/enums";
import { addDays, todayIn, wallClockToInstant, weekdayOf } from "@/lib/time/business-time";
import type { CalendarDate } from "@/lib/time/business-time";
import { cancelBooking, createBooking, rescheduleBooking } from "../booking.service";
import { cleanupTestBusiness, createTestBusiness, createTestCustomer, createTestGuestCustomer, createTestStaff, prisma } from "./fixtures";
import type { TestBusiness } from "./fixtures";
function nextWeekday(timezone: string, target: Weekday, minDaysAhead = 7): CalendarDate {
let date = addDays(todayIn(timezone), minDaysAhead);
for (let i = 0; i < 8; i++) {
if (weekdayOf(date) === target) return date;
date = addDays(date, 1);
}
throw new Error("unreachable");
}
/**
* Verifies booking.service.ts actually queues the right Notification rows
* for the right recipients on create/reschedule/cancel — see
* booking-notifications.ts. The full channel-resolution/preference logic
* is covered by notification.service.integration.test.ts; this file is
* specifically "does the booking workflow call notify() correctly".
*/
describe("booking notifications (integration)", () => {
let business: TestBusiness;
let monday: CalendarDate;
let guestProfileIds: string[];
beforeEach(async () => {
business = await createTestBusiness();
monday = nextWeekday(business.timezone, Weekday.MONDAY);
guestProfileIds = [];
});
afterEach(async () => {
await cleanupTestBusiness(business);
if (guestProfileIds.length > 0) {
await prisma.customerProfile.deleteMany({ where: { id: { in: guestProfileIds } } }).catch(() => {});
}
});
async function grantOwnerPermission(userId: string) {
const role = await prisma.role.create({
data: {
businessId: business.businessId,
name: "Test Owner",
permissions: { create: [{ permission: "VIEW_REPORTING" }] },
},
});
await prisma.staffProfile.update({ where: { userId }, data: { roleId: role.id } });
}
it("creating a booking notifies the customer, the staff member, and every owner", async () => {
const { staffId, userId: staffUserId } = await createTestStaff(business);
const { customerId, userId: customerUserId } = await createTestCustomer(business);
await grantOwnerPermission(staffUserId); // this staff member is also the "owner" for this test
const result = await createBooking(prisma, {
businessId: business.businessId,
customerId,
staffId,
serviceIds: [business.haircutServiceId],
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
});
expect(result.success).toBe(true);
if (!result.success) return;
const notifications = await prisma.notification.findMany({ where: { appointmentId: result.appointment.id } });
const types = notifications.map((n) => n.type).sort();
// A booking far enough out (monday is >= 7 days ahead — see nextWeekday) also schedules both
// 24h/2h reminders for the customer and the staff member — see reminder-scheduling.ts.
expect(types).toEqual(
[
"BOOKING_CONFIRMED",
"BOOKING_REMINDER",
"BOOKING_REMINDER",
"OWNER_NEW_BOOKING",
"STAFF_APPOINTMENT_REMINDER",
"STAFF_APPOINTMENT_REMINDER",
"STAFF_NEW_APPOINTMENT",
].sort(),
);
const customerNotification = notifications.find((n) => n.type === "BOOKING_CONFIRMED");
expect(customerNotification?.recipientUserId).toBe(customerUserId);
expect(customerNotification?.recipientCustomerId).toBe(customerId);
const staffNotification = notifications.find((n) => n.type === "STAFF_NEW_APPOINTMENT");
expect(staffNotification?.recipientUserId).toBe(staffUserId);
// Same staff member holds both roles in this test, so OWNER_NEW_BOOKING also targets them.
const ownerNotification = notifications.find((n) => n.type === "OWNER_NEW_BOOKING");
expect(ownerNotification?.recipientUserId).toBe(staffUserId);
});
it("a guest booking's customer notification has no recipientUserId but does have a deep link with the guest token", async () => {
const { staffId } = await createTestStaff(business);
const guest = await createTestGuestCustomer();
guestProfileIds.push(guest.customerId);
const result = await createBooking(prisma, {
businessId: business.businessId,
customerId: guest.customerId,
staffId,
serviceIds: [business.haircutServiceId],
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
});
expect(result.success).toBe(true);
if (!result.success) return;
expect(result.guestAccessToken).toBeTruthy();
const notification = await prisma.notification.findFirst({
where: { appointmentId: result.appointment.id, type: "BOOKING_CONFIRMED" },
});
expect(notification?.recipientUserId).toBeNull();
expect(notification?.recipientCustomerId).toBe(guest.customerId);
expect(notification?.deepLinkPath).toContain(result.guestAccessToken);
});
it("requireApproval creates a BOOKING_PENDING_APPROVAL notification instead of BOOKING_CONFIRMED", async () => {
await prisma.bookingRule.update({ where: { businessId: business.businessId }, data: { requireApproval: true } });
const { staffId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
const result = await createBooking(prisma, {
businessId: business.businessId,
customerId,
staffId,
serviceIds: [business.haircutServiceId],
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
});
expect(result.success).toBe(true);
if (!result.success) return;
const notification = await prisma.notification.findFirst({
where: { appointmentId: result.appointment.id, recipientCustomerId: customerId },
});
expect(notification?.type).toBe("BOOKING_PENDING_APPROVAL");
});
it("rescheduling notifies the customer and staff, and a second reschedule creates a fresh notification rather than deduping", async () => {
const { staffId, userId: staffUserId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
const created = await createBooking(prisma, {
businessId: business.businessId,
customerId,
staffId,
serviceIds: [business.haircutServiceId],
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
});
if (!created.success) throw new Error("setup failed");
const first = await rescheduleBooking(prisma, {
appointmentId: created.appointment.id,
startAt: wallClockToInstant(monday, 11 * 60, business.timezone),
});
expect(first.success).toBe(true);
const second = await rescheduleBooking(prisma, {
appointmentId: created.appointment.id,
startAt: wallClockToInstant(monday, 13 * 60, business.timezone),
});
expect(second.success).toBe(true);
const rescheduledNotifications = await prisma.notification.findMany({
where: { appointmentId: created.appointment.id, type: "BOOKING_RESCHEDULED" },
});
expect(rescheduledNotifications).toHaveLength(2);
const staffNotifications = await prisma.notification.findMany({
where: { appointmentId: created.appointment.id, type: "STAFF_APPOINTMENT_RESCHEDULED", recipientUserId: staffUserId },
});
expect(staffNotifications).toHaveLength(2);
});
it("cancelling notifies the customer, staff, and every owner, and only once even if called again on an already-cancelled appointment", async () => {
const { staffId, userId: staffUserId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
await grantOwnerPermission(staffUserId);
const created = await createBooking(prisma, {
businessId: business.businessId,
customerId,
staffId,
serviceIds: [business.haircutServiceId],
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
});
if (!created.success) throw new Error("setup failed");
const cancelled = await cancelBooking(prisma, { appointmentId: created.appointment.id });
expect(cancelled.success).toBe(true);
const cancelNotifications = await prisma.notification.findMany({
where: { appointmentId: created.appointment.id, type: { in: ["BOOKING_CANCELLED", "STAFF_APPOINTMENT_CANCELLED", "OWNER_BOOKING_CANCELLED"] } },
});
expect(cancelNotifications).toHaveLength(3);
// Cancelling again is rejected by cancelBooking itself (already CANCELLED) — never reaches notify() a second time.
const secondAttempt = await cancelBooking(prisma, { appointmentId: created.appointment.id });
expect(secondAttempt.success).toBe(false);
const stillOnlyOnce = await prisma.notification.count({
where: { appointmentId: created.appointment.id, type: "BOOKING_CANCELLED" },
});
expect(stillOnlyOnce).toBe(1);
});
});
@@ -0,0 +1,71 @@
import { describe, expect, it } from "vitest";
import { validateBookingRuleInput } from "../booking-rule.service";
import type { BookingRuleInput } from "../booking-rule.service";
const base: BookingRuleInput = {
minNoticeMinutes: 60,
maxAdvanceDays: 30,
cancellationWindowHours: 24,
rescheduleWindowHours: 24,
slotIntervalMinutes: 15,
bufferBeforeMinutes: 0,
bufferAfterMinutes: 0,
maxAppointmentsPerDay: null,
maxAppointmentsPerStaffPerDay: null,
customerCanChooseStaff: true,
allowGuestBooking: true,
requireApproval: false,
};
describe("validateBookingRuleInput", () => {
it("accepts the default configuration", () => {
expect(validateBookingRuleInput(base).valid).toBe(true);
});
it("rejects a negative minimum notice", () => {
const result = validateBookingRuleInput({ ...base, minNoticeMinutes: -1 });
expect(result.valid).toBe(false);
expect(result.issues[0]?.code).toBe("INVALID_INPUT");
});
it("rejects a maximum advance window under 1 day", () => {
const result = validateBookingRuleInput({ ...base, maxAdvanceDays: 0 });
expect(result.valid).toBe(false);
});
it("rejects a negative cancellation window", () => {
expect(validateBookingRuleInput({ ...base, cancellationWindowHours: -1 }).valid).toBe(false);
});
it("rejects a negative reschedule window", () => {
expect(validateBookingRuleInput({ ...base, rescheduleWindowHours: -1 }).valid).toBe(false);
});
it.each([4, 121])("rejects a slot interval outside 5-120 minutes (%s)", (slotIntervalMinutes) => {
expect(validateBookingRuleInput({ ...base, slotIntervalMinutes }).valid).toBe(false);
});
it.each([5, 120])("accepts slot interval boundaries (%s)", (slotIntervalMinutes) => {
expect(validateBookingRuleInput({ ...base, slotIntervalMinutes }).valid).toBe(true);
});
it("rejects a negative buffer", () => {
expect(validateBookingRuleInput({ ...base, bufferBeforeMinutes: -5 }).valid).toBe(false);
});
it("accepts null max-appointments-per-day (unlimited)", () => {
expect(validateBookingRuleInput({ ...base, maxAppointmentsPerDay: null }).valid).toBe(true);
});
it("rejects a zero max-appointments-per-day", () => {
expect(validateBookingRuleInput({ ...base, maxAppointmentsPerDay: 0 }).valid).toBe(false);
});
it("rejects a zero max-appointments-per-staff-per-day", () => {
expect(validateBookingRuleInput({ ...base, maxAppointmentsPerStaffPerDay: 0 }).valid).toBe(false);
});
it("accepts a positive max-appointments-per-staff-per-day", () => {
expect(validateBookingRuleInput({ ...base, maxAppointmentsPerStaffPerDay: 5 }).valid).toBe(true);
});
});
@@ -0,0 +1,114 @@
import { describe, expect, it } from "vitest";
import {
checkBusinessDailyLimit,
checkMaxAdvance,
checkMinNotice,
checkStaffDailyLimit,
combinedServiceDuration,
computeEffectiveBuffer,
isLateCancellation,
isLateReschedule,
} from "../business-rules.service";
describe("checkMinNotice", () => {
const now = new Date("2026-08-10T10:00:00Z");
it("fails when the candidate start is too soon", () => {
const candidate = new Date("2026-08-10T10:30:00Z"); // 30 min from now
const result = checkMinNotice(candidate, now, 120);
expect(result.valid).toBe(false);
expect(result.issues[0]?.code).toBe("BELOW_MIN_NOTICE");
});
it("passes when the candidate start is far enough ahead", () => {
const candidate = new Date("2026-08-10T13:00:00Z"); // 3h from now
expect(checkMinNotice(candidate, now, 120).valid).toBe(true);
});
it("passes exactly at the boundary", () => {
const candidate = new Date("2026-08-10T12:00:00Z"); // exactly 120 min
expect(checkMinNotice(candidate, now, 120).valid).toBe(true);
});
});
describe("checkMaxAdvance", () => {
it("fails when the date is beyond the max advance window", () => {
const result = checkMaxAdvance("2026-11-01", "2026-08-10", 60);
expect(result.valid).toBe(false);
expect(result.issues[0]?.code).toBe("BEYOND_MAX_ADVANCE");
});
it("passes when the date is within the window", () => {
expect(checkMaxAdvance("2026-08-20", "2026-08-10", 60).valid).toBe(true);
});
it("passes exactly at the boundary", () => {
expect(checkMaxAdvance("2026-10-09", "2026-08-10", 60).valid).toBe(true); // exactly 60 days
});
});
describe("checkBusinessDailyLimit / checkStaffDailyLimit", () => {
it("null limit means unlimited", () => {
expect(checkBusinessDailyLimit(999, null).valid).toBe(true);
expect(checkStaffDailyLimit(999, null).valid).toBe(true);
});
it("fails once the existing count reaches the limit", () => {
expect(checkBusinessDailyLimit(10, 10).valid).toBe(false);
expect(checkStaffDailyLimit(5, 5).valid).toBe(false);
});
it("passes below the limit", () => {
expect(checkBusinessDailyLimit(9, 10).valid).toBe(true);
expect(checkStaffDailyLimit(4, 5).valid).toBe(true);
});
});
describe("computeEffectiveBuffer", () => {
it("sums the business baseline with the largest per-service buffer", () => {
const result = computeEffectiveBuffer(
{ bufferBeforeMinutes: 5, bufferAfterMinutes: 5 },
[
{ bufferBeforeMinutes: 0, bufferAfterMinutes: 15 }, // e.g. colour treatment
{ bufferBeforeMinutes: 0, bufferAfterMinutes: 0 },
],
);
expect(result).toEqual({ beforeMinutes: 5, afterMinutes: 20 });
});
it("returns just the business baseline when no service adds a buffer", () => {
expect(computeEffectiveBuffer({ bufferBeforeMinutes: 0, bufferAfterMinutes: 5 }, [])).toEqual({
beforeMinutes: 0,
afterMinutes: 5,
});
});
});
describe("isLateCancellation / isLateReschedule", () => {
const now = new Date("2026-08-10T10:00:00Z");
it("flags a cancellation inside the free window as late", () => {
const appointmentStart = new Date("2026-08-10T20:00:00Z"); // 10h away, window is 24h
expect(isLateCancellation(appointmentStart, now, 24)).toBe(true);
});
it("does not flag a cancellation outside the free window", () => {
const appointmentStart = new Date("2026-08-12T10:00:00Z"); // 48h away
expect(isLateCancellation(appointmentStart, now, 24)).toBe(false);
});
it("applies the same logic to reschedules with their own window", () => {
const appointmentStart = new Date("2026-08-10T11:00:00Z"); // 1h away, window is 24h
expect(isLateReschedule(appointmentStart, now, 24)).toBe(true);
});
});
describe("combinedServiceDuration", () => {
it("sums durations across multiple services", () => {
expect(combinedServiceDuration([{ durationMinutes: 30 }, { durationMinutes: 15 }])).toBe(45);
});
it("returns 0 for an empty list", () => {
expect(combinedServiceDuration([])).toBe(0);
});
});
@@ -0,0 +1,195 @@
import "dotenv/config";
import { PrismaClient } from "@/generated/prisma/client";
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
import { buildMysqlPoolConfig } from "@/lib/db/mysql-pool-config";
import { Weekday } from "@/generated/prisma/enums";
/**
* Integration-test fixtures — real MySQL, real transactions. Not used
* by the unit test suite (`npm test`); only by `npm run test:integration`.
* See docs/phase-7-summary.md for how to point this at a database
* (migrated from PostgreSQL to MySQL in Phase 7).
*/
const adapter = new PrismaMariaDb(buildMysqlPoolConfig());
export const prisma = new PrismaClient({ adapter });
let counter = 0;
function uniqueId(prefix: string): string {
counter += 1;
return `${prefix}-${Date.now()}-${counter}-${Math.random().toString(36).slice(2, 8)}`;
}
export interface TestBusiness {
businessId: string;
timezone: string;
categoryId: string;
haircutServiceId: string;
beardTrimServiceId: string;
createdUserIds: string[];
}
/** Mon–Sat 09:00–18:00, Sunday closed. Booking rule defaults are deliberately permissive (no min notice, no daily caps) so most tests don't have to fight them — tests that specifically exercise a rule override it. */
export async function createTestBusiness(): Promise<TestBusiness> {
const slug = uniqueId("test-biz");
const timezone = "Europe/London";
const business = await prisma.business.create({
data: {
slug,
name: "Test Barbershop",
timezone,
hours: {
create: [
{ dayOfWeek: Weekday.MONDAY, opensAtMinutes: 9 * 60, closesAtMinutes: 18 * 60 },
{ dayOfWeek: Weekday.TUESDAY, opensAtMinutes: 9 * 60, closesAtMinutes: 18 * 60 },
{ dayOfWeek: Weekday.WEDNESDAY, opensAtMinutes: 9 * 60, closesAtMinutes: 18 * 60 },
{ dayOfWeek: Weekday.THURSDAY, opensAtMinutes: 9 * 60, closesAtMinutes: 18 * 60 },
{ dayOfWeek: Weekday.FRIDAY, opensAtMinutes: 9 * 60, closesAtMinutes: 18 * 60 },
{ dayOfWeek: Weekday.SATURDAY, opensAtMinutes: 9 * 60, closesAtMinutes: 18 * 60 },
{ dayOfWeek: Weekday.SUNDAY, isClosed: true },
],
},
bookingRule: {
create: {
minNoticeMinutes: 0,
maxAdvanceDays: 365,
slotIntervalMinutes: 30,
bufferBeforeMinutes: 0,
bufferAfterMinutes: 0,
},
},
},
});
const category = await prisma.serviceCategory.create({
data: { businessId: business.id, name: "Hair" },
});
const haircut = await prisma.service.create({
data: {
businessId: business.id,
categoryId: category.id,
name: "Haircut",
durationMinutes: 30,
price: 20,
},
});
const beardTrim = await prisma.service.create({
data: {
businessId: business.id,
categoryId: category.id,
name: "Beard Trim",
durationMinutes: 15,
price: 10,
},
});
return {
businessId: business.id,
timezone,
categoryId: category.id,
haircutServiceId: haircut.id,
beardTrimServiceId: beardTrim.id,
createdUserIds: [],
};
}
/** A staff member who works Mon–Sat 09:00–18:00 (matching the business), can perform both fixture services. */
export async function createTestStaff(
business: TestBusiness,
opts: { workingDays?: Weekday[] } = {},
): Promise<{ staffId: string; userId: string }> {
const workingDays = opts.workingDays ?? [
Weekday.MONDAY,
Weekday.TUESDAY,
Weekday.WEDNESDAY,
Weekday.THURSDAY,
Weekday.FRIDAY,
Weekday.SATURDAY,
];
const role = await prisma.role.create({
data: { businessId: business.businessId, name: uniqueId("Barber") },
});
const user = await prisma.user.create({
data: {
email: `${uniqueId("staff")}@test.local`,
name: "Test Barber",
staffProfile: {
create: {
businessId: business.businessId,
roleId: role.id,
workingHours: {
create: Object.values(Weekday).map((dayOfWeek) =>
workingDays.includes(dayOfWeek)
? { dayOfWeek, startMinutes: 9 * 60, endMinutes: 18 * 60 }
: { dayOfWeek, isOff: true },
),
},
services: {
create: [{ serviceId: business.haircutServiceId }, { serviceId: business.beardTrimServiceId }],
},
},
},
},
include: { staffProfile: true },
});
business.createdUserIds.push(user.id);
return { staffId: user.staffProfile!.id, userId: user.id };
}
export async function createTestCustomer(business: TestBusiness): Promise<{ customerId: string; userId: string }> {
const email = `${uniqueId("customer")}@test.local`;
const user = await prisma.user.create({
data: {
email,
name: "Test Customer",
customerProfile: {
create: { firstName: "Test", lastName: "Customer", email, isGuest: false },
},
},
include: { customerProfile: true },
});
business.createdUserIds.push(user.id);
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(() => {});
// StaffProfile.roleId is ALSO onDelete: Restrict, and Business cascades
// to both Role and StaffProfile. On Postgres, the database's own native
// FK cascade resolver orders this correctly; empirically (Phase 7, see
// docs/phase-7-summary.md), Prisma 7's client-engine-runtime interprets
// cascading deletes itself against MySQL and does NOT reliably delete
// StaffProfile before Role, leaving a P2003 "Foreign key constraint
// violated on (roleId)" that this function's own .catch(() => {}) was
// silently swallowing — leaking a "Test Barbershop" business per run.
// Deleting StaffProfile explicitly first removes the ordering ambiguity.
await prisma.staffProfile.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(() => {});
}
}
@@ -0,0 +1,158 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { Weekday } from "@/generated/prisma/enums";
import { addDays, todayIn, wallClockToInstant, weekdayOf } from "@/lib/time/business-time";
import type { CalendarDate } from "@/lib/time/business-time";
import { cancelBooking, createBooking, rescheduleBooking } from "../booking.service";
import { cleanupTestBusiness, createTestBusiness, createTestCustomer, createTestStaff, prisma } from "./fixtures";
import type { TestBusiness } from "./fixtures";
function nextWeekday(timezone: string, target: Weekday, minDaysAhead = 7): CalendarDate {
let date = addDays(todayIn(timezone), minDaysAhead);
for (let i = 0; i < 8; i++) {
if (weekdayOf(date) === target) return date;
date = addDays(date, 1);
}
throw new Error("unreachable");
}
async function reminderDeliveries(appointmentId: string) {
return prisma.notificationDelivery.findMany({
where: {
notification: { appointmentId, type: { in: ["BOOKING_REMINDER", "STAFF_APPOINTMENT_REMINDER"] } },
channel: { in: ["EMAIL", "PUSH"] },
},
include: { notification: true },
});
}
describe("reminder scheduling (integration)", () => {
let business: TestBusiness;
let monday: CalendarDate;
beforeEach(async () => {
business = await createTestBusiness();
monday = nextWeekday(business.timezone, Weekday.MONDAY);
});
afterEach(async () => {
await cleanupTestBusiness(business);
});
it("schedules 24h and 2h EMAIL reminders for both customer and staff, as SCHEDULED (not PENDING)", async () => {
const { staffId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
const result = await createBooking(prisma, {
businessId: business.businessId,
customerId,
staffId,
serviceIds: [business.haircutServiceId],
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
});
if (!result.success) throw new Error("setup failed");
const deliveries = await reminderDeliveries(result.appointment.id);
expect(deliveries).toHaveLength(4); // 2 offsets × (customer + staff)
for (const d of deliveries) {
expect(d.status).toBe("SCHEDULED");
expect(d.scheduledFor).not.toBeNull();
expect(d.scheduledFor!.getTime()).toBeLessThan(result.appointment.startAt.getTime());
}
});
it("does not schedule reminders for a PENDING (requireApproval) booking", async () => {
await prisma.bookingRule.update({ where: { businessId: business.businessId }, data: { requireApproval: true } });
const { staffId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
const result = await createBooking(prisma, {
businessId: business.businessId,
customerId,
staffId,
serviceIds: [business.haircutServiceId],
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
});
if (!result.success) throw new Error("setup failed");
const deliveries = await reminderDeliveries(result.appointment.id);
expect(deliveries).toHaveLength(0);
});
it("does not schedule a reminder offset that would already be in the past", async () => {
const { staffId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
// Book for 1 hour from now — the 24h and 2h-before offsets are both already past.
const result = await createBooking(prisma, {
businessId: business.businessId,
customerId,
staffId,
serviceIds: [business.haircutServiceId],
startAt: new Date(Date.now() + 3 * 60 * 60_000), // 3h out, staff working hours permitting — see note below
}).catch(() => null);
// This may fail validation depending on min-notice/working-hours fixtures; only assert the
// reminder-skipping behaviour when the booking itself actually succeeded.
if (!result || !result.success) return;
const deliveries = await reminderDeliveries(result.appointment.id);
// 3h out: the 2h-before offset is still in the future, the 24h-before offset is in the past.
expect(deliveries.length).toBeLessThanOrEqual(2);
expect(deliveries.every((d) => d.notification.idempotencyKey.includes("reminder-2h"))).toBe(true);
});
it("rescheduling cancels the old reminders and schedules fresh ones for the new time", async () => {
const { staffId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
const created = await createBooking(prisma, {
businessId: business.businessId,
customerId,
staffId,
serviceIds: [business.haircutServiceId],
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
});
if (!created.success) throw new Error("setup failed");
const originalDeliveries = await reminderDeliveries(created.appointment.id);
expect(originalDeliveries).toHaveLength(4);
const rescheduled = await rescheduleBooking(prisma, {
appointmentId: created.appointment.id,
startAt: wallClockToInstant(monday, 14 * 60, business.timezone),
});
expect(rescheduled.success).toBe(true);
if (!rescheduled.success) return;
const allDeliveries = await reminderDeliveries(created.appointment.id);
const cancelledCount = allDeliveries.filter((d) => d.status === "CANCELLED").length;
const scheduledCount = allDeliveries.filter((d) => d.status === "SCHEDULED").length;
expect(cancelledCount).toBe(4); // the original 4, now superseded
expect(scheduledCount).toBe(4); // 4 fresh ones for the new time
const freshOnes = allDeliveries.filter((d) => d.status === "SCHEDULED");
for (const d of freshOnes) {
expect(d.scheduledFor!.getTime()).toBeLessThan(rescheduled.appointment.startAt.getTime());
}
});
it("cancelling the appointment cancels its scheduled reminders", async () => {
const { staffId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
const created = await createBooking(prisma, {
businessId: business.businessId,
customerId,
staffId,
serviceIds: [business.haircutServiceId],
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
});
if (!created.success) throw new Error("setup failed");
const cancelled = await cancelBooking(prisma, { appointmentId: created.appointment.id });
expect(cancelled.success).toBe(true);
const deliveries = await reminderDeliveries(created.appointment.id);
expect(deliveries.every((d) => d.status === "CANCELLED")).toBe(true);
expect(deliveries).toHaveLength(4);
});
});
@@ -0,0 +1,177 @@
import { describe, expect, it } from "vitest";
import {
computeFreeWindows,
expandWithBuffer,
generateSlotStarts,
intersectWindows,
mergeWindows,
subtractWindows,
} from "../slot-calculator";
import type { TimeWindow } from "../types";
const w = (startMinutes: number, endMinutes: number): TimeWindow => ({ startMinutes, endMinutes });
describe("mergeWindows", () => {
it("merges overlapping and touching windows, drops invalid/zero-length ones", () => {
expect(mergeWindows([w(0, 60), w(30, 90), w(90, 120)])).toEqual([w(0, 120)]);
expect(mergeWindows([w(0, 30), w(40, 60)])).toEqual([w(0, 30), w(40, 60)]);
expect(mergeWindows([w(10, 10), w(20, 15)])).toEqual([]); // zero-length / inverted dropped
});
it("sorts unordered input before merging", () => {
expect(mergeWindows([w(100, 120), w(0, 30)])).toEqual([w(0, 30), w(100, 120)]);
});
});
describe("subtractWindows", () => {
it("removes a window entirely contained within a base window, splitting it in two", () => {
expect(subtractWindows([w(0, 120)], [w(30, 60)])).toEqual([w(0, 30), w(60, 120)]);
});
it("removes from the start and end", () => {
expect(subtractWindows([w(0, 120)], [w(0, 30)])).toEqual([w(30, 120)]);
expect(subtractWindows([w(0, 120)], [w(90, 120)])).toEqual([w(0, 90)]);
});
it("removes the whole window when the cut fully covers it", () => {
expect(subtractWindows([w(30, 60)], [w(0, 120)])).toEqual([]);
});
it("no-ops when there's no overlap", () => {
expect(subtractWindows([w(0, 60)], [w(60, 120)])).toEqual([w(0, 60)]);
expect(subtractWindows([w(60, 120)], [w(0, 60)])).toEqual([w(60, 120)]);
});
it("applies multiple cuts, including overlapping ones", () => {
expect(subtractWindows([w(0, 480)], [w(60, 90), w(70, 120), w(300, 330)])).toEqual([
w(0, 60),
w(120, 300),
w(330, 480),
]);
});
});
describe("intersectWindows", () => {
it("intersects business hours with staff hours", () => {
expect(intersectWindows([w(600, 1140)], [w(540, 1080)])).toEqual([w(600, 1080)]);
});
it("returns nothing when there's no overlap", () => {
expect(intersectWindows([w(0, 60)], [w(120, 180)])).toEqual([]);
});
});
describe("computeFreeWindows", () => {
const fullDayOpen = w(9 * 60, 18 * 60); // 09:00–18:00
it("returns nothing if the business is closed", () => {
expect(
computeFreeWindows({ businessOpen: null, staffWorking: fullDayOpen, breaks: [], onLeave: false, busy: [] }),
).toEqual([]);
});
it("returns nothing if the staff member isn't scheduled to work", () => {
expect(
computeFreeWindows({ businessOpen: fullDayOpen, staffWorking: null, breaks: [], onLeave: false, busy: [] }),
).toEqual([]);
});
it("returns nothing if the staff member is on approved leave, even if otherwise free", () => {
expect(
computeFreeWindows({
businessOpen: fullDayOpen,
staffWorking: fullDayOpen,
breaks: [],
onLeave: true,
busy: [],
}),
).toEqual([]);
});
it("intersects business and staff hours when staff works shorter hours", () => {
const result = computeFreeWindows({
businessOpen: w(9 * 60, 18 * 60),
staffWorking: w(10 * 60, 16 * 60),
breaks: [],
onLeave: false,
busy: [],
});
expect(result).toEqual([w(10 * 60, 16 * 60)]);
});
it("subtracts a lunch break", () => {
const result = computeFreeWindows({
businessOpen: fullDayOpen,
staffWorking: fullDayOpen,
breaks: [w(12 * 60, 13 * 60)],
onLeave: false,
busy: [],
});
expect(result).toEqual([w(9 * 60, 12 * 60), w(13 * 60, 18 * 60)]);
});
it("subtracts buffered busy time from existing appointments", () => {
const result = computeFreeWindows({
businessOpen: fullDayOpen,
staffWorking: fullDayOpen,
breaks: [],
onLeave: false,
busy: [w(10 * 60 - 5, 10 * 60 + 45 + 5)], // 10:00–10:45 appointment with 5min buffer each side
});
expect(result).toEqual([w(9 * 60, 10 * 60 - 5), w(10 * 60 + 50, 18 * 60)]);
});
it("combines breaks and busy time in one pass", () => {
const result = computeFreeWindows({
businessOpen: fullDayOpen,
staffWorking: fullDayOpen,
breaks: [w(12 * 60, 13 * 60)],
onLeave: false,
busy: [w(9 * 60, 9 * 60 + 30)],
});
expect(result).toEqual([w(9 * 60 + 30, 12 * 60), w(13 * 60, 18 * 60)]);
});
});
describe("generateSlotStarts", () => {
it("generates grid-aligned starts that fit the requested duration", () => {
// 09:00–10:00 window, 30 min slots, 30 min service → 09:00 and 09:30 only.
expect(generateSlotStarts([w(540, 600)], { slotIntervalMinutes: 30, durationMinutes: 30 })).toEqual([540, 570]);
});
it("excludes a start that would run past the end of the window", () => {
// 09:00–09:45 window, 30 min slots, 30 min service → only 09:00 fits (09:30+30=10:00 doesn't).
expect(generateSlotStarts([w(540, 585)], { slotIntervalMinutes: 30, durationMinutes: 30 })).toEqual([540]);
});
it("aligns to the global grid, not the window's own start", () => {
// Window starts at 09:05 (e.g. after a short buffer); with a 30-min grid the first candidate is 09:30, not 09:05.
expect(generateSlotStarts([w(545, 630)], { slotIntervalMinutes: 30, durationMinutes: 30 })).toEqual([570, 600]);
});
it("handles a multi-service duration longer than the slot interval", () => {
// 09:00–10:30, 15-min grid, 45-min combined service duration.
const starts = generateSlotStarts([w(540, 630)], { slotIntervalMinutes: 15, durationMinutes: 45 });
expect(starts).toEqual([540, 555, 570, 585]); // last start 09:45 -> ends 10:30 exactly
});
it("returns nothing when the window is shorter than the requested duration", () => {
expect(generateSlotStarts([w(540, 560)], { slotIntervalMinutes: 15, durationMinutes: 30 })).toEqual([]);
});
it("rejects non-positive interval or duration", () => {
expect(() => generateSlotStarts([w(0, 60)], { slotIntervalMinutes: 0, durationMinutes: 30 })).toThrow();
expect(() => generateSlotStarts([w(0, 60)], { slotIntervalMinutes: 30, durationMinutes: 0 })).toThrow();
});
});
describe("expandWithBuffer", () => {
it("pads both sides", () => {
expect(expandWithBuffer(w(600, 630), { beforeMinutes: 5, afterMinutes: 10 })).toEqual(w(595, 640));
});
it("clamps at day boundaries instead of going negative or past 1440", () => {
expect(expandWithBuffer(w(0, 30), { beforeMinutes: 15, afterMinutes: 0 })).toEqual(w(0, 30));
expect(expandWithBuffer(w(1420, 1440), { beforeMinutes: 0, afterMinutes: 30 })).toEqual(w(1420, 1440));
});
});
@@ -0,0 +1,175 @@
import type { PrismaClient } from "@/generated/prisma/client";
import { AppointmentStatus } from "@/generated/prisma/enums";
import { endOfBusinessDay, startOfBusinessDay } from "@/lib/time/business-time";
import type { CalendarDate } from "@/lib/time/business-time";
import { fail, ok } from "./types";
import type { ValidationResult } from "./types";
/**
* Day-of-operations concerns: retrieving appointments and moving them
* through their status lifecycle (check-in, start, complete, no-show).
* Deliberately has nothing to do with booking/rescheduling/availability —
* see booking.service.ts for that. An appointment already exists by the
* time anything in this file touches it.
*/
const TRANSITIONS: Record<AppointmentStatus, AppointmentStatus[]> = {
PENDING: [AppointmentStatus.CONFIRMED, AppointmentStatus.CANCELLED],
CONFIRMED: [AppointmentStatus.CHECKED_IN, AppointmentStatus.CANCELLED, AppointmentStatus.NO_SHOW],
CHECKED_IN: [AppointmentStatus.IN_PROGRESS, AppointmentStatus.CANCELLED],
IN_PROGRESS: [AppointmentStatus.COMPLETED],
COMPLETED: [],
CANCELLED: [],
NO_SHOW: [],
};
export function canTransition(from: AppointmentStatus, to: AppointmentStatus): boolean {
return TRANSITIONS[from].includes(to);
}
export function validateTransition(
from: AppointmentStatus,
to: AppointmentStatus,
): ValidationResult {
if (!canTransition(from, to)) {
return fail({
code: "INVALID_STATUS_TRANSITION",
message: `An appointment can't move from ${from} to ${to}.`,
});
}
return ok();
}
const APPOINTMENT_INCLUDE = {
services: { include: { service: true } },
staff: { include: { user: true } },
customer: true,
} as const;
export async function getAppointmentById(prisma: PrismaClient, id: string) {
return prisma.appointment.findUnique({ where: { id }, include: APPOINTMENT_INCLUDE });
}
export interface ListAppointmentsParams {
businessId?: string;
staffId?: string;
customerId?: string;
statuses?: AppointmentStatus[];
fromDate?: CalendarDate;
toDate?: CalendarDate;
businessTimezone?: string; // required if fromDate/toDate given
}
export async function listAppointments(prisma: PrismaClient, params: ListAppointmentsParams) {
const dateFilter =
params.fromDate && params.toDate && params.businessTimezone
? {
startAt: {
gte: startOfBusinessDay(params.fromDate, params.businessTimezone),
lt: endOfBusinessDay(params.toDate, params.businessTimezone),
},
}
: {};
return prisma.appointment.findMany({
where: {
...(params.businessId ? { businessId: params.businessId } : {}),
...(params.staffId ? { staffId: params.staffId } : {}),
...(params.customerId ? { customerId: params.customerId } : {}),
...(params.statuses ? { status: { in: params.statuses } } : {}),
...dateFilter,
},
include: APPOINTMENT_INCLUDE,
orderBy: { startAt: "asc" },
});
}
export type TransitionResult =
| { success: true; appointment: NonNullable<Awaited<ReturnType<typeof getAppointmentById>>> }
| { success: false; validation: ValidationResult };
async function transitionStatus(
prisma: PrismaClient,
appointmentId: string,
to: AppointmentStatus,
): Promise<TransitionResult> {
return prisma.$transaction(async (tx) => {
const appointment = await tx.appointment.findUnique({ where: { id: appointmentId } });
if (!appointment) {
return {
success: false,
validation: fail({ code: "APPOINTMENT_NOT_FOUND", message: "Appointment not found." }),
};
}
const check = validateTransition(appointment.status, to);
if (!check.valid) return { success: false, validation: check };
const updated = await tx.appointment.update({
where: { id: appointmentId },
data: { status: to },
include: APPOINTMENT_INCLUDE,
});
return { success: true, appointment: updated };
});
}
export function checkIn(prisma: PrismaClient, appointmentId: string) {
return transitionStatus(prisma, appointmentId, AppointmentStatus.CHECKED_IN);
}
export function start(prisma: PrismaClient, appointmentId: string) {
return transitionStatus(prisma, appointmentId, AppointmentStatus.IN_PROGRESS);
}
export function complete(prisma: PrismaClient, appointmentId: string) {
return transitionStatus(prisma, appointmentId, AppointmentStatus.COMPLETED);
}
export function markNoShow(prisma: PrismaClient, appointmentId: string) {
return transitionStatus(prisma, appointmentId, AppointmentStatus.NO_SHOW);
}
export function confirm(prisma: PrismaClient, appointmentId: string) {
return transitionStatus(prisma, appointmentId, AppointmentStatus.CONFIRMED);
}
const CURRENT_APPOINTMENT_ACTIVE_STATUSES: AppointmentStatus[] = [
AppointmentStatus.PENDING,
AppointmentStatus.CONFIRMED,
AppointmentStatus.CHECKED_IN,
AppointmentStatus.IN_PROGRESS,
];
/**
* Which of a staff member's appointments is "the current one" for the
* Current Appointment screen — a pure function over already-fetched
* appointments (the page fetches today's list via listAppointments; this
* just picks the most relevant one), so it's cheap to unit test every edge
* case without a database. Priority, per the Phase 5 brief:
* 1. IN_PROGRESS
* 2. CHECKED_IN
* 3. Scheduled right now (startAt <= now < endAt) and not yet terminal
* 4. The next upcoming one today
* 5. null — the page shows its own empty state
*/
export function selectCurrentAppointment<
T extends { status: AppointmentStatus; startAt: Date; endAt: Date },
>(appointments: T[], now: Date): T | null {
const inProgress = appointments.find((a) => a.status === AppointmentStatus.IN_PROGRESS);
if (inProgress) return inProgress;
const checkedIn = appointments.find((a) => a.status === AppointmentStatus.CHECKED_IN);
if (checkedIn) return checkedIn;
const scheduledNow = appointments.find(
(a) =>
CURRENT_APPOINTMENT_ACTIVE_STATUSES.includes(a.status) && a.startAt <= now && a.endAt > now,
);
if (scheduledNow) return scheduledNow;
const upcoming = appointments
.filter((a) => CURRENT_APPOINTMENT_ACTIVE_STATUSES.includes(a.status) && a.startAt > now)
.sort((a, b) => a.startAt.getTime() - b.startAt.getTime());
return upcoming[0] ?? null;
}
@@ -0,0 +1,587 @@
import type { PrismaClient } from "@/generated/prisma/client";
import { Weekday } from "@/generated/prisma/enums";
import {
calendarDateOf,
calendarDateToUtcMidnight,
eachCalendarDate,
endOfBusinessDay,
minutesFromMidnight,
startOfBusinessDay,
todayIn,
wallClockToInstant,
weekdayOf,
} from "@/lib/time/business-time";
import type { CalendarDate } from "@/lib/time/business-time";
import {
checkBusinessDailyLimit,
checkMaxAdvance,
checkMinNotice,
checkStaffDailyLimit,
combinedServiceDuration,
computeEffectiveBuffer,
} from "./business-rules.service";
import type { BookingRuleConfig } from "./business-rules.service";
import { computeFreeWindows, generateSlotStarts } from "./slot-calculator";
import {
businessOpenByWeekdayFrom,
getStaffFreeWindowsForRange,
} from "./staff-availability.service";
import { fail, mergeValidation, ok } from "./types";
import type { ServiceRequirement, TimeWindow, ValidationResult } from "./types";
/**
* The customer-facing query layer: "what services/barbers/dates/times can
* actually be booked right now". Built entirely on StaffAvailabilityService
* (per-staff free windows) and BusinessRulesService (booking-window/limit
* checks) — this module owns combining them across staff and services, not
* the underlying per-staff or per-rule logic.
*/
export interface AvailableService {
id: string;
categoryId: string;
name: string;
/** Collapses in the UI when null/empty — see ServiceCard and ServiceSelector. */
description: string | null;
/** Set via the Media Manager — see src/features/media/components/media-picker.tsx. */
imageUrl: string | null;
durationMinutes: number;
price: string; // Decimal serialized to string — currency formatting is a presentation concern, not this layer's
bufferBeforeMinutes: number;
bufferAfterMinutes: number;
}
export async function getAvailableServices(
prisma: PrismaClient,
params: { businessId: string; categoryId?: string; staffId?: string },
): Promise<AvailableService[]> {
const services = await prisma.service.findMany({
where: {
businessId: params.businessId,
isActive: true,
allowOnlineBooking: true,
...(params.categoryId ? { categoryId: params.categoryId } : {}),
...(params.staffId ? { staff: { some: { staffId: params.staffId } } } : {}),
},
orderBy: [{ displayOrder: "asc" }, { name: "asc" }],
});
return services.map((s) => ({
id: s.id,
categoryId: s.categoryId,
name: s.name,
description: s.description,
imageUrl: s.imageUrl,
durationMinutes: s.durationMinutes,
price: s.price.toString(),
bufferBeforeMinutes: s.bufferBeforeMinutes,
bufferAfterMinutes: s.bufferAfterMinutes,
}));
}
export interface AvailableStaffMember {
id: string;
name: string;
avatarUrl: string | null;
}
/** Staff who can perform every requested service (a StaffService row must exist for each), are active, and are bookable online. */
export async function getAvailableStaff(
prisma: PrismaClient,
params: { businessId: string; serviceIds: string[] },
): Promise<AvailableStaffMember[]> {
if (params.serviceIds.length === 0) return [];
const staff = await prisma.staffProfile.findMany({
where: {
businessId: params.businessId,
status: "ACTIVE",
isBookableOnline: true,
AND: params.serviceIds.map((serviceId) => ({ services: { some: { serviceId } } })),
},
include: { user: { select: { name: true, image: true } } },
});
return staff.map((s) => ({
id: s.id,
name: s.user.name ?? "Staff member",
avatarUrl: s.user.image,
}));
}
export interface ResolvedBookingContext {
business: { id: string; timezone: string };
bookingRule: BookingRuleConfig;
services: ServiceRequirement[];
totalDurationMinutes: number;
effectiveBuffer: { beforeMinutes: number; afterMinutes: number };
eligibleStaffIds: string[];
}
const DEFAULT_BOOKING_RULE: BookingRuleConfig = {
minNoticeMinutes: 120,
maxAdvanceDays: 60,
cancellationWindowHours: 24,
rescheduleWindowHours: 24,
slotIntervalMinutes: 30,
bufferBeforeMinutes: 0,
bufferAfterMinutes: 5,
maxAppointmentsPerDay: null,
maxAppointmentsPerStaffPerDay: null,
customerCanChooseStaff: true,
allowGuestBooking: true,
requireApproval: false,
};
/**
* Shared setup for every availability query and for booking validation:
* resolve the business, its booking rules, the requested services, and
* which staff can actually perform all of them. Throws (via a
* ValidationResult the caller should check) rather than silently
* returning an empty result, so callers get a meaningful reason.
*/
export async function resolveBookingContext(
prisma: PrismaClient,
params: { businessId: string; serviceIds: string[]; staffId?: string },
): Promise<{ context: ResolvedBookingContext | null; validation: ValidationResult }> {
if (params.serviceIds.length === 0) {
return { context: null, validation: fail({ code: "NO_SERVICES_SELECTED", message: "Select at least one service." }) };
}
const business = await prisma.business.findUnique({
where: { id: params.businessId },
select: { id: true, timezone: true, bookingRule: true },
});
if (!business) {
return { context: null, validation: fail({ code: "BUSINESS_NOT_FOUND", message: "Business not found." }) };
}
const services = await prisma.service.findMany({
where: { id: { in: params.serviceIds }, businessId: params.businessId, isActive: true },
});
if (services.length !== params.serviceIds.length) {
return {
context: null,
validation: fail({
code: "SERVICES_SPAN_MULTIPLE_BUSINESSES",
message: "One or more selected services are not available at this business.",
}),
};
}
const serviceRequirements: ServiceRequirement[] = services.map((s) => ({
serviceId: s.id,
durationMinutes: s.durationMinutes,
bufferBeforeMinutes: s.bufferBeforeMinutes,
bufferAfterMinutes: s.bufferAfterMinutes,
price: s.price.toString(),
}));
let eligibleStaffIds: string[];
if (params.staffId) {
const staffServiceCount = await prisma.staffService.count({
where: { staffId: params.staffId, serviceId: { in: params.serviceIds } },
});
const canPerformAll = staffServiceCount === params.serviceIds.length;
if (!canPerformAll) {
return {
context: null,
validation: fail({
code: "STAFF_CANNOT_PERFORM_SERVICE",
message: "This staff member doesn't offer one or more of the selected services.",
}),
};
}
eligibleStaffIds = [params.staffId];
} else {
const eligible = await getAvailableStaff(prisma, { businessId: params.businessId, serviceIds: params.serviceIds });
eligibleStaffIds = eligible.map((s) => s.id);
}
const bookingRule: BookingRuleConfig = business.bookingRule ?? DEFAULT_BOOKING_RULE;
return {
context: {
business: { id: business.id, timezone: business.timezone },
bookingRule,
services: serviceRequirements,
totalDurationMinutes: combinedServiceDuration(serviceRequirements),
effectiveBuffer: computeEffectiveBuffer(bookingRule, serviceRequirements),
eligibleStaffIds,
},
validation: ok(),
};
}
export interface AvailableDatesParams {
businessId: string;
serviceIds: string[];
staffId?: string;
fromDate: CalendarDate;
toDate: CalendarDate;
}
export async function getAvailableDates(
prisma: PrismaClient,
params: AvailableDatesParams,
): Promise<{ dates: CalendarDate[]; validation: ValidationResult }> {
const { context, validation } = await resolveBookingContext(prisma, params);
if (!context || !validation.valid) return { dates: [], validation };
if (context.eligibleStaffIds.length === 0) {
return { dates: [], validation: fail({ code: "STAFF_CANNOT_PERFORM_SERVICE", message: "No staff can perform the selected services." }) };
}
const businessHours = await prisma.businessHours.findMany({ where: { businessId: context.business.id } });
const businessOpenByWeekday = businessOpenByWeekdayFrom(businessHours);
const today = todayIn(context.business.timezone);
const now = new Date();
const perStaffWindows = await Promise.all(
context.eligibleStaffIds.map((staffId) =>
getStaffFreeWindowsForRange(prisma, {
staffId,
fromDate: params.fromDate,
toDate: params.toDate,
businessTimezone: context.business.timezone,
businessOpenByWeekday,
effectiveBuffer: context.effectiveBuffer,
}),
),
);
const businessCounts = await countAppointmentsPerDate(prisma, {
businessId: context.business.id,
fromDate: params.fromDate,
toDate: params.toDate,
businessTimezone: context.business.timezone,
});
const staffCounts = await Promise.all(
context.eligibleStaffIds.map((staffId) =>
countAppointmentsPerDate(prisma, {
businessId: context.business.id,
staffId,
fromDate: params.fromDate,
toDate: params.toDate,
businessTimezone: context.business.timezone,
}),
),
);
const availableDates: CalendarDate[] = [];
for (const date of eachCalendarDate(params.fromDate, params.toDate)) {
if (!checkMaxAdvance(date, today, context.bookingRule.maxAdvanceDays).valid) continue;
const businessCount = businessCounts.get(date) ?? 0;
if (!checkBusinessDailyLimit(businessCount, context.bookingRule.maxAppointmentsPerDay).valid) continue;
const hasAnyFittingSlot = context.eligibleStaffIds.some((staffId, i) => {
const staffCount = staffCounts[i]?.get(date) ?? 0;
if (!checkStaffDailyLimit(staffCount, context.bookingRule.maxAppointmentsPerStaffPerDay).valid) return false;
const freeWindows = perStaffWindows[i]?.get(date) ?? [];
const starts = generateSlotStarts(freeWindows, {
slotIntervalMinutes: context.bookingRule.slotIntervalMinutes,
durationMinutes: context.totalDurationMinutes,
});
return starts.some((startMinutes) => {
const instant = wallClockToInstant(date, startMinutes, context.business.timezone);
return checkMinNotice(instant, now, context.bookingRule.minNoticeMinutes).valid;
});
});
if (hasAnyFittingSlot) availableDates.push(date);
}
return { dates: availableDates, validation: ok() };
}
export interface TimeSlotOption {
startMinutes: number;
endMinutes: number;
startAt: string; // ISO instant
endAt: string;
staffOptions: string[];
}
export interface AvailableTimeSlotsParams {
businessId: string;
serviceIds: string[];
staffId?: string;
date: CalendarDate;
}
export async function getAvailableTimeSlots(
prisma: PrismaClient,
params: AvailableTimeSlotsParams,
): Promise<{ slots: TimeSlotOption[]; validation: ValidationResult }> {
const { context, validation } = await resolveBookingContext(prisma, params);
if (!context || !validation.valid) return { slots: [], validation };
if (context.eligibleStaffIds.length === 0) {
return { slots: [], validation: fail({ code: "STAFF_CANNOT_PERFORM_SERVICE", message: "No staff can perform the selected services." }) };
}
const today = todayIn(context.business.timezone);
const now = new Date();
const advanceCheck = checkMaxAdvance(params.date, today, context.bookingRule.maxAdvanceDays);
if (!advanceCheck.valid) return { slots: [], validation: advanceCheck };
const businessHours = await prisma.businessHours.findMany({ where: { businessId: context.business.id } });
const businessOpenByWeekday = businessOpenByWeekdayFrom(businessHours);
const businessCount = (
await countAppointmentsPerDate(prisma, {
businessId: context.business.id,
fromDate: params.date,
toDate: params.date,
businessTimezone: context.business.timezone,
})
).get(params.date) ?? 0;
if (!checkBusinessDailyLimit(businessCount, context.bookingRule.maxAppointmentsPerDay).valid) {
return { slots: [], validation: ok() }; // no slots, but this isn't a validation error for the query itself
}
const byStartMinute = new Map<number, TimeSlotOption>();
for (const staffId of context.eligibleStaffIds) {
const staffCount =
(
await countAppointmentsPerDate(prisma, {
businessId: context.business.id,
staffId,
fromDate: params.date,
toDate: params.date,
businessTimezone: context.business.timezone,
})
).get(params.date) ?? 0;
if (!checkStaffDailyLimit(staffCount, context.bookingRule.maxAppointmentsPerStaffPerDay).valid) continue;
const windowsByDate = await getStaffFreeWindowsForRange(prisma, {
staffId,
fromDate: params.date,
toDate: params.date,
businessTimezone: context.business.timezone,
businessOpenByWeekday,
effectiveBuffer: context.effectiveBuffer,
});
const freeWindows = windowsByDate.get(params.date) ?? [];
const starts = generateSlotStarts(freeWindows, {
slotIntervalMinutes: context.bookingRule.slotIntervalMinutes,
durationMinutes: context.totalDurationMinutes,
});
for (const startMinutes of starts) {
const startAt = wallClockToInstant(params.date, startMinutes, context.business.timezone);
if (!checkMinNotice(startAt, now, context.bookingRule.minNoticeMinutes).valid) continue;
const endMinutes = startMinutes + context.totalDurationMinutes;
const existing = byStartMinute.get(startMinutes);
if (existing) {
existing.staffOptions.push(staffId);
} else {
byStartMinute.set(startMinutes, {
startMinutes,
endMinutes,
startAt: startAt.toISOString(),
endAt: wallClockToInstant(params.date, endMinutes, context.business.timezone).toISOString(),
staffOptions: [staffId],
});
}
}
}
const slots = [...byStartMinute.values()].sort((a, b) => a.startMinutes - b.startMinutes);
return { slots, validation: ok() };
}
export interface ValidateBookingSlotParams {
businessId: string;
serviceIds: string[];
/** Must already be a specific staff member — BookingService resolves "any available barber" to one before calling this. */
staffId: string;
startAt: Date;
/** When re-validating a reschedule, exclude the appointment being moved from every conflict/limit check. */
excludeAppointmentId?: string;
}
export interface ValidatedBookingSlot {
validation: ValidationResult;
context: ResolvedBookingContext | null;
endAt: Date | null;
}
/**
* Diagnoses a single candidate (business, services, staff, start time) in
* full, producing the specific, distinct error the brief calls for
* ("business is open", "barber is working", "not on a break", "not on
* leave", "requested time is available", "services fit", "booking rules
* satisfied") rather than one generic "unavailable". This is a
* synchronous-feeling *diagnosis* for good error messages; the
* authoritative, race-safe check that actually guards against double
* booking still happens inside SchedulingService's transaction at commit
* time — this function can be (rarely) stale by the time a booking
* actually commits, same as any other read-then-write.
*/
export async function validateBookingSlot(
prisma: PrismaClient,
params: ValidateBookingSlotParams,
): Promise<ValidatedBookingSlot> {
const { context, validation: contextValidation } = await resolveBookingContext(prisma, {
businessId: params.businessId,
serviceIds: params.serviceIds,
staffId: params.staffId,
});
if (!context || !contextValidation.valid) return { context: null, validation: contextValidation, endAt: null };
const { timezone } = context.business;
const date = calendarDateOf(params.startAt, timezone);
const startMinutes = minutesFromMidnight(params.startAt, timezone);
const endMinutes = startMinutes + context.totalDurationMinutes;
const endAt = wallClockToInstant(date, endMinutes, timezone);
const now = new Date();
const today = todayIn(timezone);
const ruleChecks = mergeValidation(
checkMinNotice(params.startAt, now, context.bookingRule.minNoticeMinutes),
checkMaxAdvance(date, today, context.bookingRule.maxAdvanceDays),
);
const businessHours = await prisma.businessHours.findMany({ where: { businessId: context.business.id } });
const businessOpen = businessOpenByWeekdayFrom(businessHours).get(weekdayOf(date));
if (!businessOpen || startMinutes < businessOpen.startMinutes || endMinutes > businessOpen.endMinutes) {
return {
context,
endAt,
validation: mergeValidation(ruleChecks, fail({ code: "BUSINESS_CLOSED", message: "The business is closed at this time." })),
};
}
const [workingHours, breakDays, timeOffOnDate, businessCount, staffCount] = await Promise.all([
prisma.workingHours.findUnique({ where: { staffId_dayOfWeek: { staffId: params.staffId, dayOfWeek: weekdayOf(date) } } }),
prisma.breakDay.findMany({ where: { dayOfWeek: weekdayOf(date), break: { staffId: params.staffId } }, include: { break: true } }),
prisma.timeOff.findMany({
where: {
staffId: params.staffId,
status: "APPROVED",
startDate: { lte: calendarDateToUtcMidnight(date) },
endDate: { gte: calendarDateToUtcMidnight(date) },
},
}),
countAppointmentsPerDate(prisma, {
businessId: context.business.id,
fromDate: date,
toDate: date,
businessTimezone: timezone,
excludeAppointmentId: params.excludeAppointmentId,
}),
countAppointmentsPerDate(prisma, {
businessId: context.business.id,
staffId: params.staffId,
fromDate: date,
toDate: date,
businessTimezone: timezone,
excludeAppointmentId: params.excludeAppointmentId,
}),
]);
const staffWorking: TimeWindow | null =
workingHours && !workingHours.isOff && workingHours.startMinutes !== null && workingHours.endMinutes !== null
? { startMinutes: workingHours.startMinutes, endMinutes: workingHours.endMinutes }
: null;
if (!staffWorking) {
return { context, endAt, validation: mergeValidation(ruleChecks, fail({ code: "STAFF_NOT_WORKING", message: "This staff member isn't scheduled to work at this time." })) };
}
// A row with both minutes null blocks the whole day; a row with minutes
// set (including a one-off "Block Time" entry — see TimeOff.startMinutes
// in schema.prisma) only conflicts if it overlaps the requested slot.
if (timeOffOnDate.some((t) => t.startMinutes === null)) {
return { context, endAt, validation: mergeValidation(ruleChecks, fail({ code: "STAFF_ON_LEAVE", message: "This staff member is on approved leave that day." })) };
}
const partialTimeOff: TimeWindow[] = timeOffOnDate
.filter((t) => t.startMinutes !== null && t.endMinutes !== null)
.map((t) => ({ startMinutes: t.startMinutes!, endMinutes: t.endMinutes! }));
const onTimeOff = partialTimeOff.some((w) => startMinutes < w.endMinutes && endMinutes > w.startMinutes);
if (onTimeOff) {
return { context, endAt, validation: mergeValidation(ruleChecks, fail({ code: "STAFF_ON_LEAVE", message: "This staff member is unavailable during part of this time." })) };
}
const breaks: TimeWindow[] = [
...breakDays.map((bd) => ({ startMinutes: bd.break.startMinutes, endMinutes: bd.break.endMinutes })),
...partialTimeOff,
];
const onBreak = breaks.some((b) => startMinutes < b.endMinutes && endMinutes > b.startMinutes);
if (onBreak) {
return { context, endAt, validation: mergeValidation(ruleChecks, fail({ code: "STAFF_ON_BREAK", message: "This staff member is on a break during part of this time." })) };
}
// Theoretical window: hours ∩ working hours, minus breaks, WITHOUT existing appointments — distinguishes
// "the services just don't fit in the remaining open time" from "someone else booked over this".
const theoreticalWindows = computeFreeWindows({ businessOpen, staffWorking, breaks, onLeave: false, busy: [] });
const fitsTheoretically = theoreticalWindows.some((w) => startMinutes >= w.startMinutes && endMinutes <= w.endMinutes);
if (!fitsTheoretically) {
return {
context,
endAt,
validation: mergeValidation(ruleChecks, fail({ code: "INSUFFICIENT_DURATION", message: "The selected services don't fit before this staff member's hours end." })),
};
}
const businessCapCheck = checkBusinessDailyLimit(businessCount.get(date) ?? 0, context.bookingRule.maxAppointmentsPerDay);
const staffCapCheck = checkStaffDailyLimit(staffCount.get(date) ?? 0, context.bookingRule.maxAppointmentsPerStaffPerDay);
const actualWindows = await getStaffFreeWindowsForRange(prisma, {
staffId: params.staffId,
fromDate: date,
toDate: date,
businessTimezone: timezone,
businessOpenByWeekday: { get: (w: Weekday) => (w === weekdayOf(date) ? businessOpen : null) },
effectiveBuffer: context.effectiveBuffer,
excludeAppointmentId: params.excludeAppointmentId,
});
const fitsActually = (actualWindows.get(date) ?? []).some((w) => startMinutes >= w.startMinutes && endMinutes <= w.endMinutes);
const slotCheck = fitsActually ? ok() : fail({ code: "SLOT_UNAVAILABLE", message: "This time was just booked — please choose another." });
return {
context,
endAt,
validation: mergeValidation(ruleChecks, businessCapCheck, staffCapCheck, slotCheck),
};
}
async function countAppointmentsPerDate(
prisma: PrismaClient,
params: {
businessId: string;
staffId?: string;
fromDate: CalendarDate;
toDate: CalendarDate;
businessTimezone: string;
excludeAppointmentId?: string;
},
): Promise<Map<CalendarDate, number>> {
const rangeStart = startOfBusinessDay(params.fromDate, params.businessTimezone);
const rangeEnd = endOfBusinessDay(params.toDate, params.businessTimezone);
const appointments = await prisma.appointment.findMany({
where: {
businessId: params.businessId,
...(params.staffId ? { staffId: params.staffId } : {}),
...(params.excludeAppointmentId ? { id: { not: params.excludeAppointmentId } } : {}),
status: { notIn: ["CANCELLED", "NO_SHOW"] },
startAt: { gte: rangeStart, lt: rangeEnd },
},
select: { startAt: true },
});
const counts = new Map<CalendarDate, number>();
for (const a of appointments) {
const date = calendarDateOf(a.startAt, params.businessTimezone);
counts.set(date, (counts.get(date) ?? 0) + 1);
}
return counts;
}
@@ -0,0 +1,235 @@
import type { PrismaClient } from "@/generated/prisma/client";
import { NotificationType } from "@/generated/prisma/enums";
import { notifySafely } from "@/services/notifications/notification.service";
import { getOwnerUserIds } from "@/services/notifications/recipients";
import type { Appointment } from "./booking.service";
import { scheduleAppointmentReminders } from "./reminder-scheduling";
/**
* Builds and queues the Notification rows a booking-domain event
* produces — called by booking.service.ts strictly AFTER the appointment
* transaction has already committed, using notifySafely so a notification
* failure can never roll back or fail an otherwise-successful booking.
* Owns only "who gets told what, in what words" — the actual email/push
* send happens later, out of process (see reminder-worker.ts).
*/
function formatDateLabel(date: Date, timezone: string): string {
return date.toLocaleDateString("en-GB", { weekday: "short", day: "numeric", month: "short", timeZone: timezone });
}
function formatTimeLabel(date: Date, timezone: string): string {
return date.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit", timeZone: timezone });
}
async function getBusinessTimezone(prisma: PrismaClient, businessId: string): Promise<string> {
const business = await prisma.business.findUnique({ where: { id: businessId }, select: { timezone: true } });
return business?.timezone ?? "Europe/London";
}
function serviceNames(appointment: Appointment): string {
return appointment.services.map((s) => s.service.name).join(", ");
}
export async function notifyBookingCreated(
prisma: PrismaClient,
appointment: Appointment,
options: { guestAccessToken?: string } = {},
): Promise<void> {
const timezone = await getBusinessTimezone(prisma, appointment.businessId);
const dateLabel = formatDateLabel(appointment.startAt, timezone);
const timeLabel = formatTimeLabel(appointment.startAt, timezone);
const services = serviceNames(appointment);
const isPending = appointment.status === "PENDING";
const customerDeepLink = `/appointments/${appointment.id}${options.guestAccessToken ? `?token=${options.guestAccessToken}` : ""}`;
await notifySafely(prisma, {
businessId: appointment.businessId,
type: isPending ? NotificationType.BOOKING_PENDING_APPROVAL : NotificationType.BOOKING_CONFIRMED,
recipientUserId: appointment.customer.userId ?? undefined,
recipientCustomerId: appointment.customer.id,
appointmentId: appointment.id,
subject: isPending ? "Booking request received" : "Booking confirmed",
bodyText: isPending
? `Your request for ${services} with ${appointment.staff.user.name} on ${dateLabel} at ${timeLabel} is awaiting approval.`
: `Your appointment for ${services} with ${appointment.staff.user.name} on ${dateLabel} at ${timeLabel} is confirmed.`,
deepLinkPath: customerDeepLink,
idempotencyKey: `notify:booking-created:${appointment.id}`,
});
await notifySafely(prisma, {
businessId: appointment.businessId,
type: NotificationType.STAFF_NEW_APPOINTMENT,
recipientUserId: appointment.staff.userId,
appointmentId: appointment.id,
subject: "New appointment",
bodyText: `New appointment: ${services} with ${appointment.customer.firstName} ${appointment.customer.lastName} on ${dateLabel} at ${timeLabel}.`,
deepLinkPath: `/staff/appointments/${appointment.id}`,
idempotencyKey: `notify:staff-new-appointment:${appointment.id}`,
});
const ownerUserIds = await getOwnerUserIds(prisma, appointment.businessId);
for (const ownerUserId of ownerUserIds) {
await notifySafely(prisma, {
businessId: appointment.businessId,
type: NotificationType.OWNER_NEW_BOOKING,
recipientUserId: ownerUserId,
appointmentId: appointment.id,
subject: "New booking",
bodyText: `${appointment.customer.firstName} ${appointment.customer.lastName} booked ${services} with ${appointment.staff.user.name} on ${dateLabel} at ${timeLabel}.`,
deepLinkPath: `/owner/appointments/${appointment.id}`,
idempotencyKey: `notify:owner-new-booking:${appointment.id}:${ownerUserId}`,
});
}
}
export async function notifyBookingRescheduled(prisma: PrismaClient, appointment: Appointment): Promise<void> {
const timezone = await getBusinessTimezone(prisma, appointment.businessId);
const dateLabel = formatDateLabel(appointment.startAt, timezone);
const timeLabel = formatTimeLabel(appointment.startAt, timezone);
const services = serviceNames(appointment);
// Included in the key so a second reschedule creates a fresh notification
// rather than being deduped against the first one's key.
const versionSuffix = appointment.startAt.getTime();
await notifySafely(prisma, {
businessId: appointment.businessId,
type: NotificationType.BOOKING_RESCHEDULED,
recipientUserId: appointment.customer.userId ?? undefined,
recipientCustomerId: appointment.customer.id,
appointmentId: appointment.id,
subject: "Booking rescheduled",
bodyText: `Your appointment for ${services} with ${appointment.staff.user.name} has been moved to ${dateLabel} at ${timeLabel}.`,
deepLinkPath: `/appointments/${appointment.id}`,
idempotencyKey: `notify:booking-rescheduled:${appointment.id}:${versionSuffix}`,
});
await notifySafely(prisma, {
businessId: appointment.businessId,
type: NotificationType.STAFF_APPOINTMENT_RESCHEDULED,
recipientUserId: appointment.staff.userId,
appointmentId: appointment.id,
subject: "Appointment rescheduled",
bodyText: `${appointment.customer.firstName} ${appointment.customer.lastName}'s appointment (${services}) has moved to ${dateLabel} at ${timeLabel}.`,
deepLinkPath: `/staff/appointments/${appointment.id}`,
idempotencyKey: `notify:staff-appointment-rescheduled:${appointment.id}:${versionSuffix}`,
});
}
export async function notifyBookingCancelled(
prisma: PrismaClient,
appointment: Appointment,
options: { wasLateCancellation?: boolean; reason?: string } = {},
): Promise<void> {
const timezone = await getBusinessTimezone(prisma, appointment.businessId);
const dateLabel = formatDateLabel(appointment.startAt, timezone);
const timeLabel = formatTimeLabel(appointment.startAt, timezone);
const services = serviceNames(appointment);
const lateNote = options.wasLateCancellation ? " This was a late cancellation." : "";
await notifySafely(prisma, {
businessId: appointment.businessId,
type: NotificationType.BOOKING_CANCELLED,
recipientUserId: appointment.customer.userId ?? undefined,
recipientCustomerId: appointment.customer.id,
appointmentId: appointment.id,
subject: "Booking cancelled",
bodyText: `Your appointment for ${services} with ${appointment.staff.user.name} on ${dateLabel} at ${timeLabel} has been cancelled.${lateNote}`,
deepLinkPath: `/appointments/${appointment.id}`,
idempotencyKey: `notify:booking-cancelled:${appointment.id}`,
});
await notifySafely(prisma, {
businessId: appointment.businessId,
type: NotificationType.STAFF_APPOINTMENT_CANCELLED,
recipientUserId: appointment.staff.userId,
appointmentId: appointment.id,
subject: "Appointment cancelled",
bodyText: `${appointment.customer.firstName} ${appointment.customer.lastName}'s appointment (${services}) on ${dateLabel} at ${timeLabel} was cancelled.`,
deepLinkPath: `/staff/appointments/${appointment.id}`,
idempotencyKey: `notify:staff-appointment-cancelled:${appointment.id}`,
});
const ownerUserIds = await getOwnerUserIds(prisma, appointment.businessId);
for (const ownerUserId of ownerUserIds) {
await notifySafely(prisma, {
businessId: appointment.businessId,
type: NotificationType.OWNER_BOOKING_CANCELLED,
recipientUserId: ownerUserId,
appointmentId: appointment.id,
subject: "Booking cancelled",
bodyText: `${appointment.customer.firstName} ${appointment.customer.lastName}'s appointment with ${appointment.staff.user.name} on ${dateLabel} at ${timeLabel} was cancelled.${lateNote}`,
deepLinkPath: `/owner/appointments/${appointment.id}`,
idempotencyKey: `notify:owner-booking-cancelled:${appointment.id}:${ownerUserId}`,
});
}
}
/** A PENDING booking (requireApproval) is approved — see AppointmentService.confirm, only ever reachable from PENDING. */
export async function notifyBookingApproved(prisma: PrismaClient, appointment: Appointment): Promise<void> {
const timezone = await getBusinessTimezone(prisma, appointment.businessId);
await notifySafely(prisma, {
businessId: appointment.businessId,
type: NotificationType.BOOKING_APPROVED,
recipientUserId: appointment.customer.userId ?? undefined,
recipientCustomerId: appointment.customer.id,
appointmentId: appointment.id,
subject: "Booking approved",
bodyText: `Your appointment for ${serviceNames(appointment)} with ${appointment.staff.user.name} on ${formatDateLabel(appointment.startAt, timezone)} at ${formatTimeLabel(appointment.startAt, timezone)} has been approved.`,
deepLinkPath: `/appointments/${appointment.id}`,
idempotencyKey: `notify:booking-approved:${appointment.id}`,
});
// Approval is the first point a PENDING (requireApproval) booking becomes
// CONFIRMED — reminders were never scheduled at creation time for it (see
// scheduleAppointmentReminders' own CONFIRMED-only guard), so this is
// where that has to happen instead.
await scheduleAppointmentReminders(prisma, appointment);
}
/** The owner reassigns an appointment to a different staff member — distinct from a plain time reschedule (see notifyBookingRescheduled, which still also fires for a reassignment since the underlying rescheduleBooking() call can't distinguish "why", see ownerRescheduleAppointmentAction). This tells the customer specifically who they're now booked with. */
export async function notifyBookingBarberChanged(prisma: PrismaClient, appointment: Appointment): Promise<void> {
await notifySafely(prisma, {
businessId: appointment.businessId,
type: NotificationType.BOOKING_BARBER_CHANGED,
recipientUserId: appointment.customer.userId ?? undefined,
recipientCustomerId: appointment.customer.id,
appointmentId: appointment.id,
subject: "Your barber has changed",
bodyText: `Your appointment for ${serviceNames(appointment)} is now with ${appointment.staff.user.name}.`,
deepLinkPath: `/appointments/${appointment.id}`,
idempotencyKey: `notify:booking-barber-changed:${appointment.id}:${appointment.staffId}`,
});
}
/** The appointment reaches COMPLETED — a distinct, non-loyalty "thanks for visiting" notice (see LoyaltyService for the separate stamp/reward notifications, which fire independently). */
export async function notifyBookingCompleted(prisma: PrismaClient, appointment: Appointment): Promise<void> {
await notifySafely(prisma, {
businessId: appointment.businessId,
type: NotificationType.BOOKING_COMPLETED,
recipientUserId: appointment.customer.userId ?? undefined,
recipientCustomerId: appointment.customer.id,
appointmentId: appointment.id,
subject: "Thanks for visiting",
bodyText: `Thanks for visiting ${appointment.staff.user.name ? `and seeing ${appointment.staff.user.name}` : ""} — hope you loved your ${serviceNames(appointment)}!`,
deepLinkPath: `/appointments/${appointment.id}`,
idempotencyKey: `notify:booking-completed:${appointment.id}`,
});
}
/** Customer marked NO_SHOW — owner-facing only (the barber already knows, they're the one who marked it). */
export async function notifyAppointmentNoShow(prisma: PrismaClient, appointment: Appointment): Promise<void> {
const timezone = await getBusinessTimezone(prisma, appointment.businessId);
const ownerUserIds = await getOwnerUserIds(prisma, appointment.businessId);
for (const ownerUserId of ownerUserIds) {
await notifySafely(prisma, {
businessId: appointment.businessId,
type: NotificationType.OWNER_NO_SHOW,
recipientUserId: ownerUserId,
appointmentId: appointment.id,
subject: "No-show recorded",
bodyText: `${appointment.customer.firstName} ${appointment.customer.lastName} didn't show up for their appointment with ${appointment.staff.user.name} on ${formatDateLabel(appointment.startAt, timezone)} at ${formatTimeLabel(appointment.startAt, timezone)}.`,
deepLinkPath: `/owner/appointments/${appointment.id}`,
idempotencyKey: `notify:owner-no-show:${appointment.id}:${ownerUserId}`,
});
}
}
@@ -0,0 +1,72 @@
import type { PrismaClient } from "@/generated/prisma/client";
import { fail, ok } from "@/lib/validation-result";
import type { ValidationResult } from "@/lib/validation-result";
import type { BookingRuleConfig } from "./business-rules.service";
/**
* Read/write for the BookingRule row itself — deliberately separate from
* business-rules.service.ts, which is pure evaluation logic with no Prisma
* access by design (see that file's own header). This file owns the one
* thing that one doesn't: loading and validating the actual configuration
* an owner edits, which every booking-time check then reads via
* AvailabilityService exactly as before — nothing about how rules are
* *evaluated* changes here, only how they're *set*.
*/
export type BookingRuleErrorCode = "INVALID_INPUT";
export type BookingRuleInput = BookingRuleConfig;
export function validateBookingRuleInput(input: BookingRuleInput): ValidationResult<BookingRuleErrorCode> {
if (!Number.isInteger(input.minNoticeMinutes) || input.minNoticeMinutes < 0) {
return fail({ code: "INVALID_INPUT", message: "Minimum notice can't be negative." });
}
if (!Number.isInteger(input.maxAdvanceDays) || input.maxAdvanceDays < 1) {
return fail({ code: "INVALID_INPUT", message: "Maximum advance booking must be at least 1 day." });
}
if (!Number.isInteger(input.cancellationWindowHours) || input.cancellationWindowHours < 0) {
return fail({ code: "INVALID_INPUT", message: "Cancellation window can't be negative." });
}
if (!Number.isInteger(input.rescheduleWindowHours) || input.rescheduleWindowHours < 0) {
return fail({ code: "INVALID_INPUT", message: "Reschedule window can't be negative." });
}
if (!Number.isInteger(input.slotIntervalMinutes) || input.slotIntervalMinutes < 5 || input.slotIntervalMinutes > 120) {
return fail({ code: "INVALID_INPUT", message: "Slot interval must be between 5 and 120 minutes." });
}
if (!Number.isInteger(input.bufferBeforeMinutes) || input.bufferBeforeMinutes < 0) {
return fail({ code: "INVALID_INPUT", message: "Buffer before can't be negative." });
}
if (!Number.isInteger(input.bufferAfterMinutes) || input.bufferAfterMinutes < 0) {
return fail({ code: "INVALID_INPUT", message: "Buffer after can't be negative." });
}
if (input.maxAppointmentsPerDay !== null && (!Number.isInteger(input.maxAppointmentsPerDay) || input.maxAppointmentsPerDay < 1)) {
return fail({ code: "INVALID_INPUT", message: "Max appointments per day must be at least 1, or left unlimited." });
}
if (
input.maxAppointmentsPerStaffPerDay !== null &&
(!Number.isInteger(input.maxAppointmentsPerStaffPerDay) || input.maxAppointmentsPerStaffPerDay < 1)
) {
return fail({ code: "INVALID_INPUT", message: "Max appointments per staff member per day must be at least 1, or left unlimited." });
}
return ok();
}
export async function getBookingRule(prisma: PrismaClient, businessId: string) {
return prisma.bookingRule.findUnique({ where: { businessId } });
}
export async function updateBookingRule(
prisma: PrismaClient,
params: { businessId: string; input: BookingRuleInput },
): Promise<{ success: boolean; validation: ValidationResult<BookingRuleErrorCode> }> {
const validation = validateBookingRuleInput(params.input);
if (!validation.valid) return { success: false, validation };
await prisma.bookingRule.upsert({
where: { businessId: params.businessId },
create: { businessId: params.businessId, ...params.input },
update: { ...params.input },
});
return { success: true, validation: ok() };
}
+337
View File
@@ -0,0 +1,337 @@
import type { PrismaClient } from "@/generated/prisma/client";
import { AppointmentStatus } from "@/generated/prisma/enums";
import { calendarDateOf, minutesFromMidnight } from "@/lib/time/business-time";
import { getAppointmentById } from "./appointment.service";
import {
getAvailableTimeSlots,
resolveBookingContext,
validateBookingSlot,
} from "./availability.service";
import { notifyBookingCancelled, notifyBookingCreated, notifyBookingRescheduled } from "./booking-notifications";
import { isLateCancellation, isLateReschedule } from "./business-rules.service";
import { issueGuestAccessToken } from "./guest-access.service";
import { cancelScheduledReminders, scheduleAppointmentReminders } from "./reminder-scheduling";
import { DoubleBookingError, createAppointment, rescheduleAppointment } from "./scheduling.service";
import { countStaffAppointmentsOnDate } from "./staff-availability.service";
import { fail, ok } from "./types";
import type { ValidationResult } from "./types";
/**
* The full booking workflow: create, reschedule, cancel. Orchestrates
* AvailabilityService (validate the request) and SchedulingService
* (commit it transactionally) — this file makes no availability or
* conflict decisions of its own, it coordinates the services that do.
*/
export interface CreateBookingInput {
businessId: string;
customerId: string;
serviceIds: string[];
/** Omit for "any available barber". */
staffId?: string;
startAt: Date;
notes?: string;
/** Who initiated this booking — see Appointment.createdByUserId in schema.prisma. Omit for a guest's own self-service booking. */
createdByUserId?: string;
}
export type Appointment = NonNullable<Awaited<ReturnType<typeof getAppointmentById>>>;
export type BookingResult =
| {
success: true;
appointment: Appointment;
/** Raw guest access token — set only when the appointment's customer has no linked User. See guest-access.service.ts; never logged, never persisted beyond its hash. */
guestAccessToken?: string;
}
| { success: false; validation: ValidationResult };
/** Among staff free at this slot, prefer whoever has the fewest appointments that day — spreads "any barber" bookings out instead of always picking the same one. */
async function pickLeastBusyStaff(
prisma: PrismaClient,
candidateStaffIds: string[],
date: string,
businessTimezone: string,
): Promise<string> {
if (candidateStaffIds.length === 1) return candidateStaffIds[0]!;
const counts = await Promise.all(
candidateStaffIds.map((staffId) =>
countStaffAppointmentsOnDate(prisma, { staffId, date, businessTimezone }),
),
);
let best = candidateStaffIds[0]!;
let bestCount = counts[0]!;
for (let i = 1; i < candidateStaffIds.length; i++) {
const count = counts[i]!;
if (count < bestCount) {
best = candidateStaffIds[i]!;
bestCount = count;
}
}
return best;
}
export async function createBooking(
prisma: PrismaClient,
input: CreateBookingInput,
): Promise<BookingResult> {
const { context: baseContext, validation: baseValidation } = await resolveBookingContext(prisma, {
businessId: input.businessId,
serviceIds: input.serviceIds,
staffId: input.staffId,
});
if (!baseContext || !baseValidation.valid) return { success: false, validation: baseValidation };
let staffId = input.staffId;
if (!staffId) {
if (baseContext.eligibleStaffIds.length === 0) {
return {
success: false,
validation: fail({
code: "STAFF_CANNOT_PERFORM_SERVICE",
message: "No staff can perform the selected services.",
}),
};
}
const date = calendarDateOf(input.startAt, baseContext.business.timezone);
const startMinutes = minutesFromMidnight(input.startAt, baseContext.business.timezone);
const { slots } = await getAvailableTimeSlots(prisma, {
businessId: input.businessId,
serviceIds: input.serviceIds,
date,
});
const matching = slots.find((s) => s.startMinutes === startMinutes);
if (!matching || matching.staffOptions.length === 0) {
return {
success: false,
validation: fail({
code: "SLOT_UNAVAILABLE",
message: "This time is no longer available.",
}),
};
}
staffId = await pickLeastBusyStaff(
prisma,
matching.staffOptions,
date,
baseContext.business.timezone,
);
}
const { validation, context, endAt } = await validateBookingSlot(prisma, {
businessId: input.businessId,
serviceIds: input.serviceIds,
staffId,
startAt: input.startAt,
});
if (!validation.valid || !context || !endAt) return { success: false, validation };
const status = context.bookingRule.requireApproval
? AppointmentStatus.PENDING
: AppointmentStatus.CONFIRMED;
try {
const created = await createAppointment(prisma, {
businessId: input.businessId,
customerId: input.customerId,
staffId,
startAt: input.startAt,
endAt,
status,
notes: input.notes,
createdByUserId: input.createdByUserId,
effectiveBuffer: context.effectiveBuffer,
services: context.services.map((s) => ({
serviceId: s.serviceId,
priceAtBooking: s.price,
durationAtBookingMinutes: s.durationMinutes,
})),
});
// Re-fetch through the shared include shape so callers get the same
// appointment shape regardless of which workflow produced it.
const appointment = await getAppointmentById(prisma, created.id);
const guestAccessToken = created.customer.userId
? undefined
: await issueGuestAccessToken(prisma, { appointmentId: created.id, appointmentEndAt: endAt });
await notifyBookingCreated(prisma, appointment!, { guestAccessToken });
await scheduleAppointmentReminders(prisma, appointment!);
return { success: true, appointment: appointment!, guestAccessToken };
} catch (error) {
if (error instanceof DoubleBookingError) {
return {
success: false,
validation: fail({ code: "SLOT_UNAVAILABLE", message: error.message }),
};
}
throw error;
}
}
export interface RescheduleBookingInput {
appointmentId: string;
/** Omit to keep the current staff member, or supply a different one. */
staffId?: string;
startAt: Date;
}
const NON_RESCHEDULABLE_STATUSES: AppointmentStatus[] = [
AppointmentStatus.COMPLETED,
AppointmentStatus.CANCELLED,
AppointmentStatus.NO_SHOW,
AppointmentStatus.IN_PROGRESS,
];
/** Rescheduling performs the same validation as a new booking — it's a new slot, just for an appointment that already exists. */
export async function rescheduleBooking(
prisma: PrismaClient,
input: RescheduleBookingInput,
): Promise<BookingResult> {
const existing = await getAppointmentById(prisma, input.appointmentId);
if (!existing) {
return {
success: false,
validation: fail({ code: "APPOINTMENT_NOT_FOUND", message: "Appointment not found." }),
};
}
if (NON_RESCHEDULABLE_STATUSES.includes(existing.status)) {
return {
success: false,
validation: fail({
code: "INVALID_STATUS_TRANSITION",
message: `A ${existing.status.toLowerCase().replace("_", " ")} appointment can't be rescheduled.`,
}),
};
}
const serviceIds = existing.services.map((s) => s.serviceId);
const staffId = input.staffId ?? existing.staffId;
const { validation, context, endAt } = await validateBookingSlot(prisma, {
businessId: existing.businessId,
serviceIds,
staffId,
startAt: input.startAt,
excludeAppointmentId: input.appointmentId,
});
if (!validation.valid || !context || !endAt) return { success: false, validation };
try {
await rescheduleAppointment(prisma, {
appointmentId: input.appointmentId,
staffId,
startAt: input.startAt,
endAt,
effectiveBuffer: context.effectiveBuffer,
});
const appointment = await getAppointmentById(prisma, input.appointmentId);
await notifyBookingRescheduled(prisma, appointment!);
await cancelScheduledReminders(prisma, input.appointmentId);
await scheduleAppointmentReminders(prisma, appointment!);
return { success: true, appointment: appointment! };
} catch (error) {
if (error instanceof DoubleBookingError) {
return {
success: false,
validation: fail({ code: "SLOT_UNAVAILABLE", message: error.message }),
};
}
throw error;
}
}
export interface CancelBookingInput {
appointmentId: 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;
}
export interface CancelBookingResult {
success: boolean;
appointment?: Appointment;
/** Cancellation is never blocked by the cancellation-window rule — this just tells the caller whether it fell outside the free window, e.g. to show a "note: this was a late cancellation" message. */
wasLateCancellation?: boolean;
validation: ValidationResult;
}
const NON_CANCELLABLE_STATUSES: AppointmentStatus[] = [
AppointmentStatus.COMPLETED,
AppointmentStatus.CANCELLED,
AppointmentStatus.NO_SHOW,
];
/** Never deletes — sets status to CANCELLED and records when, why, and by whom. */
export async function cancelBooking(
prisma: PrismaClient,
input: CancelBookingInput,
): Promise<CancelBookingResult> {
const existing = await getAppointmentById(prisma, input.appointmentId);
if (!existing) {
return {
success: false,
validation: fail({ code: "APPOINTMENT_NOT_FOUND", message: "Appointment not found." }),
};
}
if (NON_CANCELLABLE_STATUSES.includes(existing.status)) {
return {
success: false,
validation: fail({
code: "INVALID_STATUS_TRANSITION",
message: `A ${existing.status.toLowerCase().replace("_", " ")} appointment can't be cancelled.`,
}),
};
}
const business = await prisma.business.findUnique({
where: { id: existing.businessId },
select: { bookingRule: { select: { cancellationWindowHours: true } } },
});
const cancellationWindowHours = business?.bookingRule?.cancellationWindowHours ?? 24;
const wasLateCancellation = isLateCancellation(
existing.startAt,
new Date(),
cancellationWindowHours,
);
const appointment = await prisma.appointment.update({
where: { id: input.appointmentId },
data: {
status: AppointmentStatus.CANCELLED,
cancelledAt: new Date(),
cancelledByUserId: input.cancelledByUserId,
cancellationReason: input.reason,
},
include: {
services: { include: { service: true } },
staff: { include: { user: true } },
customer: true,
},
});
await notifyBookingCancelled(prisma, appointment, {
wasLateCancellation,
reason: input.reason,
});
await cancelScheduledReminders(prisma, input.appointmentId);
return { success: true, appointment, wasLateCancellation, validation: ok() };
}
/** Exposed for symmetry with cancellation's "late" flag — a UI can warn before submitting a reschedule request. */
export function checkRescheduleWindow(
appointmentStart: Date,
now: Date,
rescheduleWindowHours: number,
): boolean {
return isLateReschedule(appointmentStart, now, rescheduleWindowHours);
}
@@ -0,0 +1,104 @@
import { diffMinutes } from "@/lib/time/business-time";
import type { CalendarDate } from "@/lib/time/business-time";
import { fail, ok } from "./types";
import type { ServiceRequirement, ValidationResult } from "./types";
/**
* Evaluates a business's BookingRule configuration against a candidate
* booking. Pure functions — every input is a plain value the caller
* already fetched, nothing here touches Prisma. This is deliberate:
* booking-rule evaluation is exactly the kind of logic that needs to be
* cheap to unit test with dozens of edge-case combinations.
*/
export interface BookingRuleConfig {
minNoticeMinutes: number;
maxAdvanceDays: number;
cancellationWindowHours: number;
rescheduleWindowHours: number;
slotIntervalMinutes: number;
bufferBeforeMinutes: number;
bufferAfterMinutes: number;
maxAppointmentsPerDay: number | null;
maxAppointmentsPerStaffPerDay: number | null;
customerCanChooseStaff: boolean;
allowGuestBooking: boolean;
requireApproval: boolean;
}
export function checkMinNotice(candidateStart: Date, now: Date, minNoticeMinutes: number): ValidationResult {
const noticeMinutes = diffMinutes(candidateStart, now);
if (noticeMinutes < minNoticeMinutes) {
return fail({
code: "BELOW_MIN_NOTICE",
message: `This time is less than ${minNoticeMinutes} minutes from now; the minimum booking notice is ${minNoticeMinutes} minutes.`,
});
}
return ok();
}
export function checkMaxAdvance(candidateDate: CalendarDate, today: CalendarDate, maxAdvanceDays: number): ValidationResult {
const daysAhead = Math.floor(
(Date.parse(`${candidateDate}T00:00:00Z`) - Date.parse(`${today}T00:00:00Z`)) / 86_400_000,
);
if (daysAhead > maxAdvanceDays) {
return fail({
code: "BEYOND_MAX_ADVANCE",
message: `Bookings can only be made up to ${maxAdvanceDays} days in advance.`,
});
}
return ok();
}
export function checkBusinessDailyLimit(existingCount: number, maxAppointmentsPerDay: number | null): ValidationResult {
if (maxAppointmentsPerDay !== null && existingCount >= maxAppointmentsPerDay) {
return fail({
code: "BUSINESS_DAILY_LIMIT_REACHED",
message: "This business has reached its maximum number of appointments for this day.",
});
}
return ok();
}
export function checkStaffDailyLimit(existingCount: number, maxAppointmentsPerStaffPerDay: number | null): ValidationResult {
if (maxAppointmentsPerStaffPerDay !== null && existingCount >= maxAppointmentsPerStaffPerDay) {
return fail({
code: "STAFF_DAILY_LIMIT_REACHED",
message: "This staff member has reached their maximum number of appointments for this day.",
});
}
return ok();
}
/**
* Effective buffer around an appointment = the business-wide baseline
* (BookingRule.bufferBeforeMinutes/bufferAfterMinutes — always applied)
* PLUS the largest per-service buffer among the requested services (a
* service like a colour treatment that needs extra processing/cleanup
* time on top of the shop's normal gap, not instead of it).
*/
export function computeEffectiveBuffer(
bookingRule: Pick<BookingRuleConfig, "bufferBeforeMinutes" | "bufferAfterMinutes">,
services: Pick<ServiceRequirement, "bufferBeforeMinutes" | "bufferAfterMinutes">[],
): { beforeMinutes: number; afterMinutes: number } {
const maxServiceBefore = services.reduce((max, s) => Math.max(max, s.bufferBeforeMinutes), 0);
const maxServiceAfter = services.reduce((max, s) => Math.max(max, s.bufferAfterMinutes), 0);
return {
beforeMinutes: bookingRule.bufferBeforeMinutes + maxServiceBefore,
afterMinutes: bookingRule.bufferAfterMinutes + maxServiceAfter,
};
}
/** True if cancelling right now falls outside the free-cancellation window (i.e. it's a "late" cancellation). Informational only — cancellation is never blocked by this, see booking.service.ts. */
export function isLateCancellation(appointmentStart: Date, now: Date, cancellationWindowHours: number): boolean {
return diffMinutes(appointmentStart, now) < cancellationWindowHours * 60;
}
/** Same idea as isLateCancellation, for reschedules. */
export function isLateReschedule(appointmentStart: Date, now: Date, rescheduleWindowHours: number): boolean {
return diffMinutes(appointmentStart, now) < rescheduleWindowHours * 60;
}
export function combinedServiceDuration(services: Pick<ServiceRequirement, "durationMinutes">[]): number {
return services.reduce((sum, s) => sum + s.durationMinutes, 0);
}
@@ -0,0 +1,89 @@
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
import type { PrismaClient } from "@/generated/prisma/client";
/**
* Single-purpose access tokens for guest (no linked User) appointments —
* closes the Phase 4 gap where a guest's only credential was the
* appointment's own id (see docs/phase-4-summary.md, "Security", and
* GuestAppointmentAccessToken in schema.prisma). A registered customer's
* access is still session-based and untouched by this file — see
* src/features/booking/authorization.ts.
*
* Only the SHA-256 hash of the token is ever persisted. The raw token
* exists solely in this module's return values, for a caller to embed in
* a link (the booking confirmation page today; a future notification
* email) and never log or store elsewhere.
*/
const TOKEN_BYTES = 32;
const VALID_FOR_DAYS_AFTER_APPOINTMENT = 90;
function hashToken(rawToken: string): string {
return createHash("sha256").update(rawToken).digest("hex");
}
/**
* Issues this appointment's guest access token, generating one if none
* exists or overwriting the previous hash if one does — deliberately a
* single row per appointment (see the model's `appointmentId @unique`),
* so re-issuing invalidates any earlier copy of the raw token rather than
* leaving multiple valid links outstanding. Reschedule/cancel do not
* currently re-issue — the same link a guest already has keeps working
* across those, which is the more useful default until Phase 7's
* notification emails (not yet built) give a concrete reason to rotate on
* a specific event.
*/
export async function issueGuestAccessToken(
prisma: PrismaClient,
params: { appointmentId: string; appointmentEndAt: Date },
): Promise<string> {
const rawToken = randomBytes(TOKEN_BYTES).toString("base64url");
const expiresAt = new Date(
params.appointmentEndAt.getTime() + VALID_FOR_DAYS_AFTER_APPOINTMENT * 24 * 60 * 60 * 1000,
);
await prisma.guestAppointmentAccessToken.upsert({
where: { appointmentId: params.appointmentId },
create: { appointmentId: params.appointmentId, tokenHash: hashToken(rawToken), expiresAt },
update: { tokenHash: hashToken(rawToken), expiresAt, revokedAt: null, lastUsedAt: null },
});
return rawToken;
}
/**
* True only if `rawToken` is the CURRENT token for exactly this
* appointment, not expired, and not revoked. Compares hashes with
* `timingSafeEqual` (matching the convention in
* src/lib/booking-draft/sign.ts) since, unlike a lookup keyed by the hash
* itself, this comparison happens in application code after fetching the
* row by appointmentId.
*/
export async function validateGuestAccessToken(
prisma: PrismaClient,
params: { appointmentId: string; rawToken: string },
): Promise<boolean> {
const record = await prisma.guestAppointmentAccessToken.findUnique({
where: { appointmentId: params.appointmentId },
});
if (!record || record.revokedAt || record.expiresAt < new Date()) return false;
const provided = Buffer.from(hashToken(params.rawToken), "hex");
const stored = Buffer.from(record.tokenHash, "hex");
if (provided.length !== stored.length || !timingSafeEqual(provided, stored)) return false;
// Best-effort — a stale lastUsedAt from a lost race isn't a security concern.
await prisma.guestAppointmentAccessToken
.update({ where: { appointmentId: params.appointmentId }, data: { lastUsedAt: new Date() } })
.catch(() => {});
return true;
}
/** Immediately invalidates a guest appointment's current access link — e.g. if it was sent to the wrong address. */
export async function revokeGuestAccessToken(prisma: PrismaClient, appointmentId: string): Promise<void> {
await prisma.guestAppointmentAccessToken.updateMany({
where: { appointmentId, revokedAt: null },
data: { revokedAt: new Date() },
});
}
@@ -0,0 +1,98 @@
import type { PrismaClient } from "@/generated/prisma/client";
import { NotificationDeliveryStatus, NotificationType } from "@/generated/prisma/enums";
import { notifySafely } from "@/services/notifications/notification.service";
import type { Appointment } from "./booking.service";
/**
* Reminder timing — hardcoded, not yet a BookingRule column: there's no
* owner-facing UI to change it in Phase 7, so storing an unreachable
* setting felt like the wrong kind of "configurable" (see
* docs/phase-7-summary.md, "Known limitations"). A natural home for this
* would be two new BookingRule fields feeding the existing Booking Rules
* editor — not built this phase.
*/
const REMINDER_HOURS_BEFORE = [24, 2] as const;
function formatDateLabel(date: Date, timezone: string): string {
return date.toLocaleDateString("en-GB", { weekday: "short", day: "numeric", month: "short", timeZone: timezone });
}
function formatTimeLabel(date: Date, timezone: string): string {
return date.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit", timeZone: timezone });
}
async function getBusinessTimezone(prisma: PrismaClient, businessId: string): Promise<string> {
const business = await prisma.business.findUnique({ where: { id: businessId }, select: { timezone: true } });
return business?.timezone ?? "Europe/London";
}
/**
* Cancels every not-yet-sent reminder delivery tied to this appointment —
* called before rescheduling to a new time and before cancelling, so a
* customer/barber never gets a reminder for a time that's no longer
* correct. Only touches PENDING/SCHEDULED rows; a reminder that already
* sent stays as a true historical record.
*/
export async function cancelScheduledReminders(prisma: PrismaClient, appointmentId: string): Promise<void> {
await prisma.notificationDelivery.updateMany({
where: {
status: { in: [NotificationDeliveryStatus.PENDING, NotificationDeliveryStatus.SCHEDULED] },
notification: {
appointmentId,
type: { in: [NotificationType.BOOKING_REMINDER, NotificationType.STAFF_APPOINTMENT_REMINDER] },
},
},
data: { status: NotificationDeliveryStatus.CANCELLED },
});
}
/**
* Schedules the customer's and barber's reminders for a CONFIRMED
* appointment at each offset in REMINDER_HOURS_BEFORE that's still in the
* future — an offset that's already passed (e.g. booked 1 hour out) is
* silently skipped, not scheduled in the past. Each reminder's
* idempotencyKey includes the exact startAt, so a reschedule (which calls
* cancelScheduledReminders then this again) always produces fresh rows
* rather than colliding with a cancelled one — see
* booking-notifications.ts for the same "version-suffixed key" pattern.
*/
export async function scheduleAppointmentReminders(prisma: PrismaClient, appointment: Appointment): Promise<void> {
if (appointment.status !== "CONFIRMED") return;
const timezone = await getBusinessTimezone(prisma, appointment.businessId);
const dateLabel = formatDateLabel(appointment.startAt, timezone);
const timeLabel = formatTimeLabel(appointment.startAt, timezone);
const services = appointment.services.map((s) => s.service.name).join(", ");
const now = new Date();
const versionSuffix = appointment.startAt.getTime();
for (const hoursBefore of REMINDER_HOURS_BEFORE) {
const scheduledFor = new Date(appointment.startAt.getTime() - hoursBefore * 60 * 60 * 1000);
if (scheduledFor <= now) continue;
await notifySafely(prisma, {
businessId: appointment.businessId,
type: NotificationType.BOOKING_REMINDER,
recipientUserId: appointment.customer.userId ?? undefined,
recipientCustomerId: appointment.customer.id,
appointmentId: appointment.id,
subject: "Upcoming appointment reminder",
bodyText: `Reminder: your appointment for ${services} with ${appointment.staff.user.name} is on ${dateLabel} at ${timeLabel}.`,
deepLinkPath: `/appointments/${appointment.id}`,
idempotencyKey: `notify:booking-reminder-${hoursBefore}h:${appointment.id}:${versionSuffix}`,
scheduledFor,
});
await notifySafely(prisma, {
businessId: appointment.businessId,
type: NotificationType.STAFF_APPOINTMENT_REMINDER,
recipientUserId: appointment.staff.userId,
appointmentId: appointment.id,
subject: "Upcoming appointment reminder",
bodyText: `Reminder: ${appointment.customer.firstName} ${appointment.customer.lastName} (${services}) is booked for ${dateLabel} at ${timeLabel}.`,
deepLinkPath: `/staff/appointments/${appointment.id}`,
idempotencyKey: `notify:staff-appointment-reminder-${hoursBefore}h:${appointment.id}:${versionSuffix}`,
scheduledFor,
});
}
}
@@ -0,0 +1,223 @@
import { Prisma } from "@/generated/prisma/client";
import type { PrismaClient } from "@/generated/prisma/client";
import type { AppointmentStatus } from "@/generated/prisma/enums";
/**
* The one place a booking is actually committed to the database. Every
* other part of the engine (AvailabilityService, BookingService) works
* with a *snapshot* of the schedule that can go stale the instant another
* request books the same slot — this function is what makes double-
* booking prevention real rather than advisory, by re-checking for
* conflicts inside a Serializable transaction and letting Postgres itself
* reject a genuine race rather than trusting an application-level check
* alone.
*
* Two layers of defence, both needed:
* 1. An explicit overlap re-check against the target staff member's
* other appointments, inside the transaction.
* 2. SERIALIZABLE isolation, so that even a race the explicit check
* can't see (e.g. two transactions whose reads briefly overlap) gets
* rejected by the database with a serialization failure, which we
* translate into the same DoubleBookingError. Serializable isolation
* is standard SQL, supported by both Postgres and MySQL — this stays
* portable, unlike a Postgres-only exclusion constraint would be.
*
* Re-verified against MySQL 8.0 in Phase 7 (see docs/phase-7-summary.md):
* a 25-way concurrent-write stress test against the same slot produced 1
* commit, 15 rejections caught by the explicit `assertNoConflict` check,
* and 9 raw database write-conflict errors — all surfaced as Prisma's
* `P2034` (`PrismaClientKnownRequestError`), the SAME code the classic
* query-engine used for Postgres. This was not a given: Prisma 7's
* TypeScript-native client-engine-runtime (used with driver adapters, see
* src/lib/db/prisma.ts) propagates a raw `DriverAdapterError` for a
* Postgres serialization failure instead of wrapping it as P2034 (see the
* check below) — empirically, MySQL's write conflicts take the OTHER path
* and already arrive as P2034, so `isSerializationFailure`'s first check
* already covers MySQL correctly with no new branch needed. Found by
* running the concurrency test repeatedly and a dedicated high-concurrency
* probe, not assumed from the Postgres behaviour.
*/
export class DoubleBookingError extends Error {
constructor(message = "This time is no longer available — it was just booked.") {
super(message);
this.name = "DoubleBookingError";
}
}
const NON_BLOCKING_STATUSES: AppointmentStatus[] = ["CANCELLED", "NO_SHOW"];
export interface AppointmentServiceLine {
serviceId: string;
priceAtBooking: Prisma.Decimal | number | string;
durationAtBookingMinutes: number;
}
export interface CreateAppointmentParams {
businessId: string;
customerId: string;
staffId: string;
startAt: Date;
endAt: Date;
status: AppointmentStatus;
notes?: string;
/** Who initiated this booking — see Appointment.createdByUserId in schema.prisma. Omitted for a guest customer's own self-service booking. */
createdByUserId?: string;
effectiveBuffer: { beforeMinutes: number; afterMinutes: number };
services: AppointmentServiceLine[];
}
export interface RescheduleAppointmentParams {
appointmentId: string;
staffId: string;
startAt: Date;
endAt: Date;
effectiveBuffer: { beforeMinutes: number; afterMinutes: number };
}
async function assertNoConflict(
tx: PrismaClient,
params: {
staffId: string;
startAt: Date;
endAt: Date;
effectiveBuffer: { beforeMinutes: number; afterMinutes: number };
excludeAppointmentId?: string;
},
): Promise<void> {
// AvailabilityService pads each EXISTING appointment by
// [start - beforeMinutes, end + afterMinutes] and checks the candidate's
// raw window against that (slot-calculator.ts expandWithBuffer). Doing
// the equivalent check here — with a single query, without fetching and
// padding every row in application code — means padding the CANDIDATE
// instead, but that flips which side each buffer applies to: a conflict
// exists when `existing.startAt < candidateEnd + beforeMinutes` (an
// existing appointment starting soon after the candidate ends still
// needs its own leading buffer) `&& existing.endAt > candidateStart -
// afterMinutes` (an existing appointment that just finished still needs
// its trailing buffer before the candidate can start). Using
// beforeMinutes/afterMinutes the other way round (as an earlier version
// of this function did) silently swaps which buffer guards which side —
// found via a real MySQL E2E failure where a candidate slot placed
// immediately BEFORE an existing appointment was wrongly rejected using
// that appointment's bufferAfterMinutes instead of its
// bufferBeforeMinutes (see docs/phase-7-summary.md).
const bufferedStart = new Date(
params.startAt.getTime() - params.effectiveBuffer.afterMinutes * 60_000,
);
const bufferedEnd = new Date(
params.endAt.getTime() + params.effectiveBuffer.beforeMinutes * 60_000,
);
const conflict = await tx.appointment.findFirst({
where: {
staffId: params.staffId,
status: { notIn: NON_BLOCKING_STATUSES },
...(params.excludeAppointmentId ? { id: { not: params.excludeAppointmentId } } : {}),
startAt: { lt: bufferedEnd },
endAt: { gt: bufferedStart },
},
select: { id: true },
});
if (conflict) throw new DoubleBookingError();
}
function isSerializationFailure(error: unknown): boolean {
// P2034: "Transaction failed due to a write conflict or a deadlock. Please retry your transaction."
// The error shape Prisma's classic query-engine binary produces — and, found
// empirically in Phase 7, also what MySQL 8.0's write-conflict errors arrive as
// through the TS-native client-engine-runtime (see the file header for the
// concurrency-stress-test results). This one check covers both databases.
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2034") return true;
// Prisma 7's TypeScript-native client-engine-runtime (used with driver
// adapters, see src/lib/db/prisma.ts) can instead propagate the adapter's
// own DriverAdapterError for a serialization failure rather than wrapping
// it as P2034 — found empirically for Postgres (SQLSTATE 40001) in Phase
// 3. MySQL's write conflicts did NOT take this path in Phase 7's testing
// (they all arrived as P2034 above); this fallback is kept as defence in
// depth for whichever database is in use, not because MySQL is known to
// need it. Duck-typed rather than importing @prisma/driver-adapter-utils
// directly, since that's a transitive dependency, not one of ours.
if (
error instanceof Error &&
error.name === "DriverAdapterError" &&
"cause" in error &&
typeof error.cause === "object" &&
error.cause !== null &&
"kind" in error.cause &&
error.cause.kind === "TransactionWriteConflict"
) {
return true;
}
return false;
}
async function runSerializable<T>(
prisma: PrismaClient,
fn: (tx: PrismaClient) => Promise<T>,
): Promise<T> {
try {
return await prisma.$transaction(fn as (tx: Prisma.TransactionClient) => Promise<T>, {
isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
});
} catch (error) {
if (isSerializationFailure(error)) throw new DoubleBookingError();
throw error;
}
}
export async function createAppointment(prisma: PrismaClient, params: CreateAppointmentParams) {
return runSerializable(prisma, async (tx) => {
await assertNoConflict(tx, {
staffId: params.staffId,
startAt: params.startAt,
endAt: params.endAt,
effectiveBuffer: params.effectiveBuffer,
});
return tx.appointment.create({
data: {
businessId: params.businessId,
customerId: params.customerId,
staffId: params.staffId,
startAt: params.startAt,
endAt: params.endAt,
status: params.status,
notes: params.notes,
createdByUserId: params.createdByUserId,
services: {
create: params.services.map((s) => ({
serviceId: s.serviceId,
priceAtBooking: s.priceAtBooking,
durationAtBookingMinutes: s.durationAtBookingMinutes,
})),
},
},
include: { services: true, staff: { include: { user: true } }, customer: true },
});
});
}
export async function rescheduleAppointment(
prisma: PrismaClient,
params: RescheduleAppointmentParams,
) {
return runSerializable(prisma, async (tx) => {
await assertNoConflict(tx, {
staffId: params.staffId,
startAt: params.startAt,
endAt: params.endAt,
effectiveBuffer: params.effectiveBuffer,
excludeAppointmentId: params.appointmentId,
});
return tx.appointment.update({
where: { id: params.appointmentId },
data: { staffId: params.staffId, startAt: params.startAt, endAt: params.endAt },
include: { services: true, staff: { include: { user: true } }, customer: true },
});
});
}
+157
View File
@@ -0,0 +1,157 @@
import type { TimeWindow } from "./types";
/**
* Pure, DB-free scheduling math for a single day. Every function here
* operates on plain `TimeWindow` values (minutes since local midnight,
* 0–1440) — no Prisma, no dates, no I/O. This is deliberate: it's the
* part of the engine with the most edge cases (overlapping breaks,
* back-to-back appointments, zero-length windows), so it's the part that
* benefits most from being fully unit-testable without a database. The
* orchestration layer (staff-availability.service.ts, etc.) fetches real
* data and converts it into these shapes; this module never touches the
* database.
*
* A window never crosses midnight — a barbershop day doesn't run past
* 24:00 in this model. `startMinutes`/`endMinutes` are clamped to
* [0, 1440] wherever a caller-supplied value (e.g. a buffer pushing an
* appointment's busy window before opening) could otherwise escape that
* range.
*/
const DAY_START = 0;
const DAY_END = 24 * 60;
function clamp(minutes: number): number {
return Math.min(DAY_END, Math.max(DAY_START, minutes));
}
function isValidWindow(w: TimeWindow): boolean {
return w.endMinutes > w.startMinutes;
}
/** Sort and merge overlapping/touching windows into the minimal equivalent set. */
export function mergeWindows(windows: TimeWindow[]): TimeWindow[] {
const valid = windows.filter(isValidWindow).sort((a, b) => a.startMinutes - b.startMinutes);
const merged: TimeWindow[] = [];
for (const w of valid) {
const last = merged[merged.length - 1];
if (last && w.startMinutes <= last.endMinutes) {
last.endMinutes = Math.max(last.endMinutes, w.endMinutes);
} else {
merged.push({ ...w });
}
}
return merged;
}
/** Subtract every window in `toRemove` from every window in `base`, returning the remaining free fragments. */
export function subtractWindows(base: TimeWindow[], toRemove: TimeWindow[]): TimeWindow[] {
const removals = mergeWindows(toRemove);
let remaining = base.filter(isValidWindow).map((w) => ({ ...w }));
for (const cut of removals) {
const next: TimeWindow[] = [];
for (const w of remaining) {
if (cut.endMinutes <= w.startMinutes || cut.startMinutes >= w.endMinutes) {
// No overlap.
next.push(w);
continue;
}
// Left remainder.
if (cut.startMinutes > w.startMinutes) {
next.push({ startMinutes: w.startMinutes, endMinutes: cut.startMinutes });
}
// Right remainder.
if (cut.endMinutes < w.endMinutes) {
next.push({ startMinutes: cut.endMinutes, endMinutes: w.endMinutes });
}
}
remaining = next;
}
return remaining.filter(isValidWindow);
}
/** Intersect two sets of windows (e.g. business-open ∩ staff-working). */
export function intersectWindows(a: TimeWindow[], b: TimeWindow[]): TimeWindow[] {
const result: TimeWindow[] = [];
for (const wa of a) {
for (const wb of b) {
const start = Math.max(wa.startMinutes, wb.startMinutes);
const end = Math.min(wa.endMinutes, wb.endMinutes);
if (end > start) result.push({ startMinutes: start, endMinutes: end });
}
}
return mergeWindows(result);
}
export interface FreeWindowsInput {
/** null = business closed all day. */
businessOpen: TimeWindow | null;
/** null = staff not scheduled to work at all that day. */
staffWorking: TimeWindow | null;
/** Recurring breaks applicable to this day of week. */
breaks: TimeWindow[];
/** True if an approved TimeOff record covers this calendar date — blocks the whole day. */
onLeave: boolean;
/** Existing appointments' busy spans, already buffer-expanded by the caller. */
busy: TimeWindow[];
}
/**
* The core availability computation for one staff member on one day:
* business hours ∩ staff hours, minus breaks, minus (the whole day, if on
* leave), minus buffered busy time from existing appointments.
*/
export function computeFreeWindows(input: FreeWindowsInput): TimeWindow[] {
if (input.onLeave) return [];
if (!input.businessOpen || !input.staffWorking) return [];
const base = intersectWindows([input.businessOpen], [input.staffWorking]);
const withoutBreaks = subtractWindows(base, input.breaks);
return subtractWindows(withoutBreaks, input.busy).map((w) => ({
startMinutes: clamp(w.startMinutes),
endMinutes: clamp(w.endMinutes),
}));
}
/**
* Candidate appointment start times: every point on the `slotIntervalMinutes`
* grid (aligned to midnight — 0, interval, 2×interval, …) where
* `[start, start + durationMinutes]` fits entirely inside one free window.
* Grid-aligned rather than window-relative so slot times look like the
* "9:00, 9:30, 10:00" a customer expects, not odd offsets derived from
* when a previous appointment happened to end.
*/
export function generateSlotStarts(
freeWindows: TimeWindow[],
params: { slotIntervalMinutes: number; durationMinutes: number },
): number[] {
const { slotIntervalMinutes, durationMinutes } = params;
if (slotIntervalMinutes <= 0) throw new Error("slotIntervalMinutes must be positive");
if (durationMinutes <= 0) throw new Error("durationMinutes must be positive");
const starts: number[] = [];
for (const window of freeWindows) {
const firstGridPoint = Math.ceil(window.startMinutes / slotIntervalMinutes) * slotIntervalMinutes;
for (let start = firstGridPoint; start + durationMinutes <= window.endMinutes; start += slotIntervalMinutes) {
starts.push(start);
}
}
return starts;
}
/** Expand an appointment's [start, end] into a buffer-padded busy window, clamped to a single day. */
export function expandWithBuffer(
appointment: TimeWindow,
buffer: { beforeMinutes: number; afterMinutes: number },
): TimeWindow {
return {
startMinutes: clamp(appointment.startMinutes - buffer.beforeMinutes),
endMinutes: clamp(appointment.endMinutes + buffer.afterMinutes),
};
}
@@ -0,0 +1,204 @@
import type { PrismaClient } from "@/generated/prisma/client";
import { Weekday } from "@/generated/prisma/enums";
import {
calendarDateToUtcMidnight,
eachCalendarDate,
endOfBusinessDay,
minutesFromMidnight,
startOfBusinessDay,
weekdayOf,
} from "@/lib/time/business-time";
import type { CalendarDate } from "@/lib/time/business-time";
import { computeFreeWindows, expandWithBuffer } from "./slot-calculator";
import type { TimeWindow } from "./types";
/**
* A single staff member's availability — recurring working hours,
* recurring breaks, approved time off, and existing appointments
* (buffer-expanded). Deliberately does NOT know about business opening
* hours or requested services; those are AvailabilityService's job to
* fetch and combine. This keeps "what does this barber's own schedule
* look like" separate from "what can a customer actually book".
*
* `getStaffFreeWindowsForRange` is the primitive both other exports build
* on: it fetches each of WorkingHours/Breaks/TimeOff/Appointments exactly
* once for the whole date range (not once per day), because a 60-day
* "which dates have openings" scan calling a single-day query in a loop
* would be 4 DB round-trips × 60 days × N staff — the range-batched
* version is 4 round-trips × N staff, full stop.
*/
export interface BusinessOpenByWeekday {
get(weekday: Weekday): TimeWindow | null;
}
export function businessOpenByWeekdayFrom(
hours: { dayOfWeek: Weekday; isClosed: boolean; opensAtMinutes: number | null; closesAtMinutes: number | null }[],
): BusinessOpenByWeekday {
const map = new Map<Weekday, TimeWindow | null>();
for (const h of hours) {
map.set(
h.dayOfWeek,
!h.isClosed && h.opensAtMinutes !== null && h.closesAtMinutes !== null
? { startMinutes: h.opensAtMinutes, endMinutes: h.closesAtMinutes }
: null,
);
}
return { get: (weekday) => map.get(weekday) ?? null };
}
export interface GetStaffFreeWindowsForRangeParams {
staffId: string;
fromDate: CalendarDate;
toDate: CalendarDate;
businessTimezone: string;
businessOpenByWeekday: BusinessOpenByWeekday;
effectiveBuffer: { beforeMinutes: number; afterMinutes: number };
excludeAppointmentId?: string;
}
export async function getStaffFreeWindowsForRange(
prisma: PrismaClient,
params: GetStaffFreeWindowsForRangeParams,
): Promise<Map<CalendarDate, TimeWindow[]>> {
const { staffId, fromDate, toDate, businessTimezone, businessOpenByWeekday, effectiveBuffer, excludeAppointmentId } =
params;
const rangeStart = startOfBusinessDay(fromDate, businessTimezone);
const rangeEnd = endOfBusinessDay(toDate, businessTimezone);
const [workingHoursRows, breakDays, timeOffRows, appointments] = await Promise.all([
prisma.workingHours.findMany({ where: { staffId } }),
prisma.breakDay.findMany({ where: { break: { staffId } }, include: { break: true } }),
prisma.timeOff.findMany({
where: {
staffId,
status: "APPROVED",
startDate: { lte: calendarDateToUtcMidnight(toDate) },
endDate: { gte: calendarDateToUtcMidnight(fromDate) },
},
}),
prisma.appointment.findMany({
where: {
staffId,
status: { notIn: ["CANCELLED", "NO_SHOW"] },
...(excludeAppointmentId ? { id: { not: excludeAppointmentId } } : {}),
startAt: { lt: rangeEnd },
endAt: { gt: rangeStart },
},
select: { startAt: true, endAt: true },
}),
]);
const workingHoursByWeekday = new Map(workingHoursRows.map((wh) => [wh.dayOfWeek, wh]));
const breaksByWeekday = new Map<Weekday, TimeWindow[]>();
for (const bd of breakDays) {
const list = breaksByWeekday.get(bd.dayOfWeek) ?? [];
list.push({ startMinutes: bd.break.startMinutes, endMinutes: bd.break.endMinutes });
breaksByWeekday.set(bd.dayOfWeek, list);
}
const result = new Map<CalendarDate, TimeWindow[]>();
for (const date of eachCalendarDate(fromDate, toDate)) {
const weekday = weekdayOf(date);
const businessOpen = businessOpenByWeekday.get(weekday);
if (!businessOpen) {
result.set(date, []);
continue;
}
const wh = workingHoursByWeekday.get(weekday);
const staffWorking: TimeWindow | null =
wh && !wh.isOff && wh.startMinutes !== null && wh.endMinutes !== null
? { startMinutes: wh.startMinutes, endMinutes: wh.endMinutes }
: null;
const dateAsUtcMidnight = calendarDateToUtcMidnight(date);
const timeOffOnDate = timeOffRows.filter((t) => t.startDate <= dateAsUtcMidnight && t.endDate >= dateAsUtcMidnight);
// A row with both minutes null blocks the whole day (the original
// behaviour); a row with minutes set — including a one-off "Block
// Time" entry, see TimeOff.startMinutes in schema.prisma — only blocks
// that window, so it's folded into `busy` below instead.
const onLeave = timeOffOnDate.some((t) => t.startMinutes === null);
const partialTimeOffBusy: TimeWindow[] = timeOffOnDate
.filter((t) => t.startMinutes !== null && t.endMinutes !== null)
.map((t) => ({ startMinutes: t.startMinutes!, endMinutes: t.endMinutes! }));
const dayStart = startOfBusinessDay(date, businessTimezone);
const dayEnd = endOfBusinessDay(date, businessTimezone);
const busy: TimeWindow[] = [
...appointments
.filter((a) => a.startAt < dayEnd && a.endAt > dayStart)
.map((a) =>
expandWithBuffer(
{
startMinutes: minutesFromMidnight(a.startAt, businessTimezone),
endMinutes: minutesFromMidnight(a.endAt, businessTimezone),
},
effectiveBuffer,
),
),
...partialTimeOffBusy,
];
result.set(
date,
computeFreeWindows({
businessOpen,
staffWorking,
breaks: breaksByWeekday.get(weekday) ?? [],
onLeave,
busy,
}),
);
}
return result;
}
export interface GetStaffFreeWindowsParams {
staffId: string;
date: CalendarDate;
businessTimezone: string;
/** Business opening hours for this date, already resolved by the caller — null if the business is closed that day. */
businessOpen: TimeWindow | null;
effectiveBuffer: { beforeMinutes: number; afterMinutes: number };
excludeAppointmentId?: string;
}
/** Single-day convenience wrapper around getStaffFreeWindowsForRange. */
export async function getStaffFreeWindows(prisma: PrismaClient, params: GetStaffFreeWindowsParams): Promise<TimeWindow[]> {
const { staffId, date, businessTimezone, businessOpen, effectiveBuffer, excludeAppointmentId } = params;
const weekday = weekdayOf(date);
const byWeekday: BusinessOpenByWeekday = { get: (w) => (w === weekday ? businessOpen : null) };
const map = await getStaffFreeWindowsForRange(prisma, {
staffId,
fromDate: date,
toDate: date,
businessTimezone,
businessOpenByWeekday: byWeekday,
effectiveBuffer,
excludeAppointmentId,
});
return map.get(date) ?? [];
}
/** How many non-cancelled appointments this staff member has on a given calendar date — used for the per-staff daily cap. */
export async function countStaffAppointmentsOnDate(
prisma: PrismaClient,
params: { staffId: string; date: CalendarDate; businessTimezone: string; excludeAppointmentId?: string },
): Promise<number> {
const { staffId, date, businessTimezone, excludeAppointmentId } = params;
return prisma.appointment.count({
where: {
staffId,
status: { notIn: ["CANCELLED", "NO_SHOW"] },
...(excludeAppointmentId ? { id: { not: excludeAppointmentId } } : {}),
startAt: { gte: startOfBusinessDay(date, businessTimezone), lt: endOfBusinessDay(date, businessTimezone) },
},
});
}
+76
View File
@@ -0,0 +1,76 @@
import type { CalendarDate } from "@/lib/time/business-time";
/** A span within a single day, in minutes since local midnight (0–1440). Never crosses a day boundary — see slot-calculator.ts header comment. */
export interface TimeWindow {
startMinutes: number;
endMinutes: number;
}
/** Minutes-from-midnight open/close for one day, or null if closed/off entirely. */
export interface DayHours {
isOpen: boolean;
window: TimeWindow | null;
}
export interface SlotCandidate {
date: CalendarDate;
startMinutes: number;
endMinutes: number;
}
export interface ServiceRequirement {
serviceId: string;
durationMinutes: number;
/** Buffer this specific service needs, on top of the business-wide default — see business-rules.service.ts for how the two combine. */
bufferBeforeMinutes: number;
bufferAfterMinutes: number;
/** Decimal serialized to string, snapshotted onto AppointmentService.priceAtBooking at booking time. */
price: string;
}
export const BOOKING_VALIDATION_ERROR_CODES = [
"INVALID_INPUT",
"NO_SERVICES_SELECTED",
"SERVICES_SPAN_MULTIPLE_BUSINESSES",
"STAFF_CANNOT_PERFORM_SERVICE",
"BUSINESS_CLOSED",
"STAFF_NOT_WORKING",
"STAFF_ON_BREAK",
"STAFF_ON_LEAVE",
"SLOT_UNAVAILABLE",
"INSUFFICIENT_DURATION",
"BELOW_MIN_NOTICE",
"BEYOND_MAX_ADVANCE",
"BUSINESS_DAILY_LIMIT_REACHED",
"STAFF_DAILY_LIMIT_REACHED",
"APPOINTMENT_NOT_FOUND",
"INVALID_STATUS_TRANSITION",
"STAFF_NOT_FOUND",
"CUSTOMER_NOT_FOUND",
"BUSINESS_NOT_FOUND",
] as const;
export type BookingValidationErrorCode = (typeof BOOKING_VALIDATION_ERROR_CODES)[number];
export interface ValidationIssue {
code: BookingValidationErrorCode;
message: string;
}
export interface ValidationResult {
valid: boolean;
issues: ValidationIssue[];
}
export function ok(): ValidationResult {
return { valid: true, issues: [] };
}
export function fail(...issues: ValidationIssue[]): ValidationResult {
return { valid: false, issues };
}
export function mergeValidation(...results: ValidationResult[]): ValidationResult {
const issues = results.flatMap((r) => r.issues);
return { valid: issues.length === 0, issues };
}
@@ -0,0 +1,244 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
createStaffProfile,
deactivateStaff,
reactivateStaff,
setStaffServiceEligibility,
updateStaffAsOwner,
} from "../staff.service";
import {
cleanupTestBusiness,
createTestBusiness,
createTestCustomer,
createTestStaff,
prisma,
} from "@/services/scheduling/__tests__/fixtures";
import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures";
/**
* Covers the owner-only staff management additions from Phase 6:
* onboarding, deactivation/last-owner protection, and service-eligibility
* conflict review. Uses createTestStaff's auto-created role (no
* MANAGE_STAFF permission) as the "ordinary barber" role, and a
* hand-created role with MANAGE_STAFF as the "owner" role — mirroring how
* countOtherActiveManagers/staffCurrentlyManagesStaff actually define "is
* an owner" (see staff.service.ts).
*/
describe("StaffService — owner actions (integration)", () => {
let business: TestBusiness;
beforeEach(async () => {
business = await createTestBusiness();
});
afterEach(async () => {
await cleanupTestBusiness(business);
});
async function createOwnerRole() {
return prisma.role.create({
data: {
businessId: business.businessId,
name: "Owner",
permissions: { create: [{ permission: "MANAGE_STAFF" }] },
},
});
}
async function createBarberRole() {
return prisma.role.create({ data: { businessId: business.businessId, name: "Barber" } });
}
describe("createStaffProfile", () => {
it("creates a fully usable User + StaffProfile + eligibility in one go", async () => {
const role = await createBarberRole();
const result = await createStaffProfile(prisma, {
businessId: business.businessId,
input: {
name: "New Barber",
email: "new.barber@test.local",
roleId: role.id,
serviceIds: [business.haircutServiceId],
isBookableOnline: true,
},
});
expect(result.success).toBe(true);
if (!result.success) throw new Error("unreachable");
business.createdUserIds.push(result.staff.userId);
const eligibility = await prisma.staffService.findMany({ where: { staffId: result.staff.id } });
expect(eligibility.map((e) => e.serviceId)).toEqual([business.haircutServiceId]);
});
it("rejects an email already in use", async () => {
const role = await createBarberRole();
const { userId } = await createTestStaff(business);
const existingUser = await prisma.user.findUniqueOrThrow({ where: { id: userId } });
const result = await createStaffProfile(prisma, {
businessId: business.businessId,
input: {
name: "Duplicate",
email: existingUser.email!,
roleId: role.id,
serviceIds: [],
isBookableOnline: true,
},
});
expect(result.success).toBe(false);
if (result.success) throw new Error("unreachable");
expect(result.validation.issues[0]?.code).toBe("EMAIL_ALREADY_IN_USE");
});
});
describe("last-owner protection", () => {
it("blocks deactivating the only active owner", async () => {
const ownerRole = await createOwnerRole();
const { staffId: ownerStaffId, userId } = await createTestStaff(business);
await prisma.staffProfile.update({ where: { id: ownerStaffId }, data: { roleId: ownerRole.id } });
void userId;
const result = await deactivateStaff(prisma, { staffId: ownerStaffId, businessId: business.businessId });
expect(result.success).toBe(false);
if (result.success) throw new Error("unreachable");
expect(result.validation.issues[0]?.code).toBe("LAST_OWNER");
const stillActive = await prisma.staffProfile.findUniqueOrThrow({ where: { id: ownerStaffId } });
expect(stillActive.status).toBe("ACTIVE");
});
it("allows deactivating an owner when another active owner remains", async () => {
const ownerRole = await createOwnerRole();
const { staffId: firstOwnerId } = await createTestStaff(business);
const { staffId: secondOwnerId } = await createTestStaff(business);
await prisma.staffProfile.updateMany({
where: { id: { in: [firstOwnerId, secondOwnerId] } },
data: { roleId: ownerRole.id },
});
const result = await deactivateStaff(prisma, { staffId: firstOwnerId, businessId: business.businessId });
expect(result.success).toBe(true);
const deactivated = await prisma.staffProfile.findUniqueOrThrow({ where: { id: firstOwnerId } });
expect(deactivated.status).toBe("INACTIVE");
});
it("blocks demoting the only active owner's role away from MANAGE_STAFF", async () => {
const ownerRole = await createOwnerRole();
const barberRole = await createBarberRole();
const { staffId: ownerStaffId } = await createTestStaff(business);
await prisma.staffProfile.update({ where: { id: ownerStaffId }, data: { roleId: ownerRole.id } });
const result = await updateStaffAsOwner(prisma, {
staffId: ownerStaffId,
businessId: business.businessId,
input: { roleId: barberRole.id },
});
expect(result.success).toBe(false);
if (result.success) throw new Error("unreachable");
expect(result.validation.issues[0]?.code).toBe("LAST_OWNER");
const stillOwner = await prisma.staffProfile.findUniqueOrThrow({ where: { id: ownerStaffId } });
expect(stillOwner.roleId).toBe(ownerRole.id);
});
it("reactivating a deactivated staff member restores ACTIVE status", async () => {
const { staffId } = await createTestStaff(business);
await prisma.staffProfile.update({ where: { id: staffId }, data: { status: "INACTIVE" } });
const result = await reactivateStaff(prisma, { staffId, businessId: business.businessId });
expect(result.success).toBe(true);
const staff = await prisma.staffProfile.findUniqueOrThrow({ where: { id: staffId } });
expect(staff.status).toBe("ACTIVE");
});
});
describe("deactivation appointment conflicts", () => {
it("blocks deactivating a staff member with a future appointment, and Save Anyway bypasses it without cancelling it", async () => {
const { staffId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
const appointment = await prisma.appointment.create({
data: {
businessId: business.businessId,
staffId,
customerId,
status: "CONFIRMED",
startAt: new Date(Date.now() + 5 * 24 * 60 * 60 * 1000),
endAt: new Date(Date.now() + 5 * 24 * 60 * 60 * 1000 + 30 * 60_000),
services: {
create: [{ serviceId: business.haircutServiceId, priceAtBooking: 20, durationAtBookingMinutes: 30 }],
},
},
});
const blocked = await deactivateStaff(prisma, { staffId, businessId: business.businessId });
expect(blocked.success).toBe(false);
if (blocked.success) throw new Error("unreachable");
expect(blocked.conflicts.map((c) => c.id)).toContain(appointment.id);
const acknowledged = await deactivateStaff(prisma, {
staffId,
businessId: business.businessId,
acknowledgeConflicts: true,
});
expect(acknowledged.success).toBe(true);
const stillThere = await prisma.appointment.findUniqueOrThrow({ where: { id: appointment.id } });
expect(stillThere.status).toBe("CONFIRMED");
expect(stillThere.staffId).toBe(staffId);
});
});
describe("setStaffServiceEligibility", () => {
it("blocks removing a service that has a future appointment booked, and Save Anyway bypasses it", async () => {
const { staffId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
await prisma.appointment.create({
data: {
businessId: business.businessId,
staffId,
customerId,
status: "CONFIRMED",
startAt: new Date(Date.now() + 5 * 24 * 60 * 60 * 1000),
endAt: new Date(Date.now() + 5 * 24 * 60 * 60 * 1000 + 30 * 60_000),
services: {
create: [{ serviceId: business.haircutServiceId, priceAtBooking: 20, durationAtBookingMinutes: 30 }],
},
},
});
// Existing fixture staff is eligible for both haircut and beard trim
// (see createTestStaff) — removing haircut should conflict.
const blocked = await setStaffServiceEligibility(prisma, {
staffId,
businessId: business.businessId,
serviceIds: [business.beardTrimServiceId],
});
expect(blocked.success).toBe(false);
if (blocked.success) throw new Error("unreachable");
expect(blocked.conflicts.length).toBeGreaterThan(0);
const acknowledged = await setStaffServiceEligibility(prisma, {
staffId,
businessId: business.businessId,
serviceIds: [business.beardTrimServiceId],
acknowledgeConflicts: true,
});
expect(acknowledged.success).toBe(true);
const eligibility = await prisma.staffService.findMany({ where: { staffId } });
expect(eligibility.map((e) => e.serviceId)).toEqual([business.beardTrimServiceId]);
});
it("adding a service never triggers a conflict", async () => {
const { staffId } = await createTestStaff(business);
const result = await setStaffServiceEligibility(prisma, {
staffId,
businessId: business.businessId,
serviceIds: [business.haircutServiceId, business.beardTrimServiceId],
});
expect(result.success).toBe(true);
});
});
});
@@ -0,0 +1,332 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { Weekday } from "@/generated/prisma/enums";
import { addDays, todayIn, wallClockToInstant, weekdayOf } from "@/lib/time/business-time";
import type { CalendarDate } from "@/lib/time/business-time";
import {
createBreak,
createTimeOff,
deleteBreak,
deleteTimeOff,
getWorkingHours,
setWorkingHours,
updateBreak,
updateTimeOff,
} from "../staff.service";
import {
cleanupTestBusiness,
createTestBusiness,
createTestCustomer,
createTestStaff,
prisma,
} from "@/services/scheduling/__tests__/fixtures";
import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures";
function nextWeekday(timezone: string, target: Weekday, minDaysAhead = 7): CalendarDate {
let date = addDays(todayIn(timezone), minDaysAhead);
for (let i = 0; i < 8; i++) {
if (weekdayOf(date) === target) return date;
date = addDays(date, 1);
}
throw new Error("unreachable");
}
/**
* Covers the Breaks/Time Off conflict-resolution flow and the ownership
* checks added to updateBreak/deleteBreak/updateTimeOff/deleteTimeOff — a
* real bug caught during Phase 5 development where a permission mismatch
* threw uncaught instead of returning the graceful {success:false} shape
* every other StaffService function uses.
*/
describe("StaffService (integration)", () => {
let business: TestBusiness;
beforeEach(async () => {
business = await createTestBusiness();
});
afterEach(async () => {
await cleanupTestBusiness(business);
});
describe("working hours", () => {
it("round-trips a full week through setWorkingHours/getWorkingHours", async () => {
const { staffId } = await createTestStaff(business);
const result = await setWorkingHours(prisma, {
staffId,
businessTimezone: business.timezone,
days: [
{ dayOfWeek: Weekday.MONDAY, isOff: false, startMinutes: 9 * 60, endMinutes: 17 * 60 },
{ dayOfWeek: Weekday.SUNDAY, isOff: true },
],
});
expect(result.success).toBe(true);
const hours = await getWorkingHours(prisma, staffId);
const monday = hours.find((h) => h.dayOfWeek === Weekday.MONDAY);
expect(monday?.startMinutes).toBe(9 * 60);
expect(monday?.endMinutes).toBe(17 * 60);
const sunday = hours.find((h) => h.dayOfWeek === Weekday.SUNDAY);
expect(sunday?.isOff).toBe(true);
});
it("blocks marking a day off that has a future appointment, and Save Anyway bypasses it without cancelling the appointment", async () => {
const { staffId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
const monday = nextWeekday(business.timezone, Weekday.MONDAY);
const appointment = await prisma.appointment.create({
data: {
businessId: business.businessId,
staffId,
customerId,
status: "CONFIRMED",
startAt: wallClockToInstant(monday, 12 * 60, business.timezone),
endAt: wallClockToInstant(monday, 12 * 60 + 30, business.timezone),
services: {
create: [{ serviceId: business.haircutServiceId, priceAtBooking: 20, durationAtBookingMinutes: 30 }],
},
},
});
const days = [
{ dayOfWeek: Weekday.MONDAY, isOff: true },
{ dayOfWeek: Weekday.TUESDAY, isOff: false, startMinutes: 9 * 60, endMinutes: 17 * 60 },
];
const blocked = await setWorkingHours(prisma, { staffId, businessTimezone: business.timezone, days });
expect(blocked.success).toBe(false);
if (blocked.success) throw new Error("unreachable");
expect(blocked.conflicts.map((c) => c.id)).toContain(appointment.id);
const stillConfirmed = await prisma.appointment.findUniqueOrThrow({ where: { id: appointment.id } });
expect(stillConfirmed.status).toBe("CONFIRMED");
const acknowledged = await setWorkingHours(prisma, {
staffId,
businessTimezone: business.timezone,
days,
acknowledgeConflicts: true,
});
expect(acknowledged.success).toBe(true);
const afterSave = await prisma.appointment.findUniqueOrThrow({ where: { id: appointment.id } });
expect(afterSave.status).toBe("CONFIRMED");
expect(afterSave.staffId).toBe(staffId);
});
it("does not flag a conflict for an appointment that falls inside the new working window", async () => {
const { staffId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
const monday = nextWeekday(business.timezone, Weekday.MONDAY);
await prisma.appointment.create({
data: {
businessId: business.businessId,
staffId,
customerId,
status: "CONFIRMED",
startAt: wallClockToInstant(monday, 12 * 60, business.timezone),
endAt: wallClockToInstant(monday, 12 * 60 + 30, business.timezone),
services: {
create: [{ serviceId: business.haircutServiceId, priceAtBooking: 20, durationAtBookingMinutes: 30 }],
},
},
});
const result = await setWorkingHours(prisma, {
staffId,
businessTimezone: business.timezone,
days: [{ dayOfWeek: Weekday.MONDAY, isOff: false, startMinutes: 9 * 60, endMinutes: 17 * 60 }],
});
expect(result.success).toBe(true);
});
});
describe("breaks", () => {
it("blocks creating a break that overlaps a future appointment on a matching day, and Save Anyway bypasses it", async () => {
const { staffId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
const monday = nextWeekday(business.timezone, Weekday.MONDAY);
const appointment = await prisma.appointment.create({
data: {
businessId: business.businessId,
staffId,
customerId,
status: "CONFIRMED",
startAt: wallClockToInstant(monday, 12 * 60, business.timezone),
endAt: wallClockToInstant(monday, 12 * 60 + 30, business.timezone),
services: {
create: [{ serviceId: business.haircutServiceId, priceAtBooking: 20, durationAtBookingMinutes: 30 }],
},
},
});
const blocked = await createBreak(prisma, {
staffId,
businessTimezone: business.timezone,
input: { label: "Lunch", startMinutes: 12 * 60, endMinutes: 13 * 60, days: [Weekday.MONDAY] },
});
expect(blocked.success).toBe(false);
if (blocked.success) throw new Error("unreachable");
expect(blocked.conflicts.map((c) => c.id)).toContain(appointment.id);
const acknowledged = await createBreak(prisma, {
staffId,
businessTimezone: business.timezone,
input: { label: "Lunch", startMinutes: 12 * 60, endMinutes: 13 * 60, days: [Weekday.MONDAY] },
acknowledgeConflicts: true,
});
expect(acknowledged.success).toBe(true);
});
it("returns a graceful failure — not a throw — when a staff member tries to update another staff member's break", async () => {
const { staffId: owner } = await createTestStaff(business);
const { staffId: intruder } = await createTestStaff(business);
const created = await createBreak(prisma, {
staffId: owner,
businessTimezone: business.timezone,
input: { label: "Lunch", startMinutes: 12 * 60, endMinutes: 13 * 60, days: [Weekday.TUESDAY] },
});
if (!created.success) throw new Error("setup failed");
const result = await updateBreak(prisma, {
breakId: created.break.id,
staffId: intruder,
businessTimezone: business.timezone,
input: { label: "Hijacked", startMinutes: 12 * 60, endMinutes: 13 * 60, days: [Weekday.TUESDAY] },
});
expect(result.success).toBe(false);
if (result.success) throw new Error("unreachable");
expect(result.validation.issues[0]?.code).toBe("PERMISSION_DENIED");
// Unchanged in the database — the attempted hijack had no effect.
const stillOwned = await prisma.break.findUniqueOrThrow({ where: { id: created.break.id } });
expect(stillOwned.label).toBe("Lunch");
});
it("returns {success:false} — not a throw — when a staff member tries to delete another staff member's break", async () => {
const { staffId: owner } = await createTestStaff(business);
const { staffId: intruder } = await createTestStaff(business);
const created = await createBreak(prisma, {
staffId: owner,
businessTimezone: business.timezone,
input: { label: "Lunch", startMinutes: 12 * 60, endMinutes: 13 * 60, days: [Weekday.WEDNESDAY] },
});
if (!created.success) throw new Error("setup failed");
const result = await deleteBreak(prisma, { breakId: created.break.id, staffId: intruder });
expect(result.success).toBe(false);
const stillExists = await prisma.break.findUnique({ where: { id: created.break.id } });
expect(stillExists).not.toBeNull();
});
});
describe("time off", () => {
it("blocks time off that overlaps a future appointment, and Save Anyway bypasses it", async () => {
const { staffId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
const monday = nextWeekday(business.timezone, Weekday.MONDAY);
const appointment = await prisma.appointment.create({
data: {
businessId: business.businessId,
staffId,
customerId,
status: "CONFIRMED",
startAt: wallClockToInstant(monday, 12 * 60, business.timezone),
endAt: wallClockToInstant(monday, 12 * 60 + 30, business.timezone),
services: {
create: [{ serviceId: business.haircutServiceId, priceAtBooking: 20, durationAtBookingMinutes: 30 }],
},
},
});
const blocked = await createTimeOff(prisma, {
staffId,
businessTimezone: business.timezone,
input: { startDate: monday, endDate: monday, reason: "Day off" },
});
expect(blocked.success).toBe(false);
if (blocked.success) throw new Error("unreachable");
expect(blocked.conflicts.map((c) => c.id)).toContain(appointment.id);
const acknowledged = await createTimeOff(prisma, {
staffId,
businessTimezone: business.timezone,
input: { startDate: monday, endDate: monday, reason: "Day off" },
acknowledgeConflicts: true,
});
expect(acknowledged.success).toBe(true);
});
it("a partial-day block only conflicts with appointments inside its own window", async () => {
const { staffId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
const monday = nextWeekday(business.timezone, Weekday.MONDAY);
// Appointment at 9am — outside the 12-1pm block below.
await prisma.appointment.create({
data: {
businessId: business.businessId,
staffId,
customerId,
status: "CONFIRMED",
startAt: wallClockToInstant(monday, 9 * 60, business.timezone),
endAt: wallClockToInstant(monday, 9 * 60 + 30, business.timezone),
services: {
create: [{ serviceId: business.haircutServiceId, priceAtBooking: 20, durationAtBookingMinutes: 30 }],
},
},
});
const result = await createTimeOff(prisma, {
staffId,
businessTimezone: business.timezone,
input: { startDate: monday, endDate: monday, startMinutes: 12 * 60, endMinutes: 13 * 60, reason: "Meeting" },
});
expect(result.success).toBe(true); // no conflict — the 9am appointment is outside the blocked window
});
it("returns a graceful failure — not a throw — when a staff member tries to update another staff member's time off", async () => {
const { staffId: owner } = await createTestStaff(business);
const { staffId: intruder } = await createTestStaff(business);
const monday = nextWeekday(business.timezone, Weekday.MONDAY);
const created = await createTimeOff(prisma, {
staffId: owner,
businessTimezone: business.timezone,
input: { startDate: monday, endDate: monday, reason: "Day off" },
});
if (!created.success) throw new Error("setup failed");
const result = await updateTimeOff(prisma, {
timeOffId: created.timeOff.id,
staffId: intruder,
businessTimezone: business.timezone,
input: { startDate: monday, endDate: monday, reason: "Hijacked" },
});
expect(result.success).toBe(false);
if (result.success) throw new Error("unreachable");
expect(result.validation.issues[0]?.code).toBe("PERMISSION_DENIED");
});
it("returns {success:false} — not a throw — when a staff member tries to delete another staff member's time off", async () => {
const { staffId: owner } = await createTestStaff(business);
const { staffId: intruder } = await createTestStaff(business);
const monday = nextWeekday(business.timezone, Weekday.MONDAY);
const created = await createTimeOff(prisma, {
staffId: owner,
businessTimezone: business.timezone,
input: { startDate: monday, endDate: monday, reason: "Day off" },
});
if (!created.success) throw new Error("setup failed");
const result = await deleteTimeOff(prisma, { timeOffId: created.timeOff.id, staffId: intruder });
expect(result.success).toBe(false);
const stillExists = await prisma.timeOff.findUnique({ where: { id: created.timeOff.id } });
expect(stillExists).not.toBeNull();
});
});
});
@@ -0,0 +1,173 @@
import { describe, expect, it } from "vitest";
import { Weekday } from "@/generated/prisma/enums";
import {
StaffPermissionDeniedError,
assertOwnsResource,
validateBreakInput,
validateCreateStaffInput,
validateTimeOffInput,
validateWorkingHoursInput,
} from "../staff.service";
describe("assertOwnsResource", () => {
it("does nothing when the acting staff member owns the resource", () => {
expect(() => assertOwnsResource("staff-1", "staff-1")).not.toThrow();
});
it("throws StaffPermissionDeniedError when they don't match", () => {
expect(() => assertOwnsResource("staff-1", "staff-2")).toThrow(StaffPermissionDeniedError);
});
});
describe("validateWorkingHoursInput", () => {
it("accepts a day marked off with no times", () => {
const result = validateWorkingHoursInput([{ dayOfWeek: Weekday.MONDAY, isOff: true }]);
expect(result.valid).toBe(true);
});
it("requires start and end minutes for a working day", () => {
const result = validateWorkingHoursInput([{ dayOfWeek: Weekday.MONDAY, isOff: false }]);
expect(result.valid).toBe(false);
expect(result.issues[0]?.code).toBe("INVALID_INPUT");
});
it("rejects start >= end", () => {
const result = validateWorkingHoursInput([
{ dayOfWeek: Weekday.TUESDAY, isOff: false, startMinutes: 600, endMinutes: 600 },
]);
expect(result.valid).toBe(false);
expect(result.issues[0]?.code).toBe("INVALID_TIME_RANGE");
});
it("rejects times outside a single day", () => {
const result = validateWorkingHoursInput([
{ dayOfWeek: Weekday.WEDNESDAY, isOff: false, startMinutes: 0, endMinutes: 25 * 60 },
]);
expect(result.valid).toBe(false);
expect(result.issues[0]?.code).toBe("INVALID_TIME_RANGE");
});
it("accepts a valid full week", () => {
const result = validateWorkingHoursInput([
{ dayOfWeek: Weekday.MONDAY, isOff: false, startMinutes: 9 * 60, endMinutes: 17 * 60 },
{ dayOfWeek: Weekday.SUNDAY, isOff: true },
]);
expect(result.valid).toBe(true);
});
});
describe("validateBreakInput", () => {
const base = { label: "Lunch", startMinutes: 12 * 60, endMinutes: 13 * 60, days: [Weekday.MONDAY] };
it("accepts a valid break", () => {
expect(validateBreakInput(base).valid).toBe(true);
});
it("rejects an empty label", () => {
const result = validateBreakInput({ ...base, label: " " });
expect(result.valid).toBe(false);
expect(result.issues[0]?.code).toBe("INVALID_INPUT");
});
it("rejects no days selected", () => {
const result = validateBreakInput({ ...base, days: [] });
expect(result.valid).toBe(false);
expect(result.issues[0]?.code).toBe("NO_DAYS_SELECTED");
});
it("rejects start >= end", () => {
const result = validateBreakInput({ ...base, startMinutes: 13 * 60, endMinutes: 13 * 60 });
expect(result.valid).toBe(false);
expect(result.issues[0]?.code).toBe("INVALID_TIME_RANGE");
});
});
describe("validateTimeOffInput", () => {
it("accepts a whole-day range with no times", () => {
const result = validateTimeOffInput({ startDate: "2026-08-10", endDate: "2026-08-12" });
expect(result.valid).toBe(true);
});
it("rejects start date after end date", () => {
const result = validateTimeOffInput({ startDate: "2026-08-12", endDate: "2026-08-10" });
expect(result.valid).toBe(false);
expect(result.issues[0]?.code).toBe("INVALID_INPUT");
});
it("rejects providing only one of startMinutes/endMinutes", () => {
const result = validateTimeOffInput({
startDate: "2026-08-10",
endDate: "2026-08-10",
startMinutes: 600,
});
expect(result.valid).toBe(false);
expect(result.issues[0]?.code).toBe("INVALID_INPUT");
});
it("rejects a partial-day block spanning more than one day", () => {
const result = validateTimeOffInput({
startDate: "2026-08-10",
endDate: "2026-08-11",
startMinutes: 600,
endMinutes: 660,
});
expect(result.valid).toBe(false);
expect(result.issues[0]?.code).toBe("INVALID_INPUT");
});
it("rejects a partial-day block with start >= end", () => {
const result = validateTimeOffInput({
startDate: "2026-08-10",
endDate: "2026-08-10",
startMinutes: 660,
endMinutes: 660,
});
expect(result.valid).toBe(false);
expect(result.issues[0]?.code).toBe("INVALID_TIME_RANGE");
});
it("accepts a valid partial-day block (Block Time)", () => {
const result = validateTimeOffInput({
startDate: "2026-08-10",
endDate: "2026-08-10",
startMinutes: 600,
endMinutes: 660,
});
expect(result.valid).toBe(true);
});
});
describe("validateCreateStaffInput", () => {
const base = {
name: "Owen Clarke",
email: "owen.clarke@blakeheritage.co.uk",
roleId: "role-barber",
serviceIds: [] as string[],
isBookableOnline: true,
};
it("accepts a valid new-staff input", () => {
expect(validateCreateStaffInput(base).valid).toBe(true);
});
it("rejects an empty name", () => {
const result = validateCreateStaffInput({ ...base, name: " " });
expect(result.valid).toBe(false);
expect(result.issues[0]?.code).toBe("INVALID_INPUT");
});
it.each(["not-an-email", "missing-domain@", "@missing-local.com", ""])(
"rejects an invalid email %s",
(email) => {
const result = validateCreateStaffInput({ ...base, email });
expect(result.valid).toBe(false);
expect(result.issues[0]?.code).toBe("INVALID_INPUT");
},
);
it("rejects a missing role", () => {
const result = validateCreateStaffInput({ ...base, roleId: "" });
expect(result.valid).toBe(false);
expect(result.issues[0]?.code).toBe("INVALID_INPUT");
});
});
@@ -0,0 +1,143 @@
import { randomBytes } from "node:crypto";
import bcrypt from "bcryptjs";
import type { PrismaClient } from "@/generated/prisma/client";
import { NotificationType } from "@/generated/prisma/enums";
import { notifySafely } from "@/services/notifications/notification.service";
import { getOwnerUserIds } from "@/services/notifications/recipients";
/**
* The "invite a new staff member" flow, deliberately split out of
* StaffService (see docs/phase-6-summary.md, "Domain services" — the
* brief's own instruction not to mix invitation delivery into StaffService).
* StaffService.createStaffProfile makes the User/StaffProfile usable
* immediately; this file only tracks the separate "has this person set
* their own password yet" signal via StaffInvitation.acceptedAt.
*
* `token` is a plain opaque value (crypto-random, not HMAC-signed like the
* booking-draft cookie, because it's validated against a database row
* rather than needing to be self-contained) — it's still also surfaced to
* the owner as a copy-link (`/invite/accept?token=...`) as a fallback in
* case email delivery is unavailable/unconfigured, but as of Phase 7 a
* real STAFF_INVITATION_RECEIVED email is queued too (see
* NotificationService/templates/staff-emails.ts).
*/
const INVITATION_TTL_DAYS = 7;
export type StaffInvitationErrorCode = "INVALID_TOKEN" | "EXPIRED" | "ALREADY_ACCEPTED" | "INVALID_INPUT";
function generateToken(): string {
return randomBytes(32).toString("base64url");
}
export async function createInvitation(
prisma: PrismaClient,
params: { businessId: string; staffId: string; email: string; invitedByUserId?: string },
) {
const invitation = await prisma.staffInvitation.create({
data: {
businessId: params.businessId,
staffId: params.staffId,
email: params.email,
token: generateToken(),
invitedByUserId: params.invitedByUserId,
expiresAt: new Date(Date.now() + INVITATION_TTL_DAYS * 24 * 60 * 60 * 1000),
},
});
const staff = await prisma.staffProfile.findUnique({
where: { id: params.staffId },
include: { user: { select: { id: true, name: true } }, role: { select: { name: true } }, business: { select: { name: true } } },
});
const invitedBy = params.invitedByUserId
? await prisma.user.findUnique({ where: { id: params.invitedByUserId }, select: { name: true } })
: null;
if (staff) {
await notifySafely(prisma, {
businessId: params.businessId,
type: NotificationType.STAFF_INVITATION_RECEIVED,
recipientUserId: staff.user.id,
subject: `You've been invited to join ${staff.business.name}`,
bodyText: `${invitedBy?.name ?? "The owner"} has added you as ${staff.role.name} at ${staff.business.name}. Set your password to get started.`,
deepLinkPath: `/invite/accept?token=${invitation.token}`,
idempotencyKey: `notify:staff-invitation-received:${invitation.id}`,
});
}
return invitation;
}
/** An expired or already-viewed invitation gets a fresh token/expiry rather than a second row — one StaffInvitation per StaffProfile (see the schema's `staffId @unique`). */
export async function regenerateInvitation(
prisma: PrismaClient,
params: { staffId: string; businessId: string },
) {
const existing = await prisma.staffInvitation.findFirst({
where: { staffId: params.staffId, businessId: params.businessId },
});
if (!existing) return null;
return prisma.staffInvitation.update({
where: { id: existing.id },
data: { token: generateToken(), expiresAt: new Date(Date.now() + INVITATION_TTL_DAYS * 24 * 60 * 60 * 1000), acceptedAt: null },
});
}
export async function getInvitationByToken(prisma: PrismaClient, token: string) {
return prisma.staffInvitation.findUnique({
where: { token },
include: {
staff: { include: { user: { select: { id: true, name: true, email: true } }, business: { select: { name: true } } } },
},
});
}
export type InvitationCheck =
| { valid: true; invitation: NonNullable<Awaited<ReturnType<typeof getInvitationByToken>>> }
| { valid: false; reason: StaffInvitationErrorCode };
export async function validateInvitationToken(prisma: PrismaClient, token: string): Promise<InvitationCheck> {
const invitation = await getInvitationByToken(prisma, token);
if (!invitation) return { valid: false, reason: "INVALID_TOKEN" };
if (invitation.acceptedAt) return { valid: false, reason: "ALREADY_ACCEPTED" };
if (invitation.expiresAt < new Date()) return { valid: false, reason: "EXPIRED" };
return { valid: true, invitation };
}
export async function acceptInvitation(
prisma: PrismaClient,
params: { token: string; password: string },
): Promise<{ success: boolean; reason?: StaffInvitationErrorCode }> {
if (params.password.length < 8) return { success: false, reason: "INVALID_INPUT" };
const check = await validateInvitationToken(prisma, params.token);
if (!check.valid) return { success: false, reason: check.reason };
const passwordHash = await bcrypt.hash(params.password, 12);
await prisma.$transaction([
prisma.user.update({
where: { id: check.invitation.staff.userId },
data: { passwordHash, emailVerified: new Date() },
}),
prisma.staffInvitation.update({
where: { id: check.invitation.id },
data: { acceptedAt: new Date() },
}),
]);
const ownerUserIds = await getOwnerUserIds(prisma, check.invitation.businessId);
for (const ownerUserId of ownerUserIds) {
await notifySafely(prisma, {
businessId: check.invitation.businessId,
type: NotificationType.OWNER_STAFF_INVITATION_ACCEPTED,
recipientUserId: ownerUserId,
subject: "Invitation accepted",
bodyText: `${check.invitation.staff.user.name ?? "A new team member"} has set their password and joined ${check.invitation.staff.business.name}.`,
deepLinkPath: `/owner/team/${check.invitation.staff.id}`,
idempotencyKey: `notify:owner-staff-invitation-accepted:${check.invitation.id}:${ownerUserId}`,
});
}
return { success: true };
}
+32
View File
@@ -0,0 +1,32 @@
import { randomUUID } from "node:crypto";
import type { PrismaClient } from "@/generated/prisma/client";
import { NotificationType } from "@/generated/prisma/enums";
import { notifySafely } from "@/services/notifications/notification.service";
/**
* STAFF_SCHEDULE_CHANGED — fired only when an OWNER changes a staff
* member's working hours/breaks/time off on their behalf (see
* src/features/owner/actions/breaks.ts, time-off.ts, staff.ts). Never
* called from the staff self-service editors — notifying someone about
* their own action would just be noise, and StaffService itself has no
* "who initiated this" context to gate on, since owner-on-behalf and
* self-service both call the exact same StaffService functions by design
* (see docs/phase-6-summary.md, "explicitly NOT creating separate
* owner-only availability logic"). A fresh idempotencyKey every call —
* unlike a booking, there's no natural "one instance" to dedupe against;
* every distinct owner edit is its own notification-worthy event.
*/
export async function notifyStaffScheduleChanged(
prisma: PrismaClient,
params: { businessId: string; staffUserId: string; changedByName: string; whatChanged: string; viewSchedulePath: string },
): Promise<void> {
await notifySafely(prisma, {
businessId: params.businessId,
type: NotificationType.STAFF_SCHEDULE_CHANGED,
recipientUserId: params.staffUserId,
subject: "Your schedule was updated",
bodyText: `${params.changedByName} has updated ${params.whatChanged}.`,
deepLinkPath: params.viewSchedulePath,
idempotencyKey: `notify:staff-schedule-changed:${randomUUID()}`,
});
}
+1000
View File
@@ -0,0 +1,1000 @@
import type { PrismaClient } from "@/generated/prisma/client";
import { Weekday } from "@/generated/prisma/enums";
import {
calendarDateOf,
calendarDateToUtcMidnight,
endOfBusinessDay,
minutesFromMidnight,
startOfBusinessDay,
weekdayOf,
} from "@/lib/time/business-time";
import type { CalendarDate } from "@/lib/time/business-time";
import { fail, ok } from "@/lib/validation-result";
import type { ValidationResult } from "@/lib/validation-result";
/**
* Everything about a staff member's own operational setup: profile fields
* they're allowed to edit themselves, working hours, breaks, and time off
* (including the one-off "Block Time" case — a TimeOff row with minutes
* set, see schema.prisma). Deliberately does NOT touch availability
* calculation or appointment status — those stay in AvailabilityService/
* StaffAvailabilityService and AppointmentService respectively. This
* service only manages the *inputs* those read; it's the write side of
* the same data StaffAvailabilityService reads.
*/
export type StaffServiceErrorCode =
| "INVALID_INPUT"
| "INVALID_TIME_RANGE"
| "NO_DAYS_SELECTED"
| "STAFF_NOT_FOUND"
| "BREAK_NOT_FOUND"
| "TIME_OFF_NOT_FOUND"
| "APPOINTMENT_CONFLICTS"
| "PERMISSION_DENIED"
| "EMAIL_ALREADY_IN_USE"
| "ROLE_NOT_FOUND"
| "LAST_OWNER";
export class StaffPermissionDeniedError extends Error {
constructor(message = "You don't have permission to manage this staff member's schedule.") {
super(message);
this.name = "StaffPermissionDeniedError";
}
}
/** Every mutation here takes the acting staff member's own id and asserts the resource belongs to them — a staff member manages only their own schedule this phase (see docs/phase-5-summary.md, "Permission model"). */
export function assertOwnsResource(resourceStaffId: string, actingStaffId: string): void {
if (resourceStaffId !== actingStaffId) {
throw new StaffPermissionDeniedError();
}
}
// ── Profile ─────────────────────────────────────────────────────────────
export async function getStaffProfileByUserId(prisma: PrismaClient, userId: string) {
return prisma.staffProfile.findUnique({
where: { userId },
include: {
user: { select: { id: true, name: true, email: true, phone: true, image: true } },
services: { include: { service: true } },
business: { select: { id: true, name: true, timezone: true } },
role: { select: { name: true } },
},
});
}
export interface UpdateStaffProfileInput {
staffId: string;
name?: string;
phone?: string;
image?: string;
bio?: string;
/** "Public visibility" in the brief — whether customers can find/book this barber online. Reuses the existing field rather than adding a duplicate flag. */
isBookableOnline?: boolean;
}
/**
* Only the fields the brief explicitly allows a barber to change themselves
* — service eligibility, business settings, and other staff accounts are
* deliberately not accepted here (owner-only, out of scope this phase).
*/
export async function updateStaffProfile(
prisma: PrismaClient,
input: UpdateStaffProfileInput,
): Promise<{ success: boolean }> {
const staff = await prisma.staffProfile.findUnique({
where: { id: input.staffId },
select: { userId: true },
});
if (!staff) return { success: false };
await prisma.$transaction([
prisma.user.update({
where: { id: staff.userId },
data: {
...(input.name !== undefined ? { name: input.name } : {}),
...(input.phone !== undefined ? { phone: input.phone || null } : {}),
...(input.image !== undefined ? { image: input.image || null } : {}),
},
}),
prisma.staffProfile.update({
where: { id: input.staffId },
data: {
...(input.bio !== undefined ? { bio: input.bio || null } : {}),
...(input.isBookableOnline !== undefined
? { isBookableOnline: input.isBookableOnline }
: {}),
},
}),
]);
return { success: true };
}
// ── Working hours ───────────────────────────────────────────────────────
export interface WorkingHoursDayInput {
dayOfWeek: Weekday;
isOff: boolean;
startMinutes?: number;
endMinutes?: number;
}
export function validateWorkingHoursInput(
days: WorkingHoursDayInput[],
): ValidationResult<StaffServiceErrorCode> {
for (const day of days) {
if (day.isOff) continue;
if (day.startMinutes === undefined || day.endMinutes === undefined) {
return fail({
code: "INVALID_INPUT",
message: `${day.dayOfWeek}: start and end time are required for a working day.`,
});
}
if (day.startMinutes < 0 || day.endMinutes > 24 * 60) {
return fail({
code: "INVALID_TIME_RANGE",
message: `${day.dayOfWeek}: times must fall within a single day.`,
});
}
if (day.startMinutes >= day.endMinutes) {
return fail({
code: "INVALID_TIME_RANGE",
message: `${day.dayOfWeek}: start time must be before end time.`,
});
}
}
return ok();
}
export async function getWorkingHours(prisma: PrismaClient, staffId: string) {
return prisma.workingHours.findMany({ where: { staffId }, orderBy: { dayOfWeek: "asc" } });
}
/**
* Future, non-cancelled appointments that would fall outside the proposed
* working hours — either on a day newly marked off, or before/after the
* new start/end window on a day that stays working. Same pattern as
* findBreakConflicts/findTimeOffConflicts/BusinessService's
* findOpeningHoursConflicts (see docs/phase-7-summary.md, "Working-hours
* conflict review"): this never cancels anything itself, only reports what
* the caller should show before asking for acknowledgement.
*/
export async function findWorkingHoursConflicts(
prisma: PrismaClient,
params: { staffId: string; businessTimezone: string; days: WorkingHoursDayInput[] },
) {
const appointments = await prisma.appointment.findMany({
where: {
staffId: params.staffId,
status: { notIn: ["CANCELLED", "NO_SHOW"] },
startAt: { gte: new Date() },
},
include: APPOINTMENT_CONFLICT_INCLUDE,
orderBy: { startAt: "asc" },
});
const byDay = new Map(params.days.map((d) => [d.dayOfWeek, d]));
return appointments.filter((a) => {
const weekday = weekdayOf(calendarDateOf(a.startAt, params.businessTimezone));
const proposed = byDay.get(weekday);
if (!proposed) return false;
if (proposed.isOff) return true;
const aStart = minutesFromMidnight(a.startAt, params.businessTimezone);
const aEnd = minutesFromMidnight(a.endAt, params.businessTimezone);
return aStart < (proposed.startMinutes ?? 0) || aEnd > (proposed.endMinutes ?? 24 * 60);
});
}
export async function setWorkingHours(
prisma: PrismaClient,
params: {
staffId: string;
businessTimezone: string;
days: WorkingHoursDayInput[];
acknowledgeConflicts?: boolean;
},
) {
const validation = validateWorkingHoursInput(params.days);
if (!validation.valid) return { success: false as const, validation, conflicts: [] };
if (!params.acknowledgeConflicts) {
const conflicts = await findWorkingHoursConflicts(prisma, {
staffId: params.staffId,
businessTimezone: params.businessTimezone,
days: params.days,
});
if (conflicts.length > 0) {
return {
success: false as const,
conflicts,
validation: fail({
code: "APPOINTMENT_CONFLICTS",
message: "These hours would leave existing appointments outside your working hours.",
}),
};
}
}
await prisma.$transaction(
params.days.map((day) =>
prisma.workingHours.upsert({
where: { staffId_dayOfWeek: { staffId: params.staffId, dayOfWeek: day.dayOfWeek } },
create: {
staffId: params.staffId,
dayOfWeek: day.dayOfWeek,
isOff: day.isOff,
startMinutes: day.isOff ? null : (day.startMinutes ?? null),
endMinutes: day.isOff ? null : (day.endMinutes ?? null),
},
update: {
isOff: day.isOff,
startMinutes: day.isOff ? null : (day.startMinutes ?? null),
endMinutes: day.isOff ? null : (day.endMinutes ?? null),
},
}),
),
);
return { success: true as const, conflicts: [], validation: ok() };
}
// ── Breaks ──────────────────────────────────────────────────────────────
const APPOINTMENT_CONFLICT_INCLUDE = {
services: { include: { service: true } },
customer: true,
} as const;
export interface BreakInput {
label: string;
startMinutes: number;
endMinutes: number;
days: Weekday[];
}
export function validateBreakInput(input: BreakInput): ValidationResult<StaffServiceErrorCode> {
if (!input.label.trim())
return fail({ code: "INVALID_INPUT", message: "Give this break a label." });
if (input.days.length === 0)
return fail({ code: "NO_DAYS_SELECTED", message: "Select at least one day for this break." });
if (
input.startMinutes < 0 ||
input.endMinutes > 24 * 60 ||
input.startMinutes >= input.endMinutes
) {
return fail({
code: "INVALID_TIME_RANGE",
message: "Break start time must be before its end time.",
});
}
return ok();
}
export async function listBreaks(prisma: PrismaClient, staffId: string) {
return prisma.break.findMany({
where: { staffId },
include: { days: true },
orderBy: { startMinutes: "asc" },
});
}
/** Future, non-cancelled appointments that would fall inside a recurring break on any of `days`. Breaks recur indefinitely, so only future appointments are ever relevant — one from last month isn't a live conflict. */
export async function findBreakConflicts(
prisma: PrismaClient,
params: {
staffId: string;
businessTimezone: string;
days: Weekday[];
startMinutes: number;
endMinutes: number;
excludeBreakId?: string;
},
) {
const appointments = await prisma.appointment.findMany({
where: {
staffId: params.staffId,
status: { notIn: ["CANCELLED", "NO_SHOW"] },
startAt: { gte: new Date() },
},
include: APPOINTMENT_CONFLICT_INCLUDE,
orderBy: { startAt: "asc" },
});
return appointments.filter((a) => {
const weekday = weekdayOf(calendarDateOf(a.startAt, params.businessTimezone));
if (!params.days.includes(weekday)) return false;
const aStart = minutesFromMidnight(a.startAt, params.businessTimezone);
const aEnd = minutesFromMidnight(a.endAt, params.businessTimezone);
return aStart < params.endMinutes && aEnd > params.startMinutes;
});
}
export async function createBreak(
prisma: PrismaClient,
params: {
staffId: string;
businessTimezone: string;
input: BreakInput;
acknowledgeConflicts?: boolean;
},
) {
const validation = validateBreakInput(params.input);
if (!validation.valid) return { success: false as const, validation, conflicts: [] };
if (!params.acknowledgeConflicts) {
const conflicts = await findBreakConflicts(prisma, {
staffId: params.staffId,
businessTimezone: params.businessTimezone,
...params.input,
});
if (conflicts.length > 0) {
return {
success: false as const,
conflicts,
validation: fail({
code: "APPOINTMENT_CONFLICTS",
message: "This break overlaps existing appointments.",
}),
};
}
}
const created = await prisma.break.create({
data: {
staffId: params.staffId,
label: params.input.label,
startMinutes: params.input.startMinutes,
endMinutes: params.input.endMinutes,
days: { create: params.input.days.map((dayOfWeek) => ({ dayOfWeek })) },
},
});
return { success: true as const, break: created, conflicts: [], validation: ok() };
}
export async function updateBreak(
prisma: PrismaClient,
params: {
breakId: string;
staffId: string;
businessTimezone: string;
input: BreakInput;
acknowledgeConflicts?: boolean;
},
) {
const existing = await prisma.break.findUnique({ where: { id: params.breakId } });
if (!existing)
return {
success: false as const,
validation: fail({ code: "BREAK_NOT_FOUND" as const, message: "Break not found." }),
conflicts: [],
};
if (existing.staffId !== params.staffId) {
return {
success: false as const,
validation: fail({ code: "PERMISSION_DENIED" as const, message: "You can only manage your own breaks." }),
conflicts: [],
};
}
const validation = validateBreakInput(params.input);
if (!validation.valid) return { success: false as const, validation, conflicts: [] };
if (!params.acknowledgeConflicts) {
const conflicts = await findBreakConflicts(prisma, {
staffId: params.staffId,
businessTimezone: params.businessTimezone,
...params.input,
excludeBreakId: params.breakId,
});
if (conflicts.length > 0) {
return {
success: false as const,
conflicts,
validation: fail({
code: "APPOINTMENT_CONFLICTS",
message: "This break overlaps existing appointments.",
}),
};
}
}
await prisma.$transaction([
prisma.breakDay.deleteMany({ where: { breakId: params.breakId } }),
prisma.break.update({
where: { id: params.breakId },
data: {
label: params.input.label,
startMinutes: params.input.startMinutes,
endMinutes: params.input.endMinutes,
days: { create: params.input.days.map((dayOfWeek) => ({ dayOfWeek })) },
},
}),
]);
return { success: true as const, conflicts: [], validation: ok() };
}
export async function deleteBreak(
prisma: PrismaClient,
params: { breakId: string; staffId: string },
): Promise<{ success: boolean }> {
const existing = await prisma.break.findUnique({ where: { id: params.breakId } });
if (!existing) return { success: false };
if (existing.staffId !== params.staffId) return { success: false };
await prisma.break.delete({ where: { id: params.breakId } });
return { success: true };
}
// ── Time off (also represents one-off "Block Time" — see schema.prisma) ──
export interface TimeOffInput {
startDate: CalendarDate;
endDate: CalendarDate;
reason?: string;
/** Both set = a partial-day block ("Block Time"); both omitted = whole-day time off. */
startMinutes?: number;
endMinutes?: number;
}
export function validateTimeOffInput(input: TimeOffInput): ValidationResult<StaffServiceErrorCode> {
if (input.startDate > input.endDate) {
return fail({
code: "INVALID_INPUT",
message: "Start date must be on or before the end date.",
});
}
const hasStart = input.startMinutes !== undefined;
const hasEnd = input.endMinutes !== undefined;
if (hasStart !== hasEnd) {
return fail({
code: "INVALID_INPUT",
message:
"Provide both a start and end time for a partial-day block, or neither for a full day.",
});
}
if (hasStart && hasEnd) {
if (input.startDate !== input.endDate) {
return fail({
code: "INVALID_INPUT",
message: "A specific-time block can only cover a single day.",
});
}
if (
input.startMinutes! < 0 ||
input.endMinutes! > 24 * 60 ||
input.startMinutes! >= input.endMinutes!
) {
return fail({ code: "INVALID_TIME_RANGE", message: "Start time must be before end time." });
}
}
return ok();
}
/** Split by calendar date, not business time zone — @db.Date columns are compared as UTC midnight regardless of zone, see business-time.ts. */
export async function listTimeOff(prisma: PrismaClient, staffId: string) {
const rows = await prisma.timeOff.findMany({
where: { staffId },
orderBy: { startDate: "desc" },
});
const today = calendarDateToUtcMidnight(new Date().toISOString().slice(0, 10));
return {
upcoming: rows
.filter((r) => r.endDate >= today)
.sort((a, b) => a.startDate.getTime() - b.startDate.getTime()),
past: rows.filter((r) => r.endDate < today),
};
}
/** Time off is only ever checked against appointments, never against other time off — there is no "excludeTimeOffId" case because two time-off rows can't conflict with each other. */
export async function findTimeOffConflicts(
prisma: PrismaClient,
params: {
staffId: string;
businessTimezone: string;
startDate: CalendarDate;
endDate: CalendarDate;
startMinutes?: number;
endMinutes?: number;
},
) {
const rangeStart = startOfBusinessDay(params.startDate, params.businessTimezone);
const rangeEnd = endOfBusinessDay(params.endDate, params.businessTimezone);
const appointments = await prisma.appointment.findMany({
where: {
staffId: params.staffId,
status: { notIn: ["CANCELLED", "NO_SHOW"] },
startAt: { lt: rangeEnd },
endAt: { gt: rangeStart },
},
include: APPOINTMENT_CONFLICT_INCLUDE,
orderBy: { startAt: "asc" },
});
if (params.startMinutes === undefined || params.endMinutes === undefined) return appointments;
return appointments.filter((a) => {
const aStart = minutesFromMidnight(a.startAt, params.businessTimezone);
const aEnd = minutesFromMidnight(a.endAt, params.businessTimezone);
return aStart < params.endMinutes! && aEnd > params.startMinutes!;
});
}
export async function createTimeOff(
prisma: PrismaClient,
params: {
staffId: string;
businessTimezone: string;
input: TimeOffInput;
acknowledgeConflicts?: boolean;
},
) {
const validation = validateTimeOffInput(params.input);
if (!validation.valid) return { success: false as const, validation, conflicts: [] };
if (!params.acknowledgeConflicts) {
const conflicts = await findTimeOffConflicts(prisma, {
staffId: params.staffId,
businessTimezone: params.businessTimezone,
...params.input,
});
if (conflicts.length > 0) {
return {
success: false as const,
conflicts,
validation: fail({
code: "APPOINTMENT_CONFLICTS",
message: "This time off overlaps existing appointments.",
}),
};
}
}
const created = await prisma.timeOff.create({
data: {
staffId: params.staffId,
startDate: calendarDateToUtcMidnight(params.input.startDate),
endDate: calendarDateToUtcMidnight(params.input.endDate),
reason: params.input.reason,
startMinutes: params.input.startMinutes ?? null,
endMinutes: params.input.endMinutes ?? null,
status: "APPROVED",
},
});
return { success: true as const, timeOff: created, conflicts: [], validation: ok() };
}
export async function updateTimeOff(
prisma: PrismaClient,
params: {
timeOffId: string;
staffId: string;
businessTimezone: string;
input: TimeOffInput;
acknowledgeConflicts?: boolean;
},
) {
const existing = await prisma.timeOff.findUnique({ where: { id: params.timeOffId } });
if (!existing)
return {
success: false as const,
validation: fail({ code: "TIME_OFF_NOT_FOUND" as const, message: "Time off not found." }),
conflicts: [],
};
if (existing.staffId !== params.staffId) {
return {
success: false as const,
validation: fail({ code: "PERMISSION_DENIED" as const, message: "You can only manage your own time off." }),
conflicts: [],
};
}
const validation = validateTimeOffInput(params.input);
if (!validation.valid) return { success: false as const, validation, conflicts: [] };
if (!params.acknowledgeConflicts) {
const conflicts = await findTimeOffConflicts(prisma, {
staffId: params.staffId,
businessTimezone: params.businessTimezone,
...params.input,
});
if (conflicts.length > 0) {
return {
success: false as const,
conflicts,
validation: fail({
code: "APPOINTMENT_CONFLICTS",
message: "This time off overlaps existing appointments.",
}),
};
}
}
await prisma.timeOff.update({
where: { id: params.timeOffId },
data: {
startDate: calendarDateToUtcMidnight(params.input.startDate),
endDate: calendarDateToUtcMidnight(params.input.endDate),
reason: params.input.reason,
startMinutes: params.input.startMinutes ?? null,
endMinutes: params.input.endMinutes ?? null,
},
});
return { success: true as const, conflicts: [], validation: ok() };
}
export async function deleteTimeOff(
prisma: PrismaClient,
params: { timeOffId: string; staffId: string },
): Promise<{ success: boolean }> {
const existing = await prisma.timeOff.findUnique({ where: { id: params.timeOffId } });
if (!existing) return { success: false };
if (existing.staffId !== params.staffId) return { success: false };
// Only future time off is editable/removable — see the brief's "Edit or remove future time off".
await prisma.timeOff.delete({ where: { id: params.timeOffId } });
return { success: true };
}
// ── Owner: team management ─────────────────────────────────────────────
// Everything below acts on staff *other than* the caller — the owner's own
// business-membership must already be verified by the caller (see
// src/features/owner/authorization.ts); every function here additionally
// re-scopes by businessId itself rather than trusting the caller alone.
export async function listStaffForBusiness(
prisma: PrismaClient,
params: { businessId: string; q?: string; includeInactive?: boolean },
) {
return prisma.staffProfile.findMany({
where: {
businessId: params.businessId,
...(params.includeInactive ? {} : { status: { not: "INACTIVE" } }),
// No `mode: "insensitive"` — that's a Postgres-only Prisma option (removed in the
// Phase 7 MySQL migration). MySQL's default collation (utf8mb4_0900_ai_ci — "ai" =
// accent-insensitive, "ci" = case-insensitive) already makes `contains` case-insensitive.
...(params.q
? {
user: {
OR: [{ name: { contains: params.q } }, { email: { contains: params.q } }],
},
}
: {}),
},
include: {
user: { select: { id: true, name: true, email: true, phone: true, image: true } },
role: { select: { id: true, name: true } },
invitation: true,
},
orderBy: { createdAt: "asc" },
});
}
export async function getStaffDetailById(prisma: PrismaClient, params: { staffId: string; businessId: string }) {
return prisma.staffProfile.findFirst({
where: { id: params.staffId, businessId: params.businessId },
include: {
user: { select: { id: true, name: true, email: true, phone: true, image: true } },
role: { include: { permissions: true } },
services: { include: { service: true } },
invitation: true,
},
});
}
export async function listRolesForBusiness(prisma: PrismaClient, businessId: string) {
return prisma.role.findMany({
where: { businessId },
include: { permissions: true },
orderBy: { name: "asc" },
});
}
/** A role holding MANAGE_STAFF is this app's definition of "an owner" — see docs/phase-6-summary.md, "Roles and permissions". */
async function countOtherActiveManagers(
prisma: PrismaClient,
params: { businessId: string; excludeStaffId: string },
) {
return prisma.staffProfile.count({
where: {
businessId: params.businessId,
status: "ACTIVE",
id: { not: params.excludeStaffId },
role: { permissions: { some: { permission: "MANAGE_STAFF" } } },
},
});
}
async function staffCurrentlyManagesStaff(prisma: PrismaClient, staffId: string): Promise<boolean> {
const staff = await prisma.staffProfile.findUnique({
where: { id: staffId },
select: { status: true, role: { select: { permissions: { select: { permission: true } } } } },
});
return (
!!staff &&
staff.status === "ACTIVE" &&
staff.role.permissions.some((p) => p.permission === "MANAGE_STAFF")
);
}
export interface CreateStaffInput {
name: string;
email: string;
phone?: string;
image?: string;
bio?: string;
roleId: string;
serviceIds: string[];
isBookableOnline: boolean;
}
export function validateCreateStaffInput(input: CreateStaffInput): ValidationResult<StaffServiceErrorCode> {
if (!input.name.trim()) return fail({ code: "INVALID_INPUT", message: "Name is required." });
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(input.email)) {
return fail({ code: "INVALID_INPUT", message: "Enter a valid email address." });
}
if (!input.roleId) return fail({ code: "INVALID_INPUT", message: "Choose a role." });
return ok();
}
/** Creates the User + StaffProfile + initial service eligibility immediately — the profile is fully usable right away. Working hours default to "off every day" until the owner (or the staff member, once they've accepted) sets them; StaffInvitationService is what tracks acceptance, deliberately kept separate — see that file. */
export async function createStaffProfile(
prisma: PrismaClient,
params: { businessId: string; input: CreateStaffInput },
) {
const validation = validateCreateStaffInput(params.input);
if (!validation.valid) return { success: false as const, validation };
const [role, existingUser] = await Promise.all([
prisma.role.findFirst({ where: { id: params.input.roleId, businessId: params.businessId } }),
prisma.user.findUnique({ where: { email: params.input.email.toLowerCase() } }),
]);
if (!role) {
return { success: false as const, validation: fail({ code: "ROLE_NOT_FOUND", message: "Role not found." }) };
}
if (existingUser) {
return {
success: false as const,
validation: fail({ code: "EMAIL_ALREADY_IN_USE", message: "That email is already in use." }),
};
}
const user = await prisma.user.create({
data: {
name: params.input.name.trim(),
email: params.input.email.toLowerCase(),
phone: params.input.phone || null,
image: params.input.image || null,
staffProfile: {
create: {
businessId: params.businessId,
roleId: params.input.roleId,
bio: params.input.bio?.trim() || null,
isBookableOnline: params.input.isBookableOnline,
services: { create: params.input.serviceIds.map((serviceId) => ({ serviceId })) },
},
},
},
include: { staffProfile: true },
});
return { success: true as const, staff: user.staffProfile!, validation: ok() };
}
export interface UpdateStaffAsOwnerInput {
name?: string;
phone?: string;
image?: string;
bio?: string;
isBookableOnline?: boolean;
roleId?: string;
}
export async function updateStaffAsOwner(
prisma: PrismaClient,
params: { staffId: string; businessId: string; input: UpdateStaffAsOwnerInput },
) {
const existing = await prisma.staffProfile.findFirst({
where: { id: params.staffId, businessId: params.businessId },
});
if (!existing) {
return { success: false as const, validation: fail({ code: "STAFF_NOT_FOUND", message: "Staff member not found." }) };
}
if (params.input.roleId && params.input.roleId !== existing.roleId) {
const role = await prisma.role.findFirst({ where: { id: params.input.roleId, businessId: params.businessId } });
if (!role) {
return { success: false as const, validation: fail({ code: "ROLE_NOT_FOUND", message: "Role not found." }) };
}
const wasManager = await staffCurrentlyManagesStaff(prisma, params.staffId);
const stillManagesAfter = !!(await prisma.rolePermission.findFirst({
where: { roleId: role.id, permission: "MANAGE_STAFF" },
}));
if (wasManager && !stillManagesAfter) {
const otherManagers = await countOtherActiveManagers(prisma, {
businessId: params.businessId,
excludeStaffId: params.staffId,
});
if (otherManagers === 0) {
return {
success: false as const,
validation: fail({
code: "LAST_OWNER",
message: "This is the only active owner — assign another owner first.",
}),
};
}
}
}
await prisma.$transaction([
prisma.user.update({
where: { id: existing.userId },
data: {
...(params.input.name !== undefined ? { name: params.input.name } : {}),
...(params.input.phone !== undefined ? { phone: params.input.phone || null } : {}),
...(params.input.image !== undefined ? { image: params.input.image || null } : {}),
},
}),
prisma.staffProfile.update({
where: { id: params.staffId },
data: {
...(params.input.bio !== undefined ? { bio: params.input.bio || null } : {}),
...(params.input.isBookableOnline !== undefined
? { isBookableOnline: params.input.isBookableOnline }
: {}),
...(params.input.roleId !== undefined ? { roleId: params.input.roleId } : {}),
},
}),
]);
return { success: true as const, validation: ok() };
}
/** Future, non-cancelled appointments still assigned to this staff member — shown for acknowledgement before deactivating, never auto-resolved. Deactivating does not touch these rows; it only removes the staff member from future booking/availability. */
export async function findStaffDeactivationConflicts(
prisma: PrismaClient,
params: { staffId: string },
) {
return prisma.appointment.findMany({
where: {
staffId: params.staffId,
status: { notIn: ["CANCELLED", "NO_SHOW", "COMPLETED"] },
startAt: { gte: new Date() },
},
include: { customer: true, services: { include: { service: true } } },
orderBy: { startAt: "asc" },
});
}
export async function deactivateStaff(
prisma: PrismaClient,
params: { staffId: string; businessId: string; acknowledgeConflicts?: boolean },
) {
const existing = await prisma.staffProfile.findFirst({
where: { id: params.staffId, businessId: params.businessId },
});
if (!existing) {
return {
success: false as const,
validation: fail({ code: "STAFF_NOT_FOUND", message: "Staff member not found." }),
conflicts: [],
};
}
const wasManager = await staffCurrentlyManagesStaff(prisma, params.staffId);
if (wasManager) {
const otherManagers = await countOtherActiveManagers(prisma, {
businessId: params.businessId,
excludeStaffId: params.staffId,
});
if (otherManagers === 0) {
return {
success: false as const,
validation: fail({
code: "LAST_OWNER",
message: "This is the only active owner — assign another owner first.",
}),
conflicts: [],
};
}
}
if (!params.acknowledgeConflicts) {
const conflicts = await findStaffDeactivationConflicts(prisma, { staffId: params.staffId });
if (conflicts.length > 0) {
return {
success: false as const,
conflicts,
validation: fail({
code: "APPOINTMENT_CONFLICTS",
message: "This staff member still has upcoming appointments.",
}),
};
}
}
await prisma.staffProfile.update({ where: { id: params.staffId }, data: { status: "INACTIVE" } });
return { success: true as const, conflicts: [], validation: ok() };
}
export async function reactivateStaff(
prisma: PrismaClient,
params: { staffId: string; businessId: string },
): Promise<{ success: boolean }> {
const result = await prisma.staffProfile.updateMany({
where: { id: params.staffId, businessId: params.businessId },
data: { status: "ACTIVE" },
});
return { success: result.count > 0 };
}
/** Future, non-cancelled appointments this staff member has that book a service being *removed* from their eligible list — the only ones that matter, since keeping or adding eligibility can never orphan an existing booking. */
export async function findServiceEligibilityConflicts(
prisma: PrismaClient,
params: { staffId: string; removedServiceIds: string[] },
) {
if (params.removedServiceIds.length === 0) return [];
return prisma.appointment.findMany({
where: {
staffId: params.staffId,
status: { notIn: ["CANCELLED", "NO_SHOW", "COMPLETED"] },
startAt: { gte: new Date() },
services: { some: { serviceId: { in: params.removedServiceIds } } },
},
include: { customer: true, services: { include: { service: true } } },
orderBy: { startAt: "asc" },
});
}
export async function setStaffServiceEligibility(
prisma: PrismaClient,
params: { staffId: string; businessId: string; serviceIds: string[]; acknowledgeConflicts?: boolean },
) {
const existing = await prisma.staffProfile.findFirst({
where: { id: params.staffId, businessId: params.businessId },
include: { services: true },
});
if (!existing) {
return {
success: false as const,
validation: fail({ code: "STAFF_NOT_FOUND", message: "Staff member not found." }),
conflicts: [],
};
}
const currentServiceIds = existing.services.map((s) => s.serviceId);
const removedServiceIds = currentServiceIds.filter((id) => !params.serviceIds.includes(id));
if (!params.acknowledgeConflicts) {
const conflicts = await findServiceEligibilityConflicts(prisma, {
staffId: params.staffId,
removedServiceIds,
});
if (conflicts.length > 0) {
return {
success: false as const,
conflicts,
validation: fail({
code: "APPOINTMENT_CONFLICTS",
message: "This staff member has upcoming appointments for a service you're removing.",
}),
};
}
}
await prisma.$transaction([
prisma.staffService.deleteMany({ where: { staffId: params.staffId } }),
prisma.staffService.createMany({
data: params.serviceIds.map((serviceId) => ({ staffId: params.staffId, serviceId })),
}),
]);
return { success: true as const, conflicts: [], validation: ok() };
}