Delivered: Super Administrator — env-var provisioned, no StaffProfile at all (structural exclusion from Team/barber-picker/appointments/staff-totals, not a filter). hasPermission short-circuit + new getActingBusinessContext() helper retrofitted across 16 owner pages so it can actually manage the business. Media Manager — one upload/library/external-URL modal + field picker, backing every image field app-wide. Local-disk storage + Docker volume, SVG rejected, size-capped. Welcome backgrounds — desktop/mobile images with CSS breakpoint swap. Business Branding settings — logo/cover/welcome/favicon/icon/colours all editable post-install; cover image finally rendered (was dead config). Service descriptions — now surfaced on Home and in the booking flow. Loyalty card — live preview, ColorPicker/MediaPicker/IconPicker, flip removed for a static premium-styled card, Loyalty added to bottom nav. Fixed a real pre-existing bug where the card's configured colour/image/icon were stored but never actually rendered. Home Screen Redesign — hero gallery + sticky Services/Portfolio/Reviews/Details tabs, backed by a new unified gallery model/service (no hero/portfolio duplication). Details tab surfaces social links and a map that were never shown before. Google Reviews — real sync pipeline (24h cron, CRON_SECRET-gated, mirrors the existing notification cron exactly), DB-only customer reads, owner hide/unhide + policy management page. Architecture cleanup — SettingsPageShell extracted from 6 duplicated page wrappers; fixed a second pre-existing bug where branding image fields could never actually be cleared once set. Verification: 418 tests passing (236 unit + 182 integration, all against a real MySQL database), full typecheck/lint clean across src, and a real Docker build+run cycle — which caught and fixed a genuine bug (scripts/ wasn't copied into the production image, so Super Admin sync would have crashed on every real deployment).
588 lines
22 KiB
TypeScript
588 lines
22 KiB
TypeScript
import type { PrismaClient } from "@/generated/prisma/client";
|
|
import { Weekday } from "@/generated/prisma/enums";
|
|
import {
|
|
calendarDateOf,
|
|
calendarDateToUtcMidnight,
|
|
eachCalendarDate,
|
|
endOfBusinessDay,
|
|
minutesFromMidnight,
|
|
startOfBusinessDay,
|
|
todayIn,
|
|
wallClockToInstant,
|
|
weekdayOf,
|
|
} from "@/lib/time/business-time";
|
|
import type { CalendarDate } from "@/lib/time/business-time";
|
|
import {
|
|
checkBusinessDailyLimit,
|
|
checkMaxAdvance,
|
|
checkMinNotice,
|
|
checkStaffDailyLimit,
|
|
combinedServiceDuration,
|
|
computeEffectiveBuffer,
|
|
} from "./business-rules.service";
|
|
import type { BookingRuleConfig } from "./business-rules.service";
|
|
import { computeFreeWindows, generateSlotStarts } from "./slot-calculator";
|
|
import {
|
|
businessOpenByWeekdayFrom,
|
|
getStaffFreeWindowsForRange,
|
|
} from "./staff-availability.service";
|
|
import { fail, mergeValidation, ok } from "./types";
|
|
import type { ServiceRequirement, TimeWindow, ValidationResult } from "./types";
|
|
|
|
/**
|
|
* The customer-facing query layer: "what services/barbers/dates/times can
|
|
* actually be booked right now". Built entirely on StaffAvailabilityService
|
|
* (per-staff free windows) and BusinessRulesService (booking-window/limit
|
|
* checks) — this module owns combining them across staff and services, not
|
|
* the underlying per-staff or per-rule logic.
|
|
*/
|
|
|
|
export interface AvailableService {
|
|
id: string;
|
|
categoryId: string;
|
|
name: string;
|
|
/** Collapses in the UI when null/empty — see ServiceCard and ServiceSelector. */
|
|
description: string | null;
|
|
/** Set via the Media Manager — see src/features/media/components/media-picker.tsx. */
|
|
imageUrl: string | null;
|
|
durationMinutes: number;
|
|
price: string; // Decimal serialized to string — currency formatting is a presentation concern, not this layer's
|
|
bufferBeforeMinutes: number;
|
|
bufferAfterMinutes: number;
|
|
}
|
|
|
|
export async function getAvailableServices(
|
|
prisma: PrismaClient,
|
|
params: { businessId: string; categoryId?: string; staffId?: string },
|
|
): Promise<AvailableService[]> {
|
|
const services = await prisma.service.findMany({
|
|
where: {
|
|
businessId: params.businessId,
|
|
isActive: true,
|
|
allowOnlineBooking: true,
|
|
...(params.categoryId ? { categoryId: params.categoryId } : {}),
|
|
...(params.staffId ? { staff: { some: { staffId: params.staffId } } } : {}),
|
|
},
|
|
orderBy: [{ displayOrder: "asc" }, { name: "asc" }],
|
|
});
|
|
|
|
return services.map((s) => ({
|
|
id: s.id,
|
|
categoryId: s.categoryId,
|
|
name: s.name,
|
|
description: s.description,
|
|
imageUrl: s.imageUrl,
|
|
durationMinutes: s.durationMinutes,
|
|
price: s.price.toString(),
|
|
bufferBeforeMinutes: s.bufferBeforeMinutes,
|
|
bufferAfterMinutes: s.bufferAfterMinutes,
|
|
}));
|
|
}
|
|
|
|
export interface AvailableStaffMember {
|
|
id: string;
|
|
name: string;
|
|
avatarUrl: string | null;
|
|
}
|
|
|
|
/** Staff who can perform every requested service (a StaffService row must exist for each), are active, and are bookable online. */
|
|
export async function getAvailableStaff(
|
|
prisma: PrismaClient,
|
|
params: { businessId: string; serviceIds: string[] },
|
|
): Promise<AvailableStaffMember[]> {
|
|
if (params.serviceIds.length === 0) return [];
|
|
|
|
const staff = await prisma.staffProfile.findMany({
|
|
where: {
|
|
businessId: params.businessId,
|
|
status: "ACTIVE",
|
|
isBookableOnline: true,
|
|
AND: params.serviceIds.map((serviceId) => ({ services: { some: { serviceId } } })),
|
|
},
|
|
include: { user: { select: { name: true, image: true } } },
|
|
});
|
|
|
|
return staff.map((s) => ({
|
|
id: s.id,
|
|
name: s.user.name ?? "Staff member",
|
|
avatarUrl: s.user.image,
|
|
}));
|
|
}
|
|
|
|
export interface ResolvedBookingContext {
|
|
business: { id: string; timezone: string };
|
|
bookingRule: BookingRuleConfig;
|
|
services: ServiceRequirement[];
|
|
totalDurationMinutes: number;
|
|
effectiveBuffer: { beforeMinutes: number; afterMinutes: number };
|
|
eligibleStaffIds: string[];
|
|
}
|
|
|
|
const DEFAULT_BOOKING_RULE: BookingRuleConfig = {
|
|
minNoticeMinutes: 120,
|
|
maxAdvanceDays: 60,
|
|
cancellationWindowHours: 24,
|
|
rescheduleWindowHours: 24,
|
|
slotIntervalMinutes: 30,
|
|
bufferBeforeMinutes: 0,
|
|
bufferAfterMinutes: 5,
|
|
maxAppointmentsPerDay: null,
|
|
maxAppointmentsPerStaffPerDay: null,
|
|
customerCanChooseStaff: true,
|
|
allowGuestBooking: true,
|
|
requireApproval: false,
|
|
};
|
|
|
|
/**
|
|
* Shared setup for every availability query and for booking validation:
|
|
* resolve the business, its booking rules, the requested services, and
|
|
* which staff can actually perform all of them. Throws (via a
|
|
* ValidationResult the caller should check) rather than silently
|
|
* returning an empty result, so callers get a meaningful reason.
|
|
*/
|
|
export async function resolveBookingContext(
|
|
prisma: PrismaClient,
|
|
params: { businessId: string; serviceIds: string[]; staffId?: string },
|
|
): Promise<{ context: ResolvedBookingContext | null; validation: ValidationResult }> {
|
|
if (params.serviceIds.length === 0) {
|
|
return { context: null, validation: fail({ code: "NO_SERVICES_SELECTED", message: "Select at least one service." }) };
|
|
}
|
|
|
|
const business = await prisma.business.findUnique({
|
|
where: { id: params.businessId },
|
|
select: { id: true, timezone: true, bookingRule: true },
|
|
});
|
|
if (!business) {
|
|
return { context: null, validation: fail({ code: "BUSINESS_NOT_FOUND", message: "Business not found." }) };
|
|
}
|
|
|
|
const services = await prisma.service.findMany({
|
|
where: { id: { in: params.serviceIds }, businessId: params.businessId, isActive: true },
|
|
});
|
|
if (services.length !== params.serviceIds.length) {
|
|
return {
|
|
context: null,
|
|
validation: fail({
|
|
code: "SERVICES_SPAN_MULTIPLE_BUSINESSES",
|
|
message: "One or more selected services are not available at this business.",
|
|
}),
|
|
};
|
|
}
|
|
|
|
const serviceRequirements: ServiceRequirement[] = services.map((s) => ({
|
|
serviceId: s.id,
|
|
durationMinutes: s.durationMinutes,
|
|
bufferBeforeMinutes: s.bufferBeforeMinutes,
|
|
bufferAfterMinutes: s.bufferAfterMinutes,
|
|
price: s.price.toString(),
|
|
}));
|
|
|
|
let eligibleStaffIds: string[];
|
|
if (params.staffId) {
|
|
const staffServiceCount = await prisma.staffService.count({
|
|
where: { staffId: params.staffId, serviceId: { in: params.serviceIds } },
|
|
});
|
|
const canPerformAll = staffServiceCount === params.serviceIds.length;
|
|
if (!canPerformAll) {
|
|
return {
|
|
context: null,
|
|
validation: fail({
|
|
code: "STAFF_CANNOT_PERFORM_SERVICE",
|
|
message: "This staff member doesn't offer one or more of the selected services.",
|
|
}),
|
|
};
|
|
}
|
|
eligibleStaffIds = [params.staffId];
|
|
} else {
|
|
const eligible = await getAvailableStaff(prisma, { businessId: params.businessId, serviceIds: params.serviceIds });
|
|
eligibleStaffIds = eligible.map((s) => s.id);
|
|
}
|
|
|
|
const bookingRule: BookingRuleConfig = business.bookingRule ?? DEFAULT_BOOKING_RULE;
|
|
|
|
return {
|
|
context: {
|
|
business: { id: business.id, timezone: business.timezone },
|
|
bookingRule,
|
|
services: serviceRequirements,
|
|
totalDurationMinutes: combinedServiceDuration(serviceRequirements),
|
|
effectiveBuffer: computeEffectiveBuffer(bookingRule, serviceRequirements),
|
|
eligibleStaffIds,
|
|
},
|
|
validation: ok(),
|
|
};
|
|
}
|
|
|
|
export interface AvailableDatesParams {
|
|
businessId: string;
|
|
serviceIds: string[];
|
|
staffId?: string;
|
|
fromDate: CalendarDate;
|
|
toDate: CalendarDate;
|
|
}
|
|
|
|
export async function getAvailableDates(
|
|
prisma: PrismaClient,
|
|
params: AvailableDatesParams,
|
|
): Promise<{ dates: CalendarDate[]; validation: ValidationResult }> {
|
|
const { context, validation } = await resolveBookingContext(prisma, params);
|
|
if (!context || !validation.valid) return { dates: [], validation };
|
|
if (context.eligibleStaffIds.length === 0) {
|
|
return { dates: [], validation: fail({ code: "STAFF_CANNOT_PERFORM_SERVICE", message: "No staff can perform the selected services." }) };
|
|
}
|
|
|
|
const businessHours = await prisma.businessHours.findMany({ where: { businessId: context.business.id } });
|
|
const businessOpenByWeekday = businessOpenByWeekdayFrom(businessHours);
|
|
|
|
const today = todayIn(context.business.timezone);
|
|
const now = new Date();
|
|
|
|
const perStaffWindows = await Promise.all(
|
|
context.eligibleStaffIds.map((staffId) =>
|
|
getStaffFreeWindowsForRange(prisma, {
|
|
staffId,
|
|
fromDate: params.fromDate,
|
|
toDate: params.toDate,
|
|
businessTimezone: context.business.timezone,
|
|
businessOpenByWeekday,
|
|
effectiveBuffer: context.effectiveBuffer,
|
|
}),
|
|
),
|
|
);
|
|
|
|
const businessCounts = await countAppointmentsPerDate(prisma, {
|
|
businessId: context.business.id,
|
|
fromDate: params.fromDate,
|
|
toDate: params.toDate,
|
|
businessTimezone: context.business.timezone,
|
|
});
|
|
const staffCounts = await Promise.all(
|
|
context.eligibleStaffIds.map((staffId) =>
|
|
countAppointmentsPerDate(prisma, {
|
|
businessId: context.business.id,
|
|
staffId,
|
|
fromDate: params.fromDate,
|
|
toDate: params.toDate,
|
|
businessTimezone: context.business.timezone,
|
|
}),
|
|
),
|
|
);
|
|
|
|
const availableDates: CalendarDate[] = [];
|
|
|
|
for (const date of eachCalendarDate(params.fromDate, params.toDate)) {
|
|
if (!checkMaxAdvance(date, today, context.bookingRule.maxAdvanceDays).valid) continue;
|
|
|
|
const businessCount = businessCounts.get(date) ?? 0;
|
|
if (!checkBusinessDailyLimit(businessCount, context.bookingRule.maxAppointmentsPerDay).valid) continue;
|
|
|
|
const hasAnyFittingSlot = context.eligibleStaffIds.some((staffId, i) => {
|
|
const staffCount = staffCounts[i]?.get(date) ?? 0;
|
|
if (!checkStaffDailyLimit(staffCount, context.bookingRule.maxAppointmentsPerStaffPerDay).valid) return false;
|
|
|
|
const freeWindows = perStaffWindows[i]?.get(date) ?? [];
|
|
const starts = generateSlotStarts(freeWindows, {
|
|
slotIntervalMinutes: context.bookingRule.slotIntervalMinutes,
|
|
durationMinutes: context.totalDurationMinutes,
|
|
});
|
|
return starts.some((startMinutes) => {
|
|
const instant = wallClockToInstant(date, startMinutes, context.business.timezone);
|
|
return checkMinNotice(instant, now, context.bookingRule.minNoticeMinutes).valid;
|
|
});
|
|
});
|
|
|
|
if (hasAnyFittingSlot) availableDates.push(date);
|
|
}
|
|
|
|
return { dates: availableDates, validation: ok() };
|
|
}
|
|
|
|
export interface TimeSlotOption {
|
|
startMinutes: number;
|
|
endMinutes: number;
|
|
startAt: string; // ISO instant
|
|
endAt: string;
|
|
staffOptions: string[];
|
|
}
|
|
|
|
export interface AvailableTimeSlotsParams {
|
|
businessId: string;
|
|
serviceIds: string[];
|
|
staffId?: string;
|
|
date: CalendarDate;
|
|
}
|
|
|
|
export async function getAvailableTimeSlots(
|
|
prisma: PrismaClient,
|
|
params: AvailableTimeSlotsParams,
|
|
): Promise<{ slots: TimeSlotOption[]; validation: ValidationResult }> {
|
|
const { context, validation } = await resolveBookingContext(prisma, params);
|
|
if (!context || !validation.valid) return { slots: [], validation };
|
|
if (context.eligibleStaffIds.length === 0) {
|
|
return { slots: [], validation: fail({ code: "STAFF_CANNOT_PERFORM_SERVICE", message: "No staff can perform the selected services." }) };
|
|
}
|
|
|
|
const today = todayIn(context.business.timezone);
|
|
const now = new Date();
|
|
|
|
const advanceCheck = checkMaxAdvance(params.date, today, context.bookingRule.maxAdvanceDays);
|
|
if (!advanceCheck.valid) return { slots: [], validation: advanceCheck };
|
|
|
|
const businessHours = await prisma.businessHours.findMany({ where: { businessId: context.business.id } });
|
|
const businessOpenByWeekday = businessOpenByWeekdayFrom(businessHours);
|
|
|
|
const businessCount = (
|
|
await countAppointmentsPerDate(prisma, {
|
|
businessId: context.business.id,
|
|
fromDate: params.date,
|
|
toDate: params.date,
|
|
businessTimezone: context.business.timezone,
|
|
})
|
|
).get(params.date) ?? 0;
|
|
if (!checkBusinessDailyLimit(businessCount, context.bookingRule.maxAppointmentsPerDay).valid) {
|
|
return { slots: [], validation: ok() }; // no slots, but this isn't a validation error for the query itself
|
|
}
|
|
|
|
const byStartMinute = new Map<number, TimeSlotOption>();
|
|
|
|
for (const staffId of context.eligibleStaffIds) {
|
|
const staffCount =
|
|
(
|
|
await countAppointmentsPerDate(prisma, {
|
|
businessId: context.business.id,
|
|
staffId,
|
|
fromDate: params.date,
|
|
toDate: params.date,
|
|
businessTimezone: context.business.timezone,
|
|
})
|
|
).get(params.date) ?? 0;
|
|
if (!checkStaffDailyLimit(staffCount, context.bookingRule.maxAppointmentsPerStaffPerDay).valid) continue;
|
|
|
|
const windowsByDate = await getStaffFreeWindowsForRange(prisma, {
|
|
staffId,
|
|
fromDate: params.date,
|
|
toDate: params.date,
|
|
businessTimezone: context.business.timezone,
|
|
businessOpenByWeekday,
|
|
effectiveBuffer: context.effectiveBuffer,
|
|
});
|
|
const freeWindows = windowsByDate.get(params.date) ?? [];
|
|
const starts = generateSlotStarts(freeWindows, {
|
|
slotIntervalMinutes: context.bookingRule.slotIntervalMinutes,
|
|
durationMinutes: context.totalDurationMinutes,
|
|
});
|
|
|
|
for (const startMinutes of starts) {
|
|
const startAt = wallClockToInstant(params.date, startMinutes, context.business.timezone);
|
|
if (!checkMinNotice(startAt, now, context.bookingRule.minNoticeMinutes).valid) continue;
|
|
|
|
const endMinutes = startMinutes + context.totalDurationMinutes;
|
|
const existing = byStartMinute.get(startMinutes);
|
|
if (existing) {
|
|
existing.staffOptions.push(staffId);
|
|
} else {
|
|
byStartMinute.set(startMinutes, {
|
|
startMinutes,
|
|
endMinutes,
|
|
startAt: startAt.toISOString(),
|
|
endAt: wallClockToInstant(params.date, endMinutes, context.business.timezone).toISOString(),
|
|
staffOptions: [staffId],
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
const slots = [...byStartMinute.values()].sort((a, b) => a.startMinutes - b.startMinutes);
|
|
return { slots, validation: ok() };
|
|
}
|
|
|
|
export interface ValidateBookingSlotParams {
|
|
businessId: string;
|
|
serviceIds: string[];
|
|
/** Must already be a specific staff member — BookingService resolves "any available barber" to one before calling this. */
|
|
staffId: string;
|
|
startAt: Date;
|
|
/** When re-validating a reschedule, exclude the appointment being moved from every conflict/limit check. */
|
|
excludeAppointmentId?: string;
|
|
}
|
|
|
|
export interface ValidatedBookingSlot {
|
|
validation: ValidationResult;
|
|
context: ResolvedBookingContext | null;
|
|
endAt: Date | null;
|
|
}
|
|
|
|
/**
|
|
* Diagnoses a single candidate (business, services, staff, start time) in
|
|
* full, producing the specific, distinct error the brief calls for
|
|
* ("business is open", "barber is working", "not on a break", "not on
|
|
* leave", "requested time is available", "services fit", "booking rules
|
|
* satisfied") rather than one generic "unavailable". This is a
|
|
* synchronous-feeling *diagnosis* for good error messages; the
|
|
* authoritative, race-safe check that actually guards against double
|
|
* booking still happens inside SchedulingService's transaction at commit
|
|
* time — this function can be (rarely) stale by the time a booking
|
|
* actually commits, same as any other read-then-write.
|
|
*/
|
|
export async function validateBookingSlot(
|
|
prisma: PrismaClient,
|
|
params: ValidateBookingSlotParams,
|
|
): Promise<ValidatedBookingSlot> {
|
|
const { context, validation: contextValidation } = await resolveBookingContext(prisma, {
|
|
businessId: params.businessId,
|
|
serviceIds: params.serviceIds,
|
|
staffId: params.staffId,
|
|
});
|
|
if (!context || !contextValidation.valid) return { context: null, validation: contextValidation, endAt: null };
|
|
|
|
const { timezone } = context.business;
|
|
const date = calendarDateOf(params.startAt, timezone);
|
|
const startMinutes = minutesFromMidnight(params.startAt, timezone);
|
|
const endMinutes = startMinutes + context.totalDurationMinutes;
|
|
const endAt = wallClockToInstant(date, endMinutes, timezone);
|
|
const now = new Date();
|
|
const today = todayIn(timezone);
|
|
|
|
const ruleChecks = mergeValidation(
|
|
checkMinNotice(params.startAt, now, context.bookingRule.minNoticeMinutes),
|
|
checkMaxAdvance(date, today, context.bookingRule.maxAdvanceDays),
|
|
);
|
|
|
|
const businessHours = await prisma.businessHours.findMany({ where: { businessId: context.business.id } });
|
|
const businessOpen = businessOpenByWeekdayFrom(businessHours).get(weekdayOf(date));
|
|
|
|
if (!businessOpen || startMinutes < businessOpen.startMinutes || endMinutes > businessOpen.endMinutes) {
|
|
return {
|
|
context,
|
|
endAt,
|
|
validation: mergeValidation(ruleChecks, fail({ code: "BUSINESS_CLOSED", message: "The business is closed at this time." })),
|
|
};
|
|
}
|
|
|
|
const [workingHours, breakDays, timeOffOnDate, businessCount, staffCount] = await Promise.all([
|
|
prisma.workingHours.findUnique({ where: { staffId_dayOfWeek: { staffId: params.staffId, dayOfWeek: weekdayOf(date) } } }),
|
|
prisma.breakDay.findMany({ where: { dayOfWeek: weekdayOf(date), break: { staffId: params.staffId } }, include: { break: true } }),
|
|
prisma.timeOff.findMany({
|
|
where: {
|
|
staffId: params.staffId,
|
|
status: "APPROVED",
|
|
startDate: { lte: calendarDateToUtcMidnight(date) },
|
|
endDate: { gte: calendarDateToUtcMidnight(date) },
|
|
},
|
|
}),
|
|
countAppointmentsPerDate(prisma, {
|
|
businessId: context.business.id,
|
|
fromDate: date,
|
|
toDate: date,
|
|
businessTimezone: timezone,
|
|
excludeAppointmentId: params.excludeAppointmentId,
|
|
}),
|
|
countAppointmentsPerDate(prisma, {
|
|
businessId: context.business.id,
|
|
staffId: params.staffId,
|
|
fromDate: date,
|
|
toDate: date,
|
|
businessTimezone: timezone,
|
|
excludeAppointmentId: params.excludeAppointmentId,
|
|
}),
|
|
]);
|
|
|
|
const staffWorking: TimeWindow | null =
|
|
workingHours && !workingHours.isOff && workingHours.startMinutes !== null && workingHours.endMinutes !== null
|
|
? { startMinutes: workingHours.startMinutes, endMinutes: workingHours.endMinutes }
|
|
: null;
|
|
|
|
if (!staffWorking) {
|
|
return { context, endAt, validation: mergeValidation(ruleChecks, fail({ code: "STAFF_NOT_WORKING", message: "This staff member isn't scheduled to work at this time." })) };
|
|
}
|
|
|
|
// A row with both minutes null blocks the whole day; a row with minutes
|
|
// set (including a one-off "Block Time" entry — see TimeOff.startMinutes
|
|
// in schema.prisma) only conflicts if it overlaps the requested slot.
|
|
if (timeOffOnDate.some((t) => t.startMinutes === null)) {
|
|
return { context, endAt, validation: mergeValidation(ruleChecks, fail({ code: "STAFF_ON_LEAVE", message: "This staff member is on approved leave that day." })) };
|
|
}
|
|
const partialTimeOff: TimeWindow[] = timeOffOnDate
|
|
.filter((t) => t.startMinutes !== null && t.endMinutes !== null)
|
|
.map((t) => ({ startMinutes: t.startMinutes!, endMinutes: t.endMinutes! }));
|
|
const onTimeOff = partialTimeOff.some((w) => startMinutes < w.endMinutes && endMinutes > w.startMinutes);
|
|
if (onTimeOff) {
|
|
return { context, endAt, validation: mergeValidation(ruleChecks, fail({ code: "STAFF_ON_LEAVE", message: "This staff member is unavailable during part of this time." })) };
|
|
}
|
|
|
|
const breaks: TimeWindow[] = [
|
|
...breakDays.map((bd) => ({ startMinutes: bd.break.startMinutes, endMinutes: bd.break.endMinutes })),
|
|
...partialTimeOff,
|
|
];
|
|
const onBreak = breaks.some((b) => startMinutes < b.endMinutes && endMinutes > b.startMinutes);
|
|
if (onBreak) {
|
|
return { context, endAt, validation: mergeValidation(ruleChecks, fail({ code: "STAFF_ON_BREAK", message: "This staff member is on a break during part of this time." })) };
|
|
}
|
|
|
|
// Theoretical window: hours ∩ working hours, minus breaks, WITHOUT existing appointments — distinguishes
|
|
// "the services just don't fit in the remaining open time" from "someone else booked over this".
|
|
const theoreticalWindows = computeFreeWindows({ businessOpen, staffWorking, breaks, onLeave: false, busy: [] });
|
|
const fitsTheoretically = theoreticalWindows.some((w) => startMinutes >= w.startMinutes && endMinutes <= w.endMinutes);
|
|
if (!fitsTheoretically) {
|
|
return {
|
|
context,
|
|
endAt,
|
|
validation: mergeValidation(ruleChecks, fail({ code: "INSUFFICIENT_DURATION", message: "The selected services don't fit before this staff member's hours end." })),
|
|
};
|
|
}
|
|
|
|
const businessCapCheck = checkBusinessDailyLimit(businessCount.get(date) ?? 0, context.bookingRule.maxAppointmentsPerDay);
|
|
const staffCapCheck = checkStaffDailyLimit(staffCount.get(date) ?? 0, context.bookingRule.maxAppointmentsPerStaffPerDay);
|
|
|
|
const actualWindows = await getStaffFreeWindowsForRange(prisma, {
|
|
staffId: params.staffId,
|
|
fromDate: date,
|
|
toDate: date,
|
|
businessTimezone: timezone,
|
|
businessOpenByWeekday: { get: (w: Weekday) => (w === weekdayOf(date) ? businessOpen : null) },
|
|
effectiveBuffer: context.effectiveBuffer,
|
|
excludeAppointmentId: params.excludeAppointmentId,
|
|
});
|
|
const fitsActually = (actualWindows.get(date) ?? []).some((w) => startMinutes >= w.startMinutes && endMinutes <= w.endMinutes);
|
|
const slotCheck = fitsActually ? ok() : fail({ code: "SLOT_UNAVAILABLE", message: "This time was just booked — please choose another." });
|
|
|
|
return {
|
|
context,
|
|
endAt,
|
|
validation: mergeValidation(ruleChecks, businessCapCheck, staffCapCheck, slotCheck),
|
|
};
|
|
}
|
|
|
|
async function countAppointmentsPerDate(
|
|
prisma: PrismaClient,
|
|
params: {
|
|
businessId: string;
|
|
staffId?: string;
|
|
fromDate: CalendarDate;
|
|
toDate: CalendarDate;
|
|
businessTimezone: string;
|
|
excludeAppointmentId?: string;
|
|
},
|
|
): Promise<Map<CalendarDate, number>> {
|
|
const rangeStart = startOfBusinessDay(params.fromDate, params.businessTimezone);
|
|
const rangeEnd = endOfBusinessDay(params.toDate, params.businessTimezone);
|
|
|
|
const appointments = await prisma.appointment.findMany({
|
|
where: {
|
|
businessId: params.businessId,
|
|
...(params.staffId ? { staffId: params.staffId } : {}),
|
|
...(params.excludeAppointmentId ? { id: { not: params.excludeAppointmentId } } : {}),
|
|
status: { notIn: ["CANCELLED", "NO_SHOW"] },
|
|
startAt: { gte: rangeStart, lt: rangeEnd },
|
|
},
|
|
select: { startAt: true },
|
|
});
|
|
|
|
const counts = new Map<CalendarDate, number>();
|
|
for (const a of appointments) {
|
|
const date = calendarDateOf(a.startAt, params.businessTimezone);
|
|
counts.set(date, (counts.get(date) ?? 0) + 1);
|
|
}
|
|
return counts;
|
|
}
|