Phase 6
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:
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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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() };
|
||||
}
|
||||
Reference in new issue
Block a user