112 lines
5.0 KiB
TypeScript
112 lines
5.0 KiB
TypeScript
// 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<T>(rows: readonly T[], providerId: (row: T) => string): Map<string, T[]> {
|
|
const grouped = new Map<string, T[]>();
|
|
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<ProviderAvailabilityInput[]> {
|
|
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<ComputedSlot[]> {
|
|
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 });
|
|
}
|