Phase 5 is complete. All 53 tasks are done, verified on a fresh database in a single clean run: **121 unit tests**, **55 integration tests**, and **15 E2E journeys** (6 Phase 4 + 9 new Phase 5) all passing, plus a clean `tsc`/`eslint`/`build`.

Summary of this final stretch:

- **Fixed a real bug**: `StaffService`'s break/time-off update/delete functions threw uncaught instead of failing gracefully on a permission mismatch — fixed with proper `PERMISSION_DENIED` handling, covered by new integration tests.
- **Built the remaining Phase 5 UI**: Breaks editor, Time Off editor, Barber Profile, and Notification Preferences — all wired to real services, no placeholders.
- **Extended seed data**: more customers, loyalty accounts/transactions/rewards, and appointments spanning every status today (anchored to real time so the "Current Appointment" priority logic is reliably exercised).
- **Found and fixed a second real bug live in the browser**: the Break/Time-Off edit sheets showed stale default values instead of the item being edited (a classic `useState`-doesn't-reinitialize-on-prop-change bug) — fixed with a `key` prop to force remount.
- **Wrote the full Phase 5 test suite**: unit tests for `selectCurrentAppointment` and all `StaffService` validators, integration tests for `LoyaltyService` and `StaffService` (including the permission-fix regression tests), and 9 new Playwright E2E journeys covering the brief's exact required list.
- **Did the desktop-responsive pass**: a persistent sidebar nav replacing the bottom bar at `lg:`, a genuine calendar+"Right Now" side-by-side split, and 2-column layouts for the clients list and customer details — addressing the brief's explicit "more than simple centering" requirement.
- **Wrote [docs/phase-5-summary.md](docs/phase-5-summary.md)** documenting everything above in full.

Nothing is committed — the whole session (Phases 2–5) sits uncommitted on top of the initial template commit, same as you left it. Let me know if you'd like me to commit, or if you want to move on to Phase 6.
This commit is contained in:
Stefan Lazic committed 2026-08-02 23:44:18 +01:00
1 parent 6ae145d187
commit ffee9d6754
81 files changed
+8024 -113

No files matched your search

+33 -2
View File
@@ -69,7 +69,10 @@ export async function resolveCustomerForBooking(
throw new Error("resolveCustomerForBooking: either userId or guest details are required.");
}
async function resolveAuthenticatedCustomer(prisma: PrismaClient, userId: string): Promise<ResolvedCustomer> {
async function resolveAuthenticatedCustomer(
prisma: PrismaClient,
userId: string,
): Promise<ResolvedCustomer> {
const existing = await prisma.customerProfile.findUnique({ where: { userId } });
if (existing) return { customerId: existing.id, isNewProfile: false };
@@ -89,7 +92,10 @@ async function resolveAuthenticatedCustomer(prisma: PrismaClient, userId: string
return { customerId: created.id, isNewProfile: true };
}
async function resolveGuestCustomer(prisma: PrismaClient, guest: GuestDetails): Promise<ResolvedCustomer> {
async function resolveGuestCustomer(
prisma: PrismaClient,
guest: GuestDetails,
): Promise<ResolvedCustomer> {
// Case-insensitive email matching without Prisma's `mode: "insensitive"`
// filter — that option is Postgres/MongoDB-only, not MySQL, which would
// break this query silently (or fail) after a provider swap. Normalizing
@@ -118,3 +124,28 @@ async function resolveGuestCustomer(prisma: PrismaClient, guest: GuestDetails):
});
return { customerId: created.id, isNewProfile: true };
}
/**
* Staff-directory search (src/app/(staff)/staff/clients). Deliberately a
* plain in-memory filter, not a Prisma `contains`/`mode: "insensitive"`
* query — that Prisma option is Postgres/Mongo-only (same portability
* reasoning as resolveGuestCustomer's email matching above), and a
* single-business shop's customer list is small enough that filtering
* application-side is simpler than a portable case-insensitive SQL query.
*/
export function normalizeSearchQuery(query: string): string {
return query.trim().toLowerCase();
}
export function matchesCustomerSearch(
customer: { firstName: string; lastName: string; email: string; phone: string | null },
query: string,
): boolean {
const normalized = normalizeSearchQuery(query);
if (!normalized) return true;
return (
`${customer.firstName} ${customer.lastName}`.toLowerCase().includes(normalized) ||
customer.email.toLowerCase().includes(normalized) ||
(customer.phone ?? "").toLowerCase().includes(normalized)
);
}
@@ -0,0 +1,238 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { RewardType } from "@/generated/prisma/enums";
import {
addStamp,
awardStampForCompletedAppointment,
correctStamp,
getLoyaltyHistory,
getLoyaltySummary,
} from "../loyalty.service";
import {
cleanupTestBusiness,
createTestBusiness,
createTestCustomer,
createTestStaff,
prisma,
} from "@/services/scheduling/__tests__/fixtures";
import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures";
/**
* Covers Phase 5's ledger-discipline requirements against a real database:
* no duplicate auto-stamp per completed appointment, no reward stacking,
* eligibility rules, and the correction path's clamp + required reason.
*/
describe("LoyaltyService (integration)", () => {
let business: TestBusiness;
async function enableLoyalty(overrides: { stampsRequired?: number } = {}) {
return prisma.loyaltyProgramme.create({
data: {
businessId: business.businessId,
isEnabled: true,
stampsRequired: overrides.stampsRequired ?? 5,
rewardType: RewardType.PERCENTAGE,
rewardValue: 50,
rewardLabel: "50% Off",
},
});
}
async function completedAppointment(staffId: string, customerId: string) {
return prisma.appointment.create({
data: {
businessId: business.businessId,
staffId,
customerId,
status: "COMPLETED",
startAt: new Date(),
endAt: new Date(Date.now() + 30 * 60_000),
services: {
create: [
{
serviceId: business.haircutServiceId,
priceAtBooking: 20,
durationAtBookingMinutes: 30,
},
],
},
},
});
}
beforeEach(async () => {
business = await createTestBusiness();
});
afterEach(async () => {
await prisma.loyaltyTransaction.deleteMany({
where: { loyaltyAccount: { loyaltyProgramme: { businessId: business.businessId } } },
}).catch(() => {});
await prisma.loyaltyReward.deleteMany({
where: { loyaltyAccount: { loyaltyProgramme: { businessId: business.businessId } } },
}).catch(() => {});
await prisma.loyaltyAccount.deleteMany({
where: { loyaltyProgramme: { businessId: business.businessId } },
}).catch(() => {});
await cleanupTestBusiness(business);
});
it("awards exactly one stamp for a completed appointment, even if called twice", async () => {
await enableLoyalty();
const { staffId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
const appointment = await completedAppointment(staffId, customerId);
const first = await awardStampForCompletedAppointment(prisma, {
appointmentId: appointment.id,
staffId,
});
expect(first.success).toBe(true);
expect(first.alreadyAwarded).toBeUndefined();
const second = await awardStampForCompletedAppointment(prisma, {
appointmentId: appointment.id,
staffId,
});
expect(second.success).toBe(true);
expect(second.alreadyAwarded).toBe(true);
const summary = await getLoyaltySummary(prisma, { customerId, businessId: business.businessId });
expect(summary?.currentStamps).toBe(1);
});
it("refuses to award a stamp for a non-completed appointment", async () => {
await enableLoyalty();
const { staffId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
const appointment = await prisma.appointment.create({
data: {
businessId: business.businessId,
staffId,
customerId,
status: "CONFIRMED",
startAt: new Date(),
endAt: new Date(Date.now() + 30 * 60_000),
services: {
create: [{ serviceId: business.haircutServiceId, priceAtBooking: 20, durationAtBookingMinutes: 30 }],
},
},
});
const result = await awardStampForCompletedAppointment(prisma, { appointmentId: appointment.id, staffId });
expect(result.success).toBe(false);
expect(result.validation.issues[0]?.code).toBe("APPOINTMENT_NOT_COMPLETED");
});
it("creates exactly one available reward when the threshold is crossed, never stacking a second", async () => {
const programme = await enableLoyalty({ stampsRequired: 3 });
const { staffId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
const firstAdd = await addStamp(prisma, { customerId, businessId: business.businessId, amount: 3, staffId });
expect(firstAdd.rewardCreated).toBe(true);
const secondAdd = await addStamp(prisma, { customerId, businessId: business.businessId, amount: 2, staffId });
expect(secondAdd.rewardCreated).toBe(false); // already has an AVAILABLE reward — no stacking
const account = await prisma.loyaltyAccount.findUniqueOrThrow({
where: { customerId_loyaltyProgrammeId: { customerId, loyaltyProgrammeId: programme.id } },
});
const availableRewards = await prisma.loyaltyReward.count({
where: { loyaltyAccountId: account.id, status: "AVAILABLE" },
});
expect(availableRewards).toBe(1);
expect(account.currentStamps).toBe(5);
});
it("rejects a manual stamp when loyalty is disabled for the business", async () => {
// No enableLoyalty() call — business has no loyalty programme at all.
const { staffId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
const result = await addStamp(prisma, { customerId, businessId: business.businessId, amount: 1, staffId });
expect(result.success).toBe(false);
expect(result.validation.issues[0]?.code).toBe("LOYALTY_DISABLED");
});
describe("eligibility", () => {
it("skips the stamp when the appointment's staff member isn't in an eligible-staff list", async () => {
const programme = await enableLoyalty();
const { staffId: eligibleStaffId } = await createTestStaff(business);
const { staffId: ineligibleStaffId } = await createTestStaff(business);
await prisma.loyaltyEligibleStaff.create({
data: { loyaltyProgrammeId: programme.id, staffId: eligibleStaffId },
});
const { customerId } = await createTestCustomer(business);
const appointment = await completedAppointment(ineligibleStaffId, customerId);
const result = await awardStampForCompletedAppointment(prisma, {
appointmentId: appointment.id,
staffId: ineligibleStaffId,
});
expect(result.success).toBe(false);
expect(result.validation.issues[0]?.code).toBe("NOT_ELIGIBLE_STAFF");
});
it("skips the stamp when none of the appointment's services are eligible", async () => {
const programme = await enableLoyalty();
const { staffId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
// Only the beard trim is eligible; the fixture appointment books a haircut.
await prisma.loyaltyEligibleService.create({
data: { loyaltyProgrammeId: programme.id, serviceId: business.beardTrimServiceId },
});
const appointment = await completedAppointment(staffId, customerId);
const result = await awardStampForCompletedAppointment(prisma, { appointmentId: appointment.id, staffId });
expect(result.success).toBe(false);
expect(result.validation.issues[0]?.code).toBe("NOT_ELIGIBLE_SERVICE");
});
});
describe("correctStamp", () => {
it("requires a reason", async () => {
const programme = await enableLoyalty();
const { staffId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
await addStamp(prisma, { customerId, businessId: business.businessId, amount: 2, staffId });
const account = await prisma.loyaltyAccount.findUniqueOrThrow({
where: { customerId_loyaltyProgrammeId: { customerId, loyaltyProgrammeId: programme.id } },
});
const result = await correctStamp(prisma, {
loyaltyAccountId: account.id,
amount: 1,
staffId,
reason: " ",
});
expect(result.success).toBe(false);
expect(result.validation.issues[0]?.code).toBe("REASON_REQUIRED");
});
it("clamps the removal at the current balance instead of going negative", async () => {
const programme = await enableLoyalty();
const { staffId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
await addStamp(prisma, { customerId, businessId: business.businessId, amount: 2, staffId });
const account = await prisma.loyaltyAccount.findUniqueOrThrow({
where: { customerId_loyaltyProgrammeId: { customerId, loyaltyProgrammeId: programme.id } },
});
const result = await correctStamp(prisma, {
loyaltyAccountId: account.id,
amount: 10, // more than the current balance of 2
staffId,
reason: "Accidental double stamp",
});
expect(result.success).toBe(true);
const updated = await prisma.loyaltyAccount.findUniqueOrThrow({ where: { id: account.id } });
expect(updated.currentStamps).toBe(0);
const history = await getLoyaltyHistory(prisma, { loyaltyAccountId: account.id });
const correction = history.find((t) => t.type === "STAMP_REMOVED");
expect(correction?.amount).toBe(-2); // ledger records what actually happened, not the requested 10
expect(correction?.reason).toBe("Accidental double stamp");
});
});
});
+453
View File
@@ -0,0 +1,453 @@
import type { LoyaltyProgramme, PrismaClient } from "@/generated/prisma/client";
import { fail, ok } from "@/lib/validation-result";
import type { ValidationResult } from "@/lib/validation-result";
/**
* Ledger-based loyalty, per the brief: the cached `LoyaltyAccount.currentStamps`
* is always derived from — and only ever changed alongside — a
* `LoyaltyTransaction` row. Nothing in this file writes `currentStamps`
* without creating the matching transaction in the same call, and nothing
* outside this file should touch either table directly.
*
* Reward 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.
*/
export type LoyaltyErrorCode =
| "LOYALTY_DISABLED"
| "PROGRAMME_NOT_FOUND"
| "NOT_ELIGIBLE_SERVICE"
| "NOT_ELIGIBLE_STAFF"
| "REASON_REQUIRED"
| "INVALID_AMOUNT"
| "APPOINTMENT_NOT_FOUND"
| "APPOINTMENT_NOT_COMPLETED";
export interface LoyaltySummary {
isEnabled: boolean;
accountId: string | null;
currentStamps: number;
stampsRequired: number;
availableReward: { id: string; label: string; createdAt: Date } | null;
rewardLabel: string | null;
stampIcon: string | null;
cardBackgroundColor: string | null;
}
/** The customer's loyalty standing for this business — null only if no loyalty programme has ever been configured (distinct from "configured but disabled", which still returns a summary with isEnabled: false). */
export async function getLoyaltySummary(
prisma: PrismaClient,
params: { customerId: string; businessId: string },
): Promise<LoyaltySummary | null> {
const programme = await prisma.loyaltyProgramme.findUnique({
where: { businessId: params.businessId },
});
if (!programme) return null;
const account = await prisma.loyaltyAccount.findUnique({
where: {
customerId_loyaltyProgrammeId: {
customerId: params.customerId,
loyaltyProgrammeId: programme.id,
},
},
include: {
rewards: { where: { status: "AVAILABLE" }, orderBy: { createdAt: "desc" }, take: 1 },
},
});
return {
isEnabled: programme.isEnabled,
accountId: account?.id ?? null,
currentStamps: account?.currentStamps ?? 0,
stampsRequired: programme.stampsRequired,
availableReward: account?.rewards[0]
? {
id: account.rewards[0].id,
label: account.rewards[0].label,
createdAt: account.rewards[0].createdAt,
}
: null,
rewardLabel: programme.rewardLabel,
stampIcon: programme.stampIcon,
cardBackgroundColor: programme.cardBackgroundColor,
};
}
/**
* Batched version of getLoyaltySummary's "does this customer have a reward
* waiting" check, for rendering a loyalty indicator across a whole day's
* appointments (the calendar) without one query per customer. Returns an
* empty map if loyalty isn't configured/enabled at all.
*/
export async function getAvailableRewardFlags(
prisma: PrismaClient,
params: { businessId: string; customerIds: string[] },
): Promise<Map<string, boolean>> {
if (params.customerIds.length === 0) return new Map();
const programme = await prisma.loyaltyProgramme.findUnique({
where: { businessId: params.businessId },
});
if (!programme || !programme.isEnabled) return new Map();
const accounts = await prisma.loyaltyAccount.findMany({
where: { loyaltyProgrammeId: programme.id, customerId: { in: params.customerIds } },
select: {
customerId: true,
rewards: { where: { status: "AVAILABLE" }, select: { id: true }, take: 1 },
},
});
return new Map(accounts.map((a) => [a.customerId, a.rewards.length > 0]));
}
export interface CustomerLoyaltyBrief {
currentStamps: number;
stampsRequired: number;
hasAvailableReward: boolean;
}
/** Fuller batched summary — current stamps + threshold + reward flag — for the staff customer directory, one query for the whole list rather than one per row. */
export async function getLoyaltySummariesForCustomers(
prisma: PrismaClient,
params: { businessId: string; customerIds: string[] },
): Promise<Map<string, CustomerLoyaltyBrief>> {
if (params.customerIds.length === 0) return new Map();
const programme = await prisma.loyaltyProgramme.findUnique({
where: { businessId: params.businessId },
});
if (!programme || !programme.isEnabled) return new Map();
const accounts = await prisma.loyaltyAccount.findMany({
where: { loyaltyProgrammeId: programme.id, customerId: { in: params.customerIds } },
select: {
customerId: true,
currentStamps: true,
rewards: { where: { status: "AVAILABLE" }, select: { id: true }, take: 1 },
},
});
return new Map(
accounts.map((a) => [
a.customerId,
{
currentStamps: a.currentStamps,
stampsRequired: programme.stampsRequired,
hasAvailableReward: a.rewards.length > 0,
},
]),
);
}
async function getOrCreateAccount(
prisma: PrismaClient,
params: { customerId: string; loyaltyProgrammeId: string },
) {
return prisma.loyaltyAccount.upsert({
where: {
customerId_loyaltyProgrammeId: {
customerId: params.customerId,
loyaltyProgrammeId: params.loyaltyProgrammeId,
},
},
create: { customerId: params.customerId, loyaltyProgrammeId: params.loyaltyProgrammeId },
update: {},
});
}
/**
* An empty eligible-services/eligible-staff list means "everyone/everything
* is eligible" (see schema.prisma) — an explicit row only exists to
* *restrict* eligibility. A visit counts if at least one of its services is
* eligible (the common "any qualifying service in this visit earns a
* stamp" rule), not only if every service is.
*/
async function checkEligibility(
prisma: PrismaClient,
params: { loyaltyProgrammeId: string; serviceIds: string[]; staffId: string },
): Promise<ValidationResult<LoyaltyErrorCode>> {
const [eligibleServices, eligibleStaff] = await Promise.all([
prisma.loyaltyEligibleService.findMany({
where: { loyaltyProgrammeId: params.loyaltyProgrammeId },
}),
prisma.loyaltyEligibleStaff.findMany({
where: { loyaltyProgrammeId: params.loyaltyProgrammeId },
}),
]);
if (eligibleStaff.length > 0 && !eligibleStaff.some((r) => r.staffId === params.staffId)) {
return fail({
code: "NOT_ELIGIBLE_STAFF",
message: "This staff member's visits don't earn loyalty stamps.",
});
}
if (
eligibleServices.length > 0 &&
!params.serviceIds.some((id) => eligibleServices.some((r) => r.serviceId === id))
) {
return fail({
code: "NOT_ELIGIBLE_SERVICE",
message: "None of the services on this visit earn a loyalty stamp.",
});
}
return ok();
}
interface AddStampResult {
success: boolean;
alreadyAwarded?: boolean;
rewardCreated?: boolean;
validation: ValidationResult<LoyaltyErrorCode>;
}
/**
* The auto-award path, called once by the "complete appointment" Server
* Action after AppointmentService has already transitioned the status —
* this function never changes appointment status itself. Idempotent: if a
* STAMP_ADDED transaction already references this appointment (e.g. the
* action is somehow triggered twice), it returns success without creating
* a second one — the brief's explicit "should not receive the same
* automatic stamp twice".
*/
export async function awardStampForCompletedAppointment(
prisma: PrismaClient,
params: { appointmentId: string; staffId: string },
): Promise<AddStampResult> {
const appointment = await prisma.appointment.findUnique({
where: { id: params.appointmentId },
include: { services: true },
});
if (!appointment)
return {
success: false,
validation: fail({ code: "APPOINTMENT_NOT_FOUND", message: "Appointment not found." }),
};
if (appointment.status !== "COMPLETED") {
return {
success: false,
validation: fail({
code: "APPOINTMENT_NOT_COMPLETED",
message: "Only a completed appointment can earn a stamp.",
}),
};
}
const programme = await prisma.loyaltyProgramme.findUnique({
where: { businessId: appointment.businessId },
});
if (!programme || !programme.isEnabled) {
return {
success: false,
validation: fail({
code: "LOYALTY_DISABLED",
message: "Loyalty isn't enabled for this business.",
}),
};
}
const eligibility = await checkEligibility(prisma, {
loyaltyProgrammeId: programme.id,
serviceIds: appointment.services.map((s) => s.serviceId),
staffId: appointment.staffId,
});
if (!eligibility.valid) return { success: false, validation: eligibility };
const account = await getOrCreateAccount(prisma, {
customerId: appointment.customerId,
loyaltyProgrammeId: programme.id,
});
const alreadyAwarded = await prisma.loyaltyTransaction.findFirst({
where: { loyaltyAccountId: account.id, appointmentId: appointment.id, type: "STAMP_ADDED" },
});
if (alreadyAwarded) return { success: true, alreadyAwarded: true, validation: ok() };
const { rewardCreated } = await addStampTransaction(prisma, {
loyaltyAccountId: account.id,
amount: 1, // one stamp per completed visit — see file header; not a per-service count
appointmentId: appointment.id,
authoredByStaffId: params.staffId,
reason: null,
programme,
});
return { success: true, rewardCreated, validation: ok() };
}
/** The manual "Add Stamp" action a staff member triggers from Customer Details / Current Appointment — not tied to a completion event, so no duplicate-award check applies (every manual add is its own deliberate transaction). */
export async function addStamp(
prisma: PrismaClient,
params: {
customerId: string;
businessId: string;
amount: number;
staffId: string;
appointmentId?: string;
reason?: string;
},
): Promise<AddStampResult> {
if (!Number.isInteger(params.amount) || params.amount <= 0) {
return {
success: false,
validation: fail({ code: "INVALID_AMOUNT", message: "Enter a positive number of stamps." }),
};
}
const programme = await prisma.loyaltyProgramme.findUnique({
where: { businessId: params.businessId },
});
if (!programme || !programme.isEnabled) {
return {
success: false,
validation: fail({
code: "LOYALTY_DISABLED",
message: "Loyalty isn't enabled for this business.",
}),
};
}
const account = await getOrCreateAccount(prisma, {
customerId: params.customerId,
loyaltyProgrammeId: programme.id,
});
const { rewardCreated } = await addStampTransaction(prisma, {
loyaltyAccountId: account.id,
amount: params.amount,
appointmentId: params.appointmentId,
authoredByStaffId: params.staffId,
reason: params.reason ?? null,
programme,
});
return { success: true, rewardCreated, validation: ok() };
}
async function addStampTransaction(
prisma: PrismaClient,
params: {
loyaltyAccountId: string;
amount: number;
appointmentId?: string;
authoredByStaffId: string;
reason: string | null;
programme: LoyaltyProgramme;
},
): Promise<{ rewardCreated: boolean }> {
return prisma.$transaction(async (tx) => {
const account = await tx.loyaltyAccount.update({
where: { id: params.loyaltyAccountId },
data: { currentStamps: { increment: params.amount } },
});
await tx.loyaltyTransaction.create({
data: {
loyaltyAccountId: params.loyaltyAccountId,
type: "STAMP_ADDED",
amount: params.amount,
reason: params.reason,
appointmentId: params.appointmentId,
authoredByStaffId: params.authoredByStaffId,
},
});
let rewardCreated = false;
if (account.currentStamps >= params.programme.stampsRequired) {
const existingAvailable = await tx.loyaltyReward.findFirst({
where: { loyaltyAccountId: account.id, status: "AVAILABLE" },
});
// Crossing the threshold again while a reward is already waiting to be
// redeemed doesn't stack a second one.
if (!existingAvailable) {
await tx.loyaltyReward.create({
data: {
loyaltyAccountId: account.id,
rewardType: params.programme.rewardType,
value: params.programme.rewardValue,
label: params.programme.rewardLabel ?? "Reward",
status: "AVAILABLE",
expiresAt: params.programme.rewardExpiresAfterDays
? new Date(Date.now() + params.programme.rewardExpiresAfterDays * 86_400_000)
: null,
},
});
rewardCreated = true;
}
}
return { rewardCreated };
});
}
/**
* Corrects an accidental stamp — always removes (never adds; use `addStamp`
* for that), always requires a reason, and always creates a compensating
* STAMP_REMOVED transaction rather than editing or deleting the original
* entry. The cached total is clamped at zero so a correction can never make
* it negative.
*/
export async function correctStamp(
prisma: PrismaClient,
params: { loyaltyAccountId: string; amount: number; staffId: string; reason: string },
): Promise<{ success: boolean; validation: ValidationResult<LoyaltyErrorCode> }> {
if (!params.reason.trim()) {
return {
success: false,
validation: fail({
code: "REASON_REQUIRED",
message: "A reason is required to correct a stamp.",
}),
};
}
if (!Number.isInteger(params.amount) || params.amount <= 0) {
return {
success: false,
validation: fail({
code: "INVALID_AMOUNT",
message: "Enter a positive number of stamps to remove.",
}),
};
}
await prisma.$transaction(async (tx) => {
const account = await tx.loyaltyAccount.findUniqueOrThrow({
where: { id: params.loyaltyAccountId },
});
const clampedRemoval = Math.min(params.amount, account.currentStamps);
await tx.loyaltyAccount.update({
where: { id: params.loyaltyAccountId },
data: { currentStamps: { decrement: clampedRemoval } },
});
await tx.loyaltyTransaction.create({
data: {
loyaltyAccountId: params.loyaltyAccountId,
type: "STAMP_REMOVED",
amount: -clampedRemoval,
reason: params.reason,
authoredByStaffId: params.staffId,
},
});
});
return { success: true, validation: ok() };
}
export async function getLoyaltyHistory(
prisma: PrismaClient,
params: { loyaltyAccountId: string; limit?: number },
) {
return prisma.loyaltyTransaction.findMany({
where: { loyaltyAccountId: params.loyaltyAccountId },
orderBy: { createdAt: "desc" },
take: params.limit ?? 20,
include: {
authoredByStaff: { include: { user: { select: { name: true } } } },
appointment: { select: { id: true, startAt: true } },
},
});
}
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { AppointmentStatus } from "@/generated/prisma/enums";
import { canTransition, validateTransition } from "../appointment.service";
import { canTransition, selectCurrentAppointment, validateTransition } from "../appointment.service";
const ALL_STATUSES = Object.values(AppointmentStatus);
@@ -65,3 +65,66 @@ describe("validateTransition", () => {
expect(result.issues[0]?.code).toBe("INVALID_STATUS_TRANSITION");
});
});
describe("selectCurrentAppointment", () => {
const now = new Date("2026-08-02T12:00:00.000Z");
interface Fixture {
id: string;
status: AppointmentStatus;
startAt: Date;
endAt: Date;
}
function appt(id: string, status: AppointmentStatus, startOffsetMin: number, durationMin = 30): Fixture {
const startAt = new Date(now.getTime() + startOffsetMin * 60_000);
return { id, status, startAt, endAt: new Date(startAt.getTime() + durationMin * 60_000) };
}
it("returns null for an empty list", () => {
expect(selectCurrentAppointment([], now)).toBeNull();
});
it("tier 1: prefers IN_PROGRESS over everything else", () => {
const inProgress = appt("in-progress", AppointmentStatus.IN_PROGRESS, -10, 30);
const checkedIn = appt("checked-in", AppointmentStatus.CHECKED_IN, 5, 30);
const scheduledNow = appt("scheduled-now", AppointmentStatus.CONFIRMED, -5, 30);
const result = selectCurrentAppointment([checkedIn, scheduledNow, inProgress], now);
expect(result?.id).toBe("in-progress");
});
it("tier 2: CHECKED_IN wins when nothing is IN_PROGRESS", () => {
const checkedIn = appt("checked-in", AppointmentStatus.CHECKED_IN, 5, 30);
const upcoming = appt("upcoming", AppointmentStatus.CONFIRMED, 120, 30);
const result = selectCurrentAppointment([upcoming, checkedIn], now);
expect(result?.id).toBe("checked-in");
});
it("tier 3: a CONFIRMED appointment whose window contains now, but never checked in", () => {
const scheduledNow = appt("scheduled-now", AppointmentStatus.CONFIRMED, -5, 30);
const upcoming = appt("upcoming", AppointmentStatus.CONFIRMED, 120, 30);
const result = selectCurrentAppointment([upcoming, scheduledNow], now);
expect(result?.id).toBe("scheduled-now");
});
it("tier 4: falls back to the next upcoming appointment today, earliest first", () => {
const later = appt("later", AppointmentStatus.CONFIRMED, 180, 30);
const sooner = appt("sooner", AppointmentStatus.PENDING, 60, 30);
const result = selectCurrentAppointment([later, sooner], now);
expect(result?.id).toBe("sooner");
});
it("tier 5: null when every appointment is terminal or in the past", () => {
const completed = appt("completed", AppointmentStatus.COMPLETED, -120, 30);
const cancelled = appt("cancelled", AppointmentStatus.CANCELLED, 60, 30);
const noShow = appt("no-show", AppointmentStatus.NO_SHOW, -60, 30);
const result = selectCurrentAppointment([completed, cancelled, noShow], now);
expect(result).toBeNull();
});
it("ignores CANCELLED and NO_SHOW rows even when their window contains now", () => {
const cancelledNow = appt("cancelled-now", AppointmentStatus.CANCELLED, -5, 30);
const result = selectCurrentAppointment([cancelledNow], now);
expect(result).toBeNull();
});
});
@@ -233,6 +233,48 @@ describe("booking engine (integration)", () => {
});
expect(slots.length).toBeGreaterThan(0);
});
it("a partial-day (Block Time) entry only blocks its own window, not the whole day", async () => {
const { staffId } = await createTestStaff(business);
await prisma.timeOff.create({
data: {
staffId,
startDate: new Date(`${monday}T00:00:00.000Z`),
endDate: new Date(`${monday}T00:00:00.000Z`),
status: "APPROVED",
reason: "Team meeting",
startMinutes: 12 * 60,
endMinutes: 13 * 60,
},
});
const { slots } = await getAvailableTimeSlots(prisma, {
businessId: business.businessId,
serviceIds: [business.haircutServiceId],
date: monday,
});
// Slots outside the block still exist — this is not a whole-day block.
expect(slots.length).toBeGreaterThan(0);
const duringBlock = slots.some((s) => s.startMinutes >= 12 * 60 && s.startMinutes < 13 * 60);
expect(duringBlock).toBe(false);
const { validation: insideBlock } = await validateBookingSlot(prisma, {
businessId: business.businessId,
serviceIds: [business.haircutServiceId],
staffId,
startAt: wallClockToInstant(monday, 12 * 60 + 15, business.timezone),
});
expect(insideBlock.valid).toBe(false);
expect(insideBlock.issues.map((i) => i.code)).toContain("STAFF_ON_LEAVE");
const { validation: outsideBlock } = await validateBookingSlot(prisma, {
businessId: business.businessId,
serviceIds: [business.haircutServiceId],
staffId,
startAt: wallClockToInstant(monday, 14 * 60, business.timezone),
});
expect(outsideBlock.valid).toBe(true);
});
});
describe("multiple services", () => {
+53 -3
View File
@@ -27,7 +27,10 @@ export function canTransition(from: AppointmentStatus, to: AppointmentStatus): b
return TRANSITIONS[from].includes(to);
}
export function validateTransition(from: AppointmentStatus, to: AppointmentStatus): ValidationResult {
export function validateTransition(
from: AppointmentStatus,
to: AppointmentStatus,
): ValidationResult {
if (!canTransition(from, to)) {
return fail({
code: "INVALID_STATUS_TRANSITION",
@@ -85,11 +88,18 @@ export type TransitionResult =
| { success: true; appointment: NonNullable<Awaited<ReturnType<typeof getAppointmentById>>> }
| { success: false; validation: ValidationResult };
async function transitionStatus(prisma: PrismaClient, appointmentId: string, to: AppointmentStatus): Promise<TransitionResult> {
async function transitionStatus(
prisma: PrismaClient,
appointmentId: string,
to: AppointmentStatus,
): Promise<TransitionResult> {
return prisma.$transaction(async (tx) => {
const appointment = await tx.appointment.findUnique({ where: { id: appointmentId } });
if (!appointment) {
return { success: false, validation: fail({ code: "APPOINTMENT_NOT_FOUND", message: "Appointment not found." }) };
return {
success: false,
validation: fail({ code: "APPOINTMENT_NOT_FOUND", message: "Appointment not found." }),
};
}
const check = validateTransition(appointment.status, to);
@@ -123,3 +133,43 @@ export function markNoShow(prisma: PrismaClient, appointmentId: string) {
export function confirm(prisma: PrismaClient, appointmentId: string) {
return transitionStatus(prisma, appointmentId, AppointmentStatus.CONFIRMED);
}
const CURRENT_APPOINTMENT_ACTIVE_STATUSES: AppointmentStatus[] = [
AppointmentStatus.PENDING,
AppointmentStatus.CONFIRMED,
AppointmentStatus.CHECKED_IN,
AppointmentStatus.IN_PROGRESS,
];
/**
* Which of a staff member's appointments is "the current one" for the
* Current Appointment screen — a pure function over already-fetched
* appointments (the page fetches today's list via listAppointments; this
* just picks the most relevant one), so it's cheap to unit test every edge
* case without a database. Priority, per the Phase 5 brief:
* 1. IN_PROGRESS
* 2. CHECKED_IN
* 3. Scheduled right now (startAt <= now < endAt) and not yet terminal
* 4. The next upcoming one today
* 5. null — the page shows its own empty state
*/
export function selectCurrentAppointment<
T extends { status: AppointmentStatus; startAt: Date; endAt: Date },
>(appointments: T[], now: Date): T | null {
const inProgress = appointments.find((a) => a.status === AppointmentStatus.IN_PROGRESS);
if (inProgress) return inProgress;
const checkedIn = appointments.find((a) => a.status === AppointmentStatus.CHECKED_IN);
if (checkedIn) return checkedIn;
const scheduledNow = appointments.find(
(a) =>
CURRENT_APPOINTMENT_ACTIVE_STATUSES.includes(a.status) && a.startAt <= now && a.endAt > now,
);
if (scheduledNow) return scheduledNow;
const upcoming = appointments
.filter((a) => CURRENT_APPOINTMENT_ACTIVE_STATUSES.includes(a.status) && a.startAt > now)
.sort((a, b) => a.startAt.getTime() - b.startAt.getTime());
return upcoming[0] ?? null;
}
@@ -453,10 +453,10 @@ export async function validateBookingSlot(
};
}
const [workingHours, breakDays, activeTimeOff, businessCount, staffCount] = await Promise.all([
const [workingHours, breakDays, timeOffOnDate, businessCount, staffCount] = await Promise.all([
prisma.workingHours.findUnique({ where: { staffId_dayOfWeek: { staffId: params.staffId, dayOfWeek: weekdayOf(date) } } }),
prisma.breakDay.findMany({ where: { dayOfWeek: weekdayOf(date), break: { staffId: params.staffId } }, include: { break: true } }),
prisma.timeOff.findFirst({
prisma.timeOff.findMany({
where: {
staffId: params.staffId,
status: "APPROVED",
@@ -490,11 +490,24 @@ export async function validateBookingSlot(
return { context, endAt, validation: mergeValidation(ruleChecks, fail({ code: "STAFF_NOT_WORKING", message: "This staff member isn't scheduled to work at this time." })) };
}
if (activeTimeOff) {
// A row with both minutes null blocks the whole day; a row with minutes
// set (including a one-off "Block Time" entry — see TimeOff.startMinutes
// in schema.prisma) only conflicts if it overlaps the requested slot.
if (timeOffOnDate.some((t) => t.startMinutes === null)) {
return { context, endAt, validation: mergeValidation(ruleChecks, fail({ code: "STAFF_ON_LEAVE", message: "This staff member is on approved leave that day." })) };
}
const partialTimeOff: TimeWindow[] = timeOffOnDate
.filter((t) => t.startMinutes !== null && t.endMinutes !== null)
.map((t) => ({ startMinutes: t.startMinutes!, endMinutes: t.endMinutes! }));
const onTimeOff = partialTimeOff.some((w) => startMinutes < w.endMinutes && endMinutes > w.startMinutes);
if (onTimeOff) {
return { context, endAt, validation: mergeValidation(ruleChecks, fail({ code: "STAFF_ON_LEAVE", message: "This staff member is unavailable during part of this time." })) };
}
const breaks: TimeWindow[] = breakDays.map((bd) => ({ startMinutes: bd.break.startMinutes, endMinutes: bd.break.endMinutes }));
const breaks: TimeWindow[] = [
...breakDays.map((bd) => ({ startMinutes: bd.break.startMinutes, endMinutes: bd.break.endMinutes })),
...partialTimeOff,
];
const onBreak = breaks.some((b) => startMinutes < b.endMinutes && endMinutes > b.startMinutes);
if (onBreak) {
return { context, endAt, validation: mergeValidation(ruleChecks, fail({ code: "STAFF_ON_BREAK", message: "This staff member is on a break during part of this time." })) };
+76 -17
View File
@@ -2,7 +2,11 @@ import type { PrismaClient } from "@/generated/prisma/client";
import { AppointmentStatus } from "@/generated/prisma/enums";
import { calendarDateOf, minutesFromMidnight } from "@/lib/time/business-time";
import { getAppointmentById } from "./appointment.service";
import { getAvailableTimeSlots, resolveBookingContext, validateBookingSlot } from "./availability.service";
import {
getAvailableTimeSlots,
resolveBookingContext,
validateBookingSlot,
} from "./availability.service";
import { isLateCancellation, isLateReschedule } from "./business-rules.service";
import { DoubleBookingError, createAppointment, rescheduleAppointment } from "./scheduling.service";
import { countStaffAppointmentsOnDate } from "./staff-availability.service";
@@ -24,11 +28,14 @@ export interface CreateBookingInput {
staffId?: string;
startAt: Date;
notes?: string;
/** Who initiated this booking — see Appointment.createdByUserId in schema.prisma. Omit for a guest's own self-service booking. */
createdByUserId?: string;
}
export type Appointment = NonNullable<Awaited<ReturnType<typeof getAppointmentById>>>;
export type BookingResult = { success: true; appointment: Appointment } | { success: false; validation: ValidationResult };
export type BookingResult =
{ success: true; appointment: Appointment } | { success: false; validation: ValidationResult };
/** Among staff free at this slot, prefer whoever has the fewest appointments that day — spreads "any barber" bookings out instead of always picking the same one. */
async function pickLeastBusyStaff(
@@ -40,7 +47,9 @@ async function pickLeastBusyStaff(
if (candidateStaffIds.length === 1) return candidateStaffIds[0]!;
const counts = await Promise.all(
candidateStaffIds.map((staffId) => countStaffAppointmentsOnDate(prisma, { staffId, date, businessTimezone })),
candidateStaffIds.map((staffId) =>
countStaffAppointmentsOnDate(prisma, { staffId, date, businessTimezone }),
),
);
let best = candidateStaffIds[0]!;
@@ -55,7 +64,10 @@ async function pickLeastBusyStaff(
return best;
}
export async function createBooking(prisma: PrismaClient, input: CreateBookingInput): Promise<BookingResult> {
export async function createBooking(
prisma: PrismaClient,
input: CreateBookingInput,
): Promise<BookingResult> {
const { context: baseContext, validation: baseValidation } = await resolveBookingContext(prisma, {
businessId: input.businessId,
serviceIds: input.serviceIds,
@@ -68,7 +80,10 @@ export async function createBooking(prisma: PrismaClient, input: CreateBookingIn
if (baseContext.eligibleStaffIds.length === 0) {
return {
success: false,
validation: fail({ code: "STAFF_CANNOT_PERFORM_SERVICE", message: "No staff can perform the selected services." }),
validation: fail({
code: "STAFF_CANNOT_PERFORM_SERVICE",
message: "No staff can perform the selected services.",
}),
};
}
@@ -81,9 +96,20 @@ export async function createBooking(prisma: PrismaClient, input: CreateBookingIn
});
const matching = slots.find((s) => s.startMinutes === startMinutes);
if (!matching || matching.staffOptions.length === 0) {
return { success: false, validation: fail({ code: "SLOT_UNAVAILABLE", message: "This time is no longer available." }) };
return {
success: false,
validation: fail({
code: "SLOT_UNAVAILABLE",
message: "This time is no longer available.",
}),
};
}
staffId = await pickLeastBusyStaff(prisma, matching.staffOptions, date, baseContext.business.timezone);
staffId = await pickLeastBusyStaff(
prisma,
matching.staffOptions,
date,
baseContext.business.timezone,
);
}
const { validation, context, endAt } = await validateBookingSlot(prisma, {
@@ -94,7 +120,9 @@ export async function createBooking(prisma: PrismaClient, input: CreateBookingIn
});
if (!validation.valid || !context || !endAt) return { success: false, validation };
const status = context.bookingRule.requireApproval ? AppointmentStatus.PENDING : AppointmentStatus.CONFIRMED;
const status = context.bookingRule.requireApproval
? AppointmentStatus.PENDING
: AppointmentStatus.CONFIRMED;
try {
const created = await createAppointment(prisma, {
@@ -105,6 +133,7 @@ export async function createBooking(prisma: PrismaClient, input: CreateBookingIn
endAt,
status,
notes: input.notes,
createdByUserId: input.createdByUserId,
effectiveBuffer: context.effectiveBuffer,
services: context.services.map((s) => ({
serviceId: s.serviceId,
@@ -118,7 +147,10 @@ export async function createBooking(prisma: PrismaClient, input: CreateBookingIn
return { success: true, appointment: appointment! };
} catch (error) {
if (error instanceof DoubleBookingError) {
return { success: false, validation: fail({ code: "SLOT_UNAVAILABLE", message: error.message }) };
return {
success: false,
validation: fail({ code: "SLOT_UNAVAILABLE", message: error.message }),
};
}
throw error;
}
@@ -139,10 +171,16 @@ const NON_RESCHEDULABLE_STATUSES: AppointmentStatus[] = [
];
/** Rescheduling performs the same validation as a new booking — it's a new slot, just for an appointment that already exists. */
export async function rescheduleBooking(prisma: PrismaClient, input: RescheduleBookingInput): Promise<BookingResult> {
export async function rescheduleBooking(
prisma: PrismaClient,
input: RescheduleBookingInput,
): Promise<BookingResult> {
const existing = await getAppointmentById(prisma, input.appointmentId);
if (!existing) {
return { success: false, validation: fail({ code: "APPOINTMENT_NOT_FOUND", message: "Appointment not found." }) };
return {
success: false,
validation: fail({ code: "APPOINTMENT_NOT_FOUND", message: "Appointment not found." }),
};
}
if (NON_RESCHEDULABLE_STATUSES.includes(existing.status)) {
return {
@@ -178,7 +216,10 @@ export async function rescheduleBooking(prisma: PrismaClient, input: RescheduleB
return { success: true, appointment: appointment! };
} catch (error) {
if (error instanceof DoubleBookingError) {
return { success: false, validation: fail({ code: "SLOT_UNAVAILABLE", message: error.message }) };
return {
success: false,
validation: fail({ code: "SLOT_UNAVAILABLE", message: error.message }),
};
}
throw error;
}
@@ -214,10 +255,16 @@ const NON_CANCELLABLE_STATUSES: AppointmentStatus[] = [
];
/** Never deletes — sets status to CANCELLED and records when, why, and by whom. */
export async function cancelBooking(prisma: PrismaClient, input: CancelBookingInput): Promise<CancelBookingResult> {
export async function cancelBooking(
prisma: PrismaClient,
input: CancelBookingInput,
): Promise<CancelBookingResult> {
const existing = await getAppointmentById(prisma, input.appointmentId);
if (!existing) {
return { success: false, validation: fail({ code: "APPOINTMENT_NOT_FOUND", message: "Appointment not found." }) };
return {
success: false,
validation: fail({ code: "APPOINTMENT_NOT_FOUND", message: "Appointment not found." }),
};
}
if (NON_CANCELLABLE_STATUSES.includes(existing.status)) {
return {
@@ -234,7 +281,11 @@ export async function cancelBooking(prisma: PrismaClient, input: CancelBookingIn
select: { bookingRule: { select: { cancellationWindowHours: true } } },
});
const cancellationWindowHours = business?.bookingRule?.cancellationWindowHours ?? 24;
const wasLateCancellation = isLateCancellation(existing.startAt, new Date(), cancellationWindowHours);
const wasLateCancellation = isLateCancellation(
existing.startAt,
new Date(),
cancellationWindowHours,
);
const appointment = await prisma.appointment.update({
where: { id: input.appointmentId },
@@ -244,13 +295,21 @@ export async function cancelBooking(prisma: PrismaClient, input: CancelBookingIn
cancelledByUserId: input.cancelledByUserId,
cancellationReason: input.reason,
},
include: { services: { include: { service: true } }, staff: { include: { user: true } }, customer: true },
include: {
services: { include: { service: true } },
staff: { include: { user: true } },
customer: true,
},
});
return { success: true, appointment, wasLateCancellation, validation: ok() };
}
/** Exposed for symmetry with cancellation's "late" flag — a UI can warn before submitting a reschedule request. */
export function checkRescheduleWindow(appointmentStart: Date, now: Date, rescheduleWindowHours: number): boolean {
export function checkRescheduleWindow(
appointmentStart: Date,
now: Date,
rescheduleWindowHours: number,
): boolean {
return isLateReschedule(appointmentStart, now, rescheduleWindowHours);
}
+24 -5
View File
@@ -46,6 +46,8 @@ export interface CreateAppointmentParams {
endAt: Date;
status: AppointmentStatus;
notes?: string;
/** Who initiated this booking — see Appointment.createdByUserId in schema.prisma. Omitted for a guest customer's own self-service booking. */
createdByUserId?: string;
effectiveBuffer: { beforeMinutes: number; afterMinutes: number };
services: AppointmentServiceLine[];
}
@@ -60,10 +62,20 @@ export interface RescheduleAppointmentParams {
async function assertNoConflict(
tx: PrismaClient,
params: { staffId: string; startAt: Date; endAt: Date; effectiveBuffer: { beforeMinutes: number; afterMinutes: number }; excludeAppointmentId?: string },
params: {
staffId: string;
startAt: Date;
endAt: Date;
effectiveBuffer: { beforeMinutes: number; afterMinutes: number };
excludeAppointmentId?: string;
},
): Promise<void> {
const bufferedStart = new Date(params.startAt.getTime() - params.effectiveBuffer.beforeMinutes * 60_000);
const bufferedEnd = new Date(params.endAt.getTime() + params.effectiveBuffer.afterMinutes * 60_000);
const bufferedStart = new Date(
params.startAt.getTime() - params.effectiveBuffer.beforeMinutes * 60_000,
);
const bufferedEnd = new Date(
params.endAt.getTime() + params.effectiveBuffer.afterMinutes * 60_000,
);
const conflict = await tx.appointment.findFirst({
where: {
@@ -110,7 +122,10 @@ function isSerializationFailure(error: unknown): boolean {
return false;
}
async function runSerializable<T>(prisma: PrismaClient, fn: (tx: PrismaClient) => Promise<T>): Promise<T> {
async function runSerializable<T>(
prisma: PrismaClient,
fn: (tx: PrismaClient) => Promise<T>,
): Promise<T> {
try {
return await prisma.$transaction(fn as (tx: Prisma.TransactionClient) => Promise<T>, {
isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
@@ -139,6 +154,7 @@ export async function createAppointment(prisma: PrismaClient, params: CreateAppo
endAt: params.endAt,
status: params.status,
notes: params.notes,
createdByUserId: params.createdByUserId,
services: {
create: params.services.map((s) => ({
serviceId: s.serviceId,
@@ -152,7 +168,10 @@ export async function createAppointment(prisma: PrismaClient, params: CreateAppo
});
}
export async function rescheduleAppointment(prisma: PrismaClient, params: RescheduleAppointmentParams) {
export async function rescheduleAppointment(
prisma: PrismaClient,
params: RescheduleAppointmentParams,
) {
return runSerializable(prisma, async (tx) => {
await assertNoConflict(tx, {
staffId: params.staffId,
@@ -116,21 +116,32 @@ export async function getStaffFreeWindowsForRange(
: null;
const dateAsUtcMidnight = calendarDateToUtcMidnight(date);
const onLeave = timeOffRows.some((t) => t.startDate <= dateAsUtcMidnight && t.endDate >= dateAsUtcMidnight);
const timeOffOnDate = timeOffRows.filter((t) => t.startDate <= dateAsUtcMidnight && t.endDate >= dateAsUtcMidnight);
// A row with both minutes null blocks the whole day (the original
// behaviour); a row with minutes set — including a one-off "Block
// Time" entry, see TimeOff.startMinutes in schema.prisma — only blocks
// that window, so it's folded into `busy` below instead.
const onLeave = timeOffOnDate.some((t) => t.startMinutes === null);
const partialTimeOffBusy: TimeWindow[] = timeOffOnDate
.filter((t) => t.startMinutes !== null && t.endMinutes !== null)
.map((t) => ({ startMinutes: t.startMinutes!, endMinutes: t.endMinutes! }));
const dayStart = startOfBusinessDay(date, businessTimezone);
const dayEnd = endOfBusinessDay(date, businessTimezone);
const busy: TimeWindow[] = appointments
.filter((a) => a.startAt < dayEnd && a.endAt > dayStart)
.map((a) =>
expandWithBuffer(
{
startMinutes: minutesFromMidnight(a.startAt, businessTimezone),
endMinutes: minutesFromMidnight(a.endAt, businessTimezone),
},
effectiveBuffer,
const busy: TimeWindow[] = [
...appointments
.filter((a) => a.startAt < dayEnd && a.endAt > dayStart)
.map((a) =>
expandWithBuffer(
{
startMinutes: minutesFromMidnight(a.startAt, businessTimezone),
endMinutes: minutesFromMidnight(a.endAt, businessTimezone),
},
effectiveBuffer,
),
),
);
...partialTimeOffBusy,
];
result.set(
date,
@@ -0,0 +1,259 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { Weekday } from "@/generated/prisma/enums";
import { addDays, todayIn, wallClockToInstant, weekdayOf } from "@/lib/time/business-time";
import type { CalendarDate } from "@/lib/time/business-time";
import {
createBreak,
createTimeOff,
deleteBreak,
deleteTimeOff,
getWorkingHours,
setWorkingHours,
updateBreak,
updateTimeOff,
} from "../staff.service";
import {
cleanupTestBusiness,
createTestBusiness,
createTestCustomer,
createTestStaff,
prisma,
} from "@/services/scheduling/__tests__/fixtures";
import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures";
function nextWeekday(timezone: string, target: Weekday, minDaysAhead = 7): CalendarDate {
let date = addDays(todayIn(timezone), minDaysAhead);
for (let i = 0; i < 8; i++) {
if (weekdayOf(date) === target) return date;
date = addDays(date, 1);
}
throw new Error("unreachable");
}
/**
* Covers the Breaks/Time Off conflict-resolution flow and the ownership
* checks added to updateBreak/deleteBreak/updateTimeOff/deleteTimeOff — a
* real bug caught during Phase 5 development where a permission mismatch
* threw uncaught instead of returning the graceful {success:false} shape
* every other StaffService function uses.
*/
describe("StaffService (integration)", () => {
let business: TestBusiness;
beforeEach(async () => {
business = await createTestBusiness();
});
afterEach(async () => {
await cleanupTestBusiness(business);
});
describe("working hours", () => {
it("round-trips a full week through setWorkingHours/getWorkingHours", async () => {
const { staffId } = await createTestStaff(business);
const result = await setWorkingHours(prisma, {
staffId,
days: [
{ dayOfWeek: Weekday.MONDAY, isOff: false, startMinutes: 9 * 60, endMinutes: 17 * 60 },
{ dayOfWeek: Weekday.SUNDAY, isOff: true },
],
});
expect(result.success).toBe(true);
const hours = await getWorkingHours(prisma, staffId);
const monday = hours.find((h) => h.dayOfWeek === Weekday.MONDAY);
expect(monday?.startMinutes).toBe(9 * 60);
expect(monday?.endMinutes).toBe(17 * 60);
const sunday = hours.find((h) => h.dayOfWeek === Weekday.SUNDAY);
expect(sunday?.isOff).toBe(true);
});
});
describe("breaks", () => {
it("blocks creating a break that overlaps a future appointment on a matching day, and Save Anyway bypasses it", async () => {
const { staffId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
const monday = nextWeekday(business.timezone, Weekday.MONDAY);
const appointment = await prisma.appointment.create({
data: {
businessId: business.businessId,
staffId,
customerId,
status: "CONFIRMED",
startAt: wallClockToInstant(monday, 12 * 60, business.timezone),
endAt: wallClockToInstant(monday, 12 * 60 + 30, business.timezone),
services: {
create: [{ serviceId: business.haircutServiceId, priceAtBooking: 20, durationAtBookingMinutes: 30 }],
},
},
});
const blocked = await createBreak(prisma, {
staffId,
businessTimezone: business.timezone,
input: { label: "Lunch", startMinutes: 12 * 60, endMinutes: 13 * 60, days: [Weekday.MONDAY] },
});
expect(blocked.success).toBe(false);
if (blocked.success) throw new Error("unreachable");
expect(blocked.conflicts.map((c) => c.id)).toContain(appointment.id);
const acknowledged = await createBreak(prisma, {
staffId,
businessTimezone: business.timezone,
input: { label: "Lunch", startMinutes: 12 * 60, endMinutes: 13 * 60, days: [Weekday.MONDAY] },
acknowledgeConflicts: true,
});
expect(acknowledged.success).toBe(true);
});
it("returns a graceful failure — not a throw — when a staff member tries to update another staff member's break", async () => {
const { staffId: owner } = await createTestStaff(business);
const { staffId: intruder } = await createTestStaff(business);
const created = await createBreak(prisma, {
staffId: owner,
businessTimezone: business.timezone,
input: { label: "Lunch", startMinutes: 12 * 60, endMinutes: 13 * 60, days: [Weekday.TUESDAY] },
});
if (!created.success) throw new Error("setup failed");
const result = await updateBreak(prisma, {
breakId: created.break.id,
staffId: intruder,
businessTimezone: business.timezone,
input: { label: "Hijacked", startMinutes: 12 * 60, endMinutes: 13 * 60, days: [Weekday.TUESDAY] },
});
expect(result.success).toBe(false);
if (result.success) throw new Error("unreachable");
expect(result.validation.issues[0]?.code).toBe("PERMISSION_DENIED");
// Unchanged in the database — the attempted hijack had no effect.
const stillOwned = await prisma.break.findUniqueOrThrow({ where: { id: created.break.id } });
expect(stillOwned.label).toBe("Lunch");
});
it("returns {success:false} — not a throw — when a staff member tries to delete another staff member's break", async () => {
const { staffId: owner } = await createTestStaff(business);
const { staffId: intruder } = await createTestStaff(business);
const created = await createBreak(prisma, {
staffId: owner,
businessTimezone: business.timezone,
input: { label: "Lunch", startMinutes: 12 * 60, endMinutes: 13 * 60, days: [Weekday.WEDNESDAY] },
});
if (!created.success) throw new Error("setup failed");
const result = await deleteBreak(prisma, { breakId: created.break.id, staffId: intruder });
expect(result.success).toBe(false);
const stillExists = await prisma.break.findUnique({ where: { id: created.break.id } });
expect(stillExists).not.toBeNull();
});
});
describe("time off", () => {
it("blocks time off that overlaps a future appointment, and Save Anyway bypasses it", async () => {
const { staffId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
const monday = nextWeekday(business.timezone, Weekday.MONDAY);
const appointment = await prisma.appointment.create({
data: {
businessId: business.businessId,
staffId,
customerId,
status: "CONFIRMED",
startAt: wallClockToInstant(monday, 12 * 60, business.timezone),
endAt: wallClockToInstant(monday, 12 * 60 + 30, business.timezone),
services: {
create: [{ serviceId: business.haircutServiceId, priceAtBooking: 20, durationAtBookingMinutes: 30 }],
},
},
});
const blocked = await createTimeOff(prisma, {
staffId,
businessTimezone: business.timezone,
input: { startDate: monday, endDate: monday, reason: "Day off" },
});
expect(blocked.success).toBe(false);
if (blocked.success) throw new Error("unreachable");
expect(blocked.conflicts.map((c) => c.id)).toContain(appointment.id);
const acknowledged = await createTimeOff(prisma, {
staffId,
businessTimezone: business.timezone,
input: { startDate: monday, endDate: monday, reason: "Day off" },
acknowledgeConflicts: true,
});
expect(acknowledged.success).toBe(true);
});
it("a partial-day block only conflicts with appointments inside its own window", async () => {
const { staffId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
const monday = nextWeekday(business.timezone, Weekday.MONDAY);
// Appointment at 9am — outside the 12-1pm block below.
await prisma.appointment.create({
data: {
businessId: business.businessId,
staffId,
customerId,
status: "CONFIRMED",
startAt: wallClockToInstant(monday, 9 * 60, business.timezone),
endAt: wallClockToInstant(monday, 9 * 60 + 30, business.timezone),
services: {
create: [{ serviceId: business.haircutServiceId, priceAtBooking: 20, durationAtBookingMinutes: 30 }],
},
},
});
const result = await createTimeOff(prisma, {
staffId,
businessTimezone: business.timezone,
input: { startDate: monday, endDate: monday, startMinutes: 12 * 60, endMinutes: 13 * 60, reason: "Meeting" },
});
expect(result.success).toBe(true); // no conflict — the 9am appointment is outside the blocked window
});
it("returns a graceful failure — not a throw — when a staff member tries to update another staff member's time off", async () => {
const { staffId: owner } = await createTestStaff(business);
const { staffId: intruder } = await createTestStaff(business);
const monday = nextWeekday(business.timezone, Weekday.MONDAY);
const created = await createTimeOff(prisma, {
staffId: owner,
businessTimezone: business.timezone,
input: { startDate: monday, endDate: monday, reason: "Day off" },
});
if (!created.success) throw new Error("setup failed");
const result = await updateTimeOff(prisma, {
timeOffId: created.timeOff.id,
staffId: intruder,
businessTimezone: business.timezone,
input: { startDate: monday, endDate: monday, reason: "Hijacked" },
});
expect(result.success).toBe(false);
if (result.success) throw new Error("unreachable");
expect(result.validation.issues[0]?.code).toBe("PERMISSION_DENIED");
});
it("returns {success:false} — not a throw — when a staff member tries to delete another staff member's time off", async () => {
const { staffId: owner } = await createTestStaff(business);
const { staffId: intruder } = await createTestStaff(business);
const monday = nextWeekday(business.timezone, Weekday.MONDAY);
const created = await createTimeOff(prisma, {
staffId: owner,
businessTimezone: business.timezone,
input: { startDate: monday, endDate: monday, reason: "Day off" },
});
if (!created.success) throw new Error("setup failed");
const result = await deleteTimeOff(prisma, { timeOffId: created.timeOff.id, staffId: intruder });
expect(result.success).toBe(false);
const stillExists = await prisma.timeOff.findUnique({ where: { id: created.timeOff.id } });
expect(stillExists).not.toBeNull();
});
});
});
@@ -0,0 +1,137 @@
import { describe, expect, it } from "vitest";
import { Weekday } from "@/generated/prisma/enums";
import {
StaffPermissionDeniedError,
assertOwnsResource,
validateBreakInput,
validateTimeOffInput,
validateWorkingHoursInput,
} from "../staff.service";
describe("assertOwnsResource", () => {
it("does nothing when the acting staff member owns the resource", () => {
expect(() => assertOwnsResource("staff-1", "staff-1")).not.toThrow();
});
it("throws StaffPermissionDeniedError when they don't match", () => {
expect(() => assertOwnsResource("staff-1", "staff-2")).toThrow(StaffPermissionDeniedError);
});
});
describe("validateWorkingHoursInput", () => {
it("accepts a day marked off with no times", () => {
const result = validateWorkingHoursInput([{ dayOfWeek: Weekday.MONDAY, isOff: true }]);
expect(result.valid).toBe(true);
});
it("requires start and end minutes for a working day", () => {
const result = validateWorkingHoursInput([{ dayOfWeek: Weekday.MONDAY, isOff: false }]);
expect(result.valid).toBe(false);
expect(result.issues[0]?.code).toBe("INVALID_INPUT");
});
it("rejects start >= end", () => {
const result = validateWorkingHoursInput([
{ dayOfWeek: Weekday.TUESDAY, isOff: false, startMinutes: 600, endMinutes: 600 },
]);
expect(result.valid).toBe(false);
expect(result.issues[0]?.code).toBe("INVALID_TIME_RANGE");
});
it("rejects times outside a single day", () => {
const result = validateWorkingHoursInput([
{ dayOfWeek: Weekday.WEDNESDAY, isOff: false, startMinutes: 0, endMinutes: 25 * 60 },
]);
expect(result.valid).toBe(false);
expect(result.issues[0]?.code).toBe("INVALID_TIME_RANGE");
});
it("accepts a valid full week", () => {
const result = validateWorkingHoursInput([
{ dayOfWeek: Weekday.MONDAY, isOff: false, startMinutes: 9 * 60, endMinutes: 17 * 60 },
{ dayOfWeek: Weekday.SUNDAY, isOff: true },
]);
expect(result.valid).toBe(true);
});
});
describe("validateBreakInput", () => {
const base = { label: "Lunch", startMinutes: 12 * 60, endMinutes: 13 * 60, days: [Weekday.MONDAY] };
it("accepts a valid break", () => {
expect(validateBreakInput(base).valid).toBe(true);
});
it("rejects an empty label", () => {
const result = validateBreakInput({ ...base, label: " " });
expect(result.valid).toBe(false);
expect(result.issues[0]?.code).toBe("INVALID_INPUT");
});
it("rejects no days selected", () => {
const result = validateBreakInput({ ...base, days: [] });
expect(result.valid).toBe(false);
expect(result.issues[0]?.code).toBe("NO_DAYS_SELECTED");
});
it("rejects start >= end", () => {
const result = validateBreakInput({ ...base, startMinutes: 13 * 60, endMinutes: 13 * 60 });
expect(result.valid).toBe(false);
expect(result.issues[0]?.code).toBe("INVALID_TIME_RANGE");
});
});
describe("validateTimeOffInput", () => {
it("accepts a whole-day range with no times", () => {
const result = validateTimeOffInput({ startDate: "2026-08-10", endDate: "2026-08-12" });
expect(result.valid).toBe(true);
});
it("rejects start date after end date", () => {
const result = validateTimeOffInput({ startDate: "2026-08-12", endDate: "2026-08-10" });
expect(result.valid).toBe(false);
expect(result.issues[0]?.code).toBe("INVALID_INPUT");
});
it("rejects providing only one of startMinutes/endMinutes", () => {
const result = validateTimeOffInput({
startDate: "2026-08-10",
endDate: "2026-08-10",
startMinutes: 600,
});
expect(result.valid).toBe(false);
expect(result.issues[0]?.code).toBe("INVALID_INPUT");
});
it("rejects a partial-day block spanning more than one day", () => {
const result = validateTimeOffInput({
startDate: "2026-08-10",
endDate: "2026-08-11",
startMinutes: 600,
endMinutes: 660,
});
expect(result.valid).toBe(false);
expect(result.issues[0]?.code).toBe("INVALID_INPUT");
});
it("rejects a partial-day block with start >= end", () => {
const result = validateTimeOffInput({
startDate: "2026-08-10",
endDate: "2026-08-10",
startMinutes: 660,
endMinutes: 660,
});
expect(result.valid).toBe(false);
expect(result.issues[0]?.code).toBe("INVALID_TIME_RANGE");
});
it("accepts a valid partial-day block (Block Time)", () => {
const result = validateTimeOffInput({
startDate: "2026-08-10",
endDate: "2026-08-10",
startMinutes: 600,
endMinutes: 660,
});
expect(result.valid).toBe(true);
});
});
+627
View File
@@ -0,0 +1,627 @@
import type { PrismaClient } from "@/generated/prisma/client";
import { NotificationCategory, NotificationChannel, Weekday } from "@/generated/prisma/enums";
import {
calendarDateOf,
calendarDateToUtcMidnight,
endOfBusinessDay,
minutesFromMidnight,
startOfBusinessDay,
weekdayOf,
} from "@/lib/time/business-time";
import type { CalendarDate } from "@/lib/time/business-time";
import { fail, ok } from "@/lib/validation-result";
import type { ValidationResult } from "@/lib/validation-result";
/**
* Everything about a staff member's own operational setup: profile fields
* they're allowed to edit themselves, working hours, breaks, and time off
* (including the one-off "Block Time" case — a TimeOff row with minutes
* set, see schema.prisma). Deliberately does NOT touch availability
* calculation or appointment status — those stay in AvailabilityService/
* StaffAvailabilityService and AppointmentService respectively. This
* service only manages the *inputs* those read; it's the write side of
* the same data StaffAvailabilityService reads.
*/
export type StaffServiceErrorCode =
| "INVALID_INPUT"
| "INVALID_TIME_RANGE"
| "NO_DAYS_SELECTED"
| "STAFF_NOT_FOUND"
| "BREAK_NOT_FOUND"
| "TIME_OFF_NOT_FOUND"
| "APPOINTMENT_CONFLICTS"
| "PERMISSION_DENIED";
export class StaffPermissionDeniedError extends Error {
constructor(message = "You don't have permission to manage this staff member's schedule.") {
super(message);
this.name = "StaffPermissionDeniedError";
}
}
/** Every mutation here takes the acting staff member's own id and asserts the resource belongs to them — a staff member manages only their own schedule this phase (see docs/phase-5-summary.md, "Permission model"). */
export function assertOwnsResource(resourceStaffId: string, actingStaffId: string): void {
if (resourceStaffId !== actingStaffId) {
throw new StaffPermissionDeniedError();
}
}
// ── Profile ─────────────────────────────────────────────────────────────
export async function getStaffProfileByUserId(prisma: PrismaClient, userId: string) {
return prisma.staffProfile.findUnique({
where: { userId },
include: {
user: { select: { id: true, name: true, email: true, phone: true, image: true } },
services: { include: { service: true } },
business: { select: { id: true, name: true, timezone: true } },
role: { select: { name: true } },
},
});
}
export interface UpdateStaffProfileInput {
staffId: string;
name?: string;
phone?: string;
image?: string;
bio?: string;
/** "Public visibility" in the brief — whether customers can find/book this barber online. Reuses the existing field rather than adding a duplicate flag. */
isBookableOnline?: boolean;
}
/**
* Only the fields the brief explicitly allows a barber to change themselves
* — service eligibility, business settings, and other staff accounts are
* deliberately not accepted here (owner-only, out of scope this phase).
*/
export async function updateStaffProfile(
prisma: PrismaClient,
input: UpdateStaffProfileInput,
): Promise<{ success: boolean }> {
const staff = await prisma.staffProfile.findUnique({
where: { id: input.staffId },
select: { userId: true },
});
if (!staff) return { success: false };
await prisma.$transaction([
prisma.user.update({
where: { id: staff.userId },
data: {
...(input.name !== undefined ? { name: input.name } : {}),
...(input.phone !== undefined ? { phone: input.phone || null } : {}),
...(input.image !== undefined ? { image: input.image || null } : {}),
},
}),
prisma.staffProfile.update({
where: { id: input.staffId },
data: {
...(input.bio !== undefined ? { bio: input.bio || null } : {}),
...(input.isBookableOnline !== undefined
? { isBookableOnline: input.isBookableOnline }
: {}),
},
}),
]);
return { success: true };
}
// ── Working hours ───────────────────────────────────────────────────────
export interface WorkingHoursDayInput {
dayOfWeek: Weekday;
isOff: boolean;
startMinutes?: number;
endMinutes?: number;
}
export function validateWorkingHoursInput(
days: WorkingHoursDayInput[],
): ValidationResult<StaffServiceErrorCode> {
for (const day of days) {
if (day.isOff) continue;
if (day.startMinutes === undefined || day.endMinutes === undefined) {
return fail({
code: "INVALID_INPUT",
message: `${day.dayOfWeek}: start and end time are required for a working day.`,
});
}
if (day.startMinutes < 0 || day.endMinutes > 24 * 60) {
return fail({
code: "INVALID_TIME_RANGE",
message: `${day.dayOfWeek}: times must fall within a single day.`,
});
}
if (day.startMinutes >= day.endMinutes) {
return fail({
code: "INVALID_TIME_RANGE",
message: `${day.dayOfWeek}: start time must be before end time.`,
});
}
}
return ok();
}
export async function getWorkingHours(prisma: PrismaClient, staffId: string) {
return prisma.workingHours.findMany({ where: { staffId }, orderBy: { dayOfWeek: "asc" } });
}
export async function setWorkingHours(
prisma: PrismaClient,
params: { staffId: string; days: WorkingHoursDayInput[] },
): Promise<{ success: boolean; validation: ValidationResult<StaffServiceErrorCode> }> {
const validation = validateWorkingHoursInput(params.days);
if (!validation.valid) return { success: false, validation };
await prisma.$transaction(
params.days.map((day) =>
prisma.workingHours.upsert({
where: { staffId_dayOfWeek: { staffId: params.staffId, dayOfWeek: day.dayOfWeek } },
create: {
staffId: params.staffId,
dayOfWeek: day.dayOfWeek,
isOff: day.isOff,
startMinutes: day.isOff ? null : (day.startMinutes ?? null),
endMinutes: day.isOff ? null : (day.endMinutes ?? null),
},
update: {
isOff: day.isOff,
startMinutes: day.isOff ? null : (day.startMinutes ?? null),
endMinutes: day.isOff ? null : (day.endMinutes ?? null),
},
}),
),
);
return { success: true, validation: ok() };
}
// ── Breaks ──────────────────────────────────────────────────────────────
const APPOINTMENT_CONFLICT_INCLUDE = {
services: { include: { service: true } },
customer: true,
} as const;
export interface BreakInput {
label: string;
startMinutes: number;
endMinutes: number;
days: Weekday[];
}
export function validateBreakInput(input: BreakInput): ValidationResult<StaffServiceErrorCode> {
if (!input.label.trim())
return fail({ code: "INVALID_INPUT", message: "Give this break a label." });
if (input.days.length === 0)
return fail({ code: "NO_DAYS_SELECTED", message: "Select at least one day for this break." });
if (
input.startMinutes < 0 ||
input.endMinutes > 24 * 60 ||
input.startMinutes >= input.endMinutes
) {
return fail({
code: "INVALID_TIME_RANGE",
message: "Break start time must be before its end time.",
});
}
return ok();
}
export async function listBreaks(prisma: PrismaClient, staffId: string) {
return prisma.break.findMany({
where: { staffId },
include: { days: true },
orderBy: { startMinutes: "asc" },
});
}
/** Future, non-cancelled appointments that would fall inside a recurring break on any of `days`. Breaks recur indefinitely, so only future appointments are ever relevant — one from last month isn't a live conflict. */
export async function findBreakConflicts(
prisma: PrismaClient,
params: {
staffId: string;
businessTimezone: string;
days: Weekday[];
startMinutes: number;
endMinutes: number;
excludeBreakId?: string;
},
) {
const appointments = await prisma.appointment.findMany({
where: {
staffId: params.staffId,
status: { notIn: ["CANCELLED", "NO_SHOW"] },
startAt: { gte: new Date() },
},
include: APPOINTMENT_CONFLICT_INCLUDE,
orderBy: { startAt: "asc" },
});
return appointments.filter((a) => {
const weekday = weekdayOf(calendarDateOf(a.startAt, params.businessTimezone));
if (!params.days.includes(weekday)) return false;
const aStart = minutesFromMidnight(a.startAt, params.businessTimezone);
const aEnd = minutesFromMidnight(a.endAt, params.businessTimezone);
return aStart < params.endMinutes && aEnd > params.startMinutes;
});
}
export async function createBreak(
prisma: PrismaClient,
params: {
staffId: string;
businessTimezone: string;
input: BreakInput;
acknowledgeConflicts?: boolean;
},
) {
const validation = validateBreakInput(params.input);
if (!validation.valid) return { success: false as const, validation, conflicts: [] };
if (!params.acknowledgeConflicts) {
const conflicts = await findBreakConflicts(prisma, {
staffId: params.staffId,
businessTimezone: params.businessTimezone,
...params.input,
});
if (conflicts.length > 0) {
return {
success: false as const,
conflicts,
validation: fail({
code: "APPOINTMENT_CONFLICTS",
message: "This break overlaps existing appointments.",
}),
};
}
}
const created = await prisma.break.create({
data: {
staffId: params.staffId,
label: params.input.label,
startMinutes: params.input.startMinutes,
endMinutes: params.input.endMinutes,
days: { create: params.input.days.map((dayOfWeek) => ({ dayOfWeek })) },
},
});
return { success: true as const, break: created, conflicts: [], validation: ok() };
}
export async function updateBreak(
prisma: PrismaClient,
params: {
breakId: string;
staffId: string;
businessTimezone: string;
input: BreakInput;
acknowledgeConflicts?: boolean;
},
) {
const existing = await prisma.break.findUnique({ where: { id: params.breakId } });
if (!existing)
return {
success: false as const,
validation: fail({ code: "BREAK_NOT_FOUND" as const, message: "Break not found." }),
conflicts: [],
};
if (existing.staffId !== params.staffId) {
return {
success: false as const,
validation: fail({ code: "PERMISSION_DENIED" as const, message: "You can only manage your own breaks." }),
conflicts: [],
};
}
const validation = validateBreakInput(params.input);
if (!validation.valid) return { success: false as const, validation, conflicts: [] };
if (!params.acknowledgeConflicts) {
const conflicts = await findBreakConflicts(prisma, {
staffId: params.staffId,
businessTimezone: params.businessTimezone,
...params.input,
excludeBreakId: params.breakId,
});
if (conflicts.length > 0) {
return {
success: false as const,
conflicts,
validation: fail({
code: "APPOINTMENT_CONFLICTS",
message: "This break overlaps existing appointments.",
}),
};
}
}
await prisma.$transaction([
prisma.breakDay.deleteMany({ where: { breakId: params.breakId } }),
prisma.break.update({
where: { id: params.breakId },
data: {
label: params.input.label,
startMinutes: params.input.startMinutes,
endMinutes: params.input.endMinutes,
days: { create: params.input.days.map((dayOfWeek) => ({ dayOfWeek })) },
},
}),
]);
return { success: true as const, conflicts: [], validation: ok() };
}
export async function deleteBreak(
prisma: PrismaClient,
params: { breakId: string; staffId: string },
): Promise<{ success: boolean }> {
const existing = await prisma.break.findUnique({ where: { id: params.breakId } });
if (!existing) return { success: false };
if (existing.staffId !== params.staffId) return { success: false };
await prisma.break.delete({ where: { id: params.breakId } });
return { success: true };
}
// ── Time off (also represents one-off "Block Time" — see schema.prisma) ──
export interface TimeOffInput {
startDate: CalendarDate;
endDate: CalendarDate;
reason?: string;
/** Both set = a partial-day block ("Block Time"); both omitted = whole-day time off. */
startMinutes?: number;
endMinutes?: number;
}
export function validateTimeOffInput(input: TimeOffInput): ValidationResult<StaffServiceErrorCode> {
if (input.startDate > input.endDate) {
return fail({
code: "INVALID_INPUT",
message: "Start date must be on or before the end date.",
});
}
const hasStart = input.startMinutes !== undefined;
const hasEnd = input.endMinutes !== undefined;
if (hasStart !== hasEnd) {
return fail({
code: "INVALID_INPUT",
message:
"Provide both a start and end time for a partial-day block, or neither for a full day.",
});
}
if (hasStart && hasEnd) {
if (input.startDate !== input.endDate) {
return fail({
code: "INVALID_INPUT",
message: "A specific-time block can only cover a single day.",
});
}
if (
input.startMinutes! < 0 ||
input.endMinutes! > 24 * 60 ||
input.startMinutes! >= input.endMinutes!
) {
return fail({ code: "INVALID_TIME_RANGE", message: "Start time must be before end time." });
}
}
return ok();
}
/** Split by calendar date, not business time zone — @db.Date columns are compared as UTC midnight regardless of zone, see business-time.ts. */
export async function listTimeOff(prisma: PrismaClient, staffId: string) {
const rows = await prisma.timeOff.findMany({
where: { staffId },
orderBy: { startDate: "desc" },
});
const today = calendarDateToUtcMidnight(new Date().toISOString().slice(0, 10));
return {
upcoming: rows
.filter((r) => r.endDate >= today)
.sort((a, b) => a.startDate.getTime() - b.startDate.getTime()),
past: rows.filter((r) => r.endDate < today),
};
}
/** Time off is only ever checked against appointments, never against other time off — there is no "excludeTimeOffId" case because two time-off rows can't conflict with each other. */
export async function findTimeOffConflicts(
prisma: PrismaClient,
params: {
staffId: string;
businessTimezone: string;
startDate: CalendarDate;
endDate: CalendarDate;
startMinutes?: number;
endMinutes?: number;
},
) {
const rangeStart = startOfBusinessDay(params.startDate, params.businessTimezone);
const rangeEnd = endOfBusinessDay(params.endDate, params.businessTimezone);
const appointments = await prisma.appointment.findMany({
where: {
staffId: params.staffId,
status: { notIn: ["CANCELLED", "NO_SHOW"] },
startAt: { lt: rangeEnd },
endAt: { gt: rangeStart },
},
include: APPOINTMENT_CONFLICT_INCLUDE,
orderBy: { startAt: "asc" },
});
if (params.startMinutes === undefined || params.endMinutes === undefined) return appointments;
return appointments.filter((a) => {
const aStart = minutesFromMidnight(a.startAt, params.businessTimezone);
const aEnd = minutesFromMidnight(a.endAt, params.businessTimezone);
return aStart < params.endMinutes! && aEnd > params.startMinutes!;
});
}
export async function createTimeOff(
prisma: PrismaClient,
params: {
staffId: string;
businessTimezone: string;
input: TimeOffInput;
acknowledgeConflicts?: boolean;
},
) {
const validation = validateTimeOffInput(params.input);
if (!validation.valid) return { success: false as const, validation, conflicts: [] };
if (!params.acknowledgeConflicts) {
const conflicts = await findTimeOffConflicts(prisma, {
staffId: params.staffId,
businessTimezone: params.businessTimezone,
...params.input,
});
if (conflicts.length > 0) {
return {
success: false as const,
conflicts,
validation: fail({
code: "APPOINTMENT_CONFLICTS",
message: "This time off overlaps existing appointments.",
}),
};
}
}
const created = await prisma.timeOff.create({
data: {
staffId: params.staffId,
startDate: calendarDateToUtcMidnight(params.input.startDate),
endDate: calendarDateToUtcMidnight(params.input.endDate),
reason: params.input.reason,
startMinutes: params.input.startMinutes ?? null,
endMinutes: params.input.endMinutes ?? null,
status: "APPROVED",
},
});
return { success: true as const, timeOff: created, conflicts: [], validation: ok() };
}
export async function updateTimeOff(
prisma: PrismaClient,
params: {
timeOffId: string;
staffId: string;
businessTimezone: string;
input: TimeOffInput;
acknowledgeConflicts?: boolean;
},
) {
const existing = await prisma.timeOff.findUnique({ where: { id: params.timeOffId } });
if (!existing)
return {
success: false as const,
validation: fail({ code: "TIME_OFF_NOT_FOUND" as const, message: "Time off not found." }),
conflicts: [],
};
if (existing.staffId !== params.staffId) {
return {
success: false as const,
validation: fail({ code: "PERMISSION_DENIED" as const, message: "You can only manage your own time off." }),
conflicts: [],
};
}
const validation = validateTimeOffInput(params.input);
if (!validation.valid) return { success: false as const, validation, conflicts: [] };
if (!params.acknowledgeConflicts) {
const conflicts = await findTimeOffConflicts(prisma, {
staffId: params.staffId,
businessTimezone: params.businessTimezone,
...params.input,
});
if (conflicts.length > 0) {
return {
success: false as const,
conflicts,
validation: fail({
code: "APPOINTMENT_CONFLICTS",
message: "This time off overlaps existing appointments.",
}),
};
}
}
await prisma.timeOff.update({
where: { id: params.timeOffId },
data: {
startDate: calendarDateToUtcMidnight(params.input.startDate),
endDate: calendarDateToUtcMidnight(params.input.endDate),
reason: params.input.reason,
startMinutes: params.input.startMinutes ?? null,
endMinutes: params.input.endMinutes ?? null,
},
});
return { success: true as const, conflicts: [], validation: ok() };
}
export async function deleteTimeOff(
prisma: PrismaClient,
params: { timeOffId: string; staffId: string },
): Promise<{ success: boolean }> {
const existing = await prisma.timeOff.findUnique({ where: { id: params.timeOffId } });
if (!existing) return { success: false };
if (existing.staffId !== params.staffId) return { success: false };
// Only future time off is editable/removable — see the brief's "Edit or remove future time off".
await prisma.timeOff.delete({ where: { id: params.timeOffId } });
return { success: true };
}
// ── Notification preferences ───────────────────────────────────────────
// UI only this phase — no real delivery. Every (category, channel) pair
// defaults to enabled (matches the schema's `isEnabled @default(true)`),
// so a user with no rows yet still sees a fully-populated, all-on grid.
const NOTIFICATION_CATEGORIES = Object.values(NotificationCategory);
const NOTIFICATION_CHANNELS = Object.values(NotificationChannel);
export interface NotificationPreferenceCell {
category: NotificationCategory;
channel: NotificationChannel;
isEnabled: boolean;
}
export async function getNotificationPreferences(
prisma: PrismaClient,
userId: string,
): Promise<NotificationPreferenceCell[]> {
const rows = await prisma.notificationPreference.findMany({ where: { userId } });
const byKey = new Map(rows.map((r) => [`${r.category}:${r.channel}`, r.isEnabled]));
const cells: NotificationPreferenceCell[] = [];
for (const category of NOTIFICATION_CATEGORIES) {
for (const channel of NOTIFICATION_CHANNELS) {
cells.push({ category, channel, isEnabled: byKey.get(`${category}:${channel}`) ?? true });
}
}
return cells;
}
export async function setNotificationPreferences(
prisma: PrismaClient,
userId: string,
updates: NotificationPreferenceCell[],
): Promise<{ success: true }> {
await prisma.$transaction(
updates.map((u) =>
prisma.notificationPreference.upsert({
where: { userId_category_channel: { userId, category: u.category, channel: u.channel } },
create: { userId, category: u.category, channel: u.channel, isEnabled: u.isEnabled },
update: { isEnabled: u.isEnabled },
}),
),
);
return { success: true };
}