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 { 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 { 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(); 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 { 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> { 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(); for (const a of appointments) { const date = calendarDateOf(a.startAt, params.businessTimezone); counts.set(date, (counts.get(date) ?? 0) + 1); } return counts; }