Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/apps/web/app/modules/booking/services/SlotQueryService.server.ts
T

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 });
}