// AK-P16b · slot query service (loader-side). Loads availability rules, exceptions, // existing appointments and Google busy intervals (AK-P16a) for a booking page's // providers, then delegates the timezone-aware arithmetic to the pure slotEngine. // // Busy intervals fed to the engine combine, per provider: // - existing appointments (status scheduled/held) → [startAt, endAt) // - external/internal calendar events flagged busy → via availability.ts (leak-safe) // The engine buffers the candidate slot, so raw occupied intervals are enough. import { db } from "~/utils/db.server"; import { extractBusyIntervals, type BusyInterval } from "~/modules/calendar/services/availability"; import { computeSlots, type BookingWeekday, type ComputedSlot, type ProviderAvailabilityInput, type SlotEngineConfig, type SlotExceptionInput, type SlotRuleInput, } from "./slotEngine"; const DAY_MS = 86_400_000; const OCCUPYING_STATUSES = ["scheduled", "held"] as const; export interface SlotQueryInput { readonly tenantId: string; readonly providerUserIds: readonly string[]; /** Provider-local calendar date `YYYY-MM-DD`. */ readonly date: string; readonly config: SlotEngineConfig; readonly now: Date; } /** Broad UTC window around the requested local day (covers any provider timezone). */ function dayWindow(date: string): { from: Date; to: Date } { const midnightUtc = new Date(`${date}T00:00:00Z`).getTime(); return { from: new Date(midnightUtc - DAY_MS), to: new Date(midnightUtc + 2 * DAY_MS) }; } function groupByProvider(rows: readonly T[], providerId: (row: T) => string): Map { const grouped = new Map(); for (const row of rows) { const id = providerId(row); const group = grouped.get(id); if (group) group.push(row); else grouped.set(id, [row]); } return grouped; } /** Load all providers' rules, exceptions and busy intervals in four queries. */ async function loadProviderInputs(tenantId: string, providerUserIds: readonly string[], date: string): Promise { const window = dayWindow(date); const [rules, exceptions, appointments, events] = await Promise.all([ db.availabilityRule.findMany({ where: { tenantId, providerUserId: { in: [...providerUserIds] } }, select: { providerUserId: true, dayOfWeek: true, startTime: true, endTime: true, timezone: true }, }), db.availabilityException.findMany({ where: { tenantId, providerUserId: { in: [...providerUserIds] }, date: new Date(`${date}T00:00:00Z`) }, select: { providerUserId: true, date: true, kind: true, startTime: true, endTime: true, timezone: true }, }), db.appointment.findMany({ where: { tenantId, providerUserId: { in: [...providerUserIds] }, status: { in: [...OCCUPYING_STATUSES] }, startAt: { gte: window.from, lte: window.to } }, select: { providerUserId: true, startAt: true, endAt: true }, }), db.calendarEvent.findMany({ where: { tenantId, userId: { in: [...providerUserIds] }, isBusyForAvailability: true, archivedAt: null, startsAt: { gte: window.from, lte: window.to } }, select: { userId: true, startsAt: true, endsAt: true, isBusyForAvailability: true, archivedAt: true }, }), ]); const rulesByProvider = groupByProvider(rules, (row) => row.providerUserId); const exceptionsByProvider = groupByProvider(exceptions, (row) => row.providerUserId); const appointmentsByProvider = groupByProvider(appointments, (row) => row.providerUserId); const eventsByProvider = groupByProvider(events, (row) => row.userId); return providerUserIds.map((providerUserId) => { const providerAppointments = appointmentsByProvider.get(providerUserId) ?? []; const appointmentBusy: BusyInterval[] = providerAppointments.map((appointment) => ({ from: appointment.startAt, to: appointment.endAt })); const calendarBusy = extractBusyIntervals(eventsByProvider.get(providerUserId) ?? []); return { providerUserId, rules: (rulesByProvider.get(providerUserId) ?? []).map((rule) => ({ dayOfWeek: rule.dayOfWeek as BookingWeekday, startTime: rule.startTime, endTime: rule.endTime, timezone: rule.timezone, })) satisfies SlotRuleInput[], exceptions: (exceptionsByProvider.get(providerUserId) ?? []).map((exception) => ({ date, kind: exception.kind as SlotExceptionInput["kind"], startTime: exception.startTime, endTime: exception.endTime, timezone: exception.timezone, })) satisfies SlotExceptionInput[], busyIntervals: [...appointmentBusy, ...calendarBusy], }; }); } /** Compute the bookable slots for a booking page's providers on a given local day. */ export async function loadSlotsForDate(input: SlotQueryInput): Promise { if (input.providerUserIds.length === 0) { return []; } const providers = await loadProviderInputs(input.tenantId, input.providerUserIds, input.date); return computeSlots({ date: input.date, now: input.now, providers, config: input.config }); }