Owner Administration is complete. Summary of this final stretch of work:

Seed data extended with an inactive staff member, a pending invitation, a redeemed-reward fixture, a service with no eligible staff, and a future-appointment fixture for conflict testing — all idempotent, verified against the dev DB.

Testing brought to 179 unit / 86 integration / 27 E2E tests, all passing together on a freshly seeded, disposable Postgres:

Unit tests for every new pure validator (business hours, categories/services, booking rules, loyalty config, staff creation).
Integration tests covering opening-hours conflicts, last-owner protection, staff deactivation/eligibility conflicts, loyalty redemption's atomic race-guard, and owner-vs-barber/business-isolation authorization — all against a real Postgres.
12 new Playwright owner journeys, which surfaced and fixed two genuine issues along the way: a cookie-clearing race condition in the sign-out-mid-test flow (a real, reproducible timing bug, not just a test artifact), and confirmation that seed fixtures upserted by deterministic ID go stale once "today" has moved on — now handled defensively.
Desktop responsiveness: converted the single-column Business Details, Booking Rules, and Loyalty Configuration forms to a genuine two-column layout at the lg: breakpoint (verified via measured element positions, not just screenshots), confirmed mobile stacking is unaffected.

Final verification: clean tsc, eslint, and next build, plus the full three-suite run against one fresh database as the authoritative pass.
This commit is contained in:
Stefan Lazic committed 2026-08-03 12:56:55 +01:00
1 parent ffee9d6754
commit ec5583f972
73 files changed
+8400 -53

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);
});
});
+259
View File
@@ -0,0 +1,259 @@
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";
/**
* 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;
logoUrl?: string;
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;
}
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 };
}
// ── 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,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,322 @@
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;
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 } : {}),
...(params.q ? { name: { contains: params.q, mode: "insensitive" } } : {}),
},
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,
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,
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,65 @@
import { describe, expect, it } from "vitest";
import { validateProgrammeConfig } from "../loyalty.service";
import type { ProgrammeConfigInput } from "../loyalty.service";
const base: ProgrammeConfigInput = {
isEnabled: true,
stampsRequired: 10,
rewardType: "PERCENTAGE",
rewardValue: "50",
autoApplyReward: true,
requiresStaffApproval: false,
earnOnCancelled: false,
earnOnNoShow: false,
};
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");
});
});
@@ -0,0 +1,248 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
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,
rewardType: "PERCENTAGE",
rewardValue: "25",
autoApplyReward: true,
requiresStaffApproval: false,
earnOnCancelled: false,
earnOnNoShow: false,
},
});
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: 12,
rewardType: "PERCENTAGE",
rewardValue: "40",
autoApplyReward: true,
requiresStaffApproval: false,
earnOnCancelled: false,
earnOnNoShow: false,
},
});
expect(second.success).toBe(true);
const updated = await prisma.loyaltyProgramme.findUniqueOrThrow({ where: { businessId: business.businessId } });
expect(updated.stampsRequired).toBe(12);
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,
rewardType: "FREE_SERVICE",
freeServiceId: inactiveService.id,
autoApplyReward: true,
requiresStaffApproval: false,
earnOnCancelled: false,
earnOnNoShow: false,
},
});
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 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");
});
});
});
+295 -6
View File
@@ -9,11 +9,11 @@ import type { ValidationResult } from "@/lib/validation-result";
* without creating the matching transaction in the same call, and nothing
* outside this file should touch either table directly.
*
* Reward reset-on-redemption (see LoyaltyReward's own schema comment) is
* deliberately not implemented here — redeeming a reward isn't one of the
* Phase 5 barber capabilities, so `currentStamps` is allowed to sit at or
* above `stampsRequired` once a reward becomes available. Redemption is a
* documented Phase 5 limitation, not an oversight.
* 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 =
@@ -24,7 +24,15 @@ export type LoyaltyErrorCode =
| "REASON_REQUIRED"
| "INVALID_AMOUNT"
| "APPOINTMENT_NOT_FOUND"
| "APPOINTMENT_NOT_COMPLETED";
| "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;
@@ -451,3 +459,284 @@ export async function getLoyaltyHistory(
},
});
}
// ── Owner: programme configuration ─────────────────────────────────────
export interface ProgrammeConfigInput {
isEnabled: boolean;
stampsRequired: number;
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;
}
/** 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) {
return fail({ code: "INVALID_INPUT", message: "Stamps required must be at least 1." });
}
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,
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,
};
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." }),
};
}
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);
const stampsRequired = reward.loyaltyAccount.loyaltyProgramme.stampsRequired;
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(stampsRequired, 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." }),
};
}
return { success: true, validation: ok(), discountAmount: discountAmountString };
}
@@ -0,0 +1,272 @@
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,
rewardType: loyaltyProgramme.rewardType,
rewardValue: loyaltyProgramme.rewardValue?.toString(),
freeServiceId: loyaltyProgramme.freeServiceId ?? undefined,
autoApplyReward: loyaltyProgramme.autoApplyReward,
requiresStaffApproval: loyaltyProgramme.requiresStaffApproval,
earnOnCancelled: loyaltyProgramme.earnOnCancelled,
earnOnNoShow: loyaltyProgramme.earnOnNoShow,
});
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 [];
const warnings: ConfigWarning[] = [];
for (const timeOff of upcomingTimeOff) {
const conflictCount = await prisma.appointment.count({
where: {
staffId: timeOff.staffId,
status: { notIn: ["CANCELLED", "NO_SHOW", "COMPLETED"] },
startAt: { lt: timeOff.endDate, gte: timeOff.startDate },
},
});
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,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,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,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);
});
});
});
@@ -4,6 +4,7 @@ import {
StaffPermissionDeniedError,
assertOwnsResource,
validateBreakInput,
validateCreateStaffInput,
validateTimeOffInput,
validateWorkingHoursInput,
} from "../staff.service";
@@ -135,3 +136,38 @@ describe("validateTimeOffInput", () => {
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,103 @@
import { randomBytes } from "node:crypto";
import bcrypt from "bcryptjs";
import type { PrismaClient } from "@/generated/prisma/client";
/**
* 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.
*
* No real email delivery this phase — `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) surfaced to the owner as a copy-link
* (`/invite/accept?token=...`) to send however they like. This is a
* documented development-safe shortcut, not a production email system.
*/
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 },
) {
return 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),
},
});
}
/** 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() },
}),
]);
return { success: true };
}
+361 -1
View File
@@ -31,7 +31,10 @@ export type StaffServiceErrorCode =
| "BREAK_NOT_FOUND"
| "TIME_OFF_NOT_FOUND"
| "APPOINTMENT_CONFLICTS"
| "PERMISSION_DENIED";
| "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.") {
@@ -625,3 +628,360 @@ export async function setNotificationPreferences(
);
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" } }),
...(params.q
? {
user: {
OR: [
{ name: { contains: params.q, mode: "insensitive" } },
{ email: { contains: params.q, mode: "insensitive" } },
],
},
}
: {}),
},
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() };
}