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);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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}`,
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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" },
|
||||
});
|
||||
}
|
||||
@@ -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}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
"<script>alert("x & y's")</script>",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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 & 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("<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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
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),
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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() };
|
||||
}
|
||||
@@ -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 },
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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) },
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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()}`,
|
||||
});
|
||||
}
|
||||
@@ -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() };
|
||||
}
|
||||
Reference in new issue
Block a user