Files
Calbook/lib/services/availability.ts

330 lines
8.5 KiB
TypeScript

import { addDays, addMinutes, isBefore } from "date-fns";
import { formatInTimeZone } from "date-fns-tz";
import { prisma } from "@/lib/prisma";
import { getSettings } from "@/lib/settings";
import { SETTING_KEYS } from "@/lib/constants";
import {
DEFAULT_TIMEZONE,
combineDateAndTime,
atStartOfDayInZone,
atEndOfDayInZone,
isoDateRangeInclusive,
resolveTimeZone,
zonedDateFromParts,
zonedDateOnlyToUtc
} from "@/lib/date";
import {
createWeekdayAvailabilityFromLegacy,
parseWeekdayAvailabilityJson
} from "@/lib/weekday-availability";
export type SlotResult = {
staffId: string;
date: string;
slots: string[];
total: number;
};
export type SlotConfig = {
duration: number;
bufferMinutes: number;
bookingLeadHours: number;
bookingWindowDays: number;
};
type Interval = {
start: Date;
end: Date;
};
const SLOT_STEP_MINUTES = 15;
function overlaps(a: Interval, b: Interval) {
return a.start < b.end && b.start < a.end;
}
function buildDayRange(date: Date) {
const dayStart = atStartOfDayInZone(date);
const dayEnd = atEndOfDayInZone(date);
return { dayStart, dayEnd };
}
export async function loadSlotConfig(): Promise<SlotConfig> {
const settings = await getSettings([
SETTING_KEYS.DEFAULT_DURATION_MINUTES,
SETTING_KEYS.BUFFER_MINUTES,
SETTING_KEYS.BOOKING_LEAD_HOURS,
SETTING_KEYS.BOOKING_WINDOW_DAYS
]);
return {
duration: Number(settings[SETTING_KEYS.DEFAULT_DURATION_MINUTES] ?? "60"),
bufferMinutes: Number(settings[SETTING_KEYS.BUFFER_MINUTES] ?? "10"),
bookingLeadHours: Number(settings[SETTING_KEYS.BOOKING_LEAD_HOURS] ?? "2"),
bookingWindowDays: Number(settings[SETTING_KEYS.BOOKING_WINDOW_DAYS] ?? "60")
};
}
export async function calculateSlotsForStaff(
staffId: string,
dayDate: Date,
configInput?: SlotConfig
): Promise<SlotResult> {
const config = configInput ?? (await loadSlotConfig());
const { duration, bufferMinutes, bookingLeadHours, bookingWindowDays } = config;
const now = new Date();
const leadLimit = addMinutes(now, bookingLeadHours * 60);
const zoneTodayStart = atStartOfDayInZone(now);
const targetDayStart = atStartOfDayInZone(dayDate);
const windowLimit = addDays(zoneTodayStart, bookingWindowDays);
if (isBefore(targetDayStart, zoneTodayStart) || targetDayStart > windowLimit) {
return {
staffId,
date: formatInTimeZone(targetDayStart, DEFAULT_TIMEZONE, "yyyy-MM-dd"),
slots: [],
total: 0
};
}
const dayOfWeek = Number(formatInTimeZone(targetDayStart, DEFAULT_TIMEZONE, "i")) - 1;
const dayIso = formatInTimeZone(targetDayStart, DEFAULT_TIMEZONE, "yyyy-MM-dd");
const primaryCalendar = await prisma.calendarConn.findFirst({
where: {
userId: staffId,
user: {
isActive: true,
role: "STAFF"
}
},
orderBy: { createdAt: "asc" },
select: {
bookingAllowedWeekdays: true,
bookingDayStartTime: true,
bookingDayEndTime: true,
bookingDayRangesJson: true
}
});
if (!primaryCalendar) {
return {
staffId,
date: dayIso,
slots: [],
total: 0
};
}
const dayRanges = parseWeekdayAvailabilityJson(
primaryCalendar.bookingDayRangesJson,
createWeekdayAvailabilityFromLegacy(
primaryCalendar.bookingAllowedWeekdays,
primaryCalendar.bookingDayStartTime,
primaryCalendar.bookingDayEndTime
)
);
const dayKey = String(dayOfWeek) as keyof typeof dayRanges;
const selectedRange = dayRanges[dayKey];
if (!selectedRange?.enabled) {
return {
staffId,
date: dayIso,
slots: [],
total: 0
};
}
const dayStartTime = selectedRange.start;
const dayEndTime = selectedRange.end;
const effectiveStart = combineDateAndTime(dayDate, dayStartTime);
const effectiveEnd = combineDateAndTime(dayDate, dayEndTime);
if (effectiveStart >= effectiveEnd) {
return {
staffId,
date: dayIso,
slots: [],
total: 0
};
}
const { dayStart, dayEnd } = buildDayRange(dayDate);
const [busyBlocks, appointments] = await Promise.all([
prisma.busyBlock.findMany({
where: {
calendarConn: {
userId: staffId,
syncEnabled: true
},
startAt: { lt: dayEnd },
endAt: { gt: dayStart }
}
}),
prisma.appointment.findMany({
where: {
staffId,
status: "CONFIRMED",
startAt: { lt: dayEnd },
endAt: { gt: dayStart }
}
})
]);
const blockedIntervals: Interval[] = [];
for (const block of busyBlocks) {
blockedIntervals.push({
start: block.startAt,
end: addMinutes(block.endAt, bufferMinutes)
});
}
for (const app of appointments) {
blockedIntervals.push({
start: app.startAt,
end: addMinutes(app.endAt, bufferMinutes)
});
}
const slots: string[] = [];
for (
let current = effectiveStart;
addMinutes(current, duration) <= effectiveEnd;
current = addMinutes(current, SLOT_STEP_MINUTES)
) {
const candidate: Interval = {
start: current,
end: addMinutes(current, duration)
};
if (candidate.start < leadLimit) continue;
const hasConflict = blockedIntervals.some((blocked) => overlaps(candidate, blocked));
if (!hasConflict) {
slots.push(formatInTimeZone(candidate.start, DEFAULT_TIMEZONE, "HH:mm"));
}
}
return {
staffId,
date: dayIso,
slots,
total: slots.length
};
}
export async function calculateSlots(
staffId: string | undefined,
dayDate: Date,
options?: {
config?: SlotConfig;
staffIds?: string[];
}
) {
const config = options?.config ?? (await loadSlotConfig());
if (staffId) {
return [await calculateSlotsForStaff(staffId, dayDate, config)];
}
const staffIds =
options?.staffIds ??
(
await prisma.user.findMany({
where: {
isActive: true,
role: "STAFF",
calendars: { some: {} }
},
select: { id: true }
})
).map((staff) => staff.id);
const results = await Promise.all(
staffIds.map((id) => calculateSlotsForStaff(id, dayDate, config))
);
return results;
}
export async function calculateSlotsForDisplayDate(
staffId: string | undefined,
displayDateIso: string,
options?: {
displayTimezone?: string;
config?: SlotConfig;
staffIds?: string[];
}
) {
const displayTimezone = resolveTimeZone(options?.displayTimezone);
const config = options?.config ?? (await loadSlotConfig());
const dayStartUtc = zonedDateOnlyToUtc(displayDateIso, displayTimezone);
const dayEndUtc = zonedDateFromParts(displayDateIso, "23:59", displayTimezone);
const baseStartIso = formatInTimeZone(dayStartUtc, DEFAULT_TIMEZONE, "yyyy-MM-dd");
const baseEndIso = formatInTimeZone(dayEndUtc, DEFAULT_TIMEZONE, "yyyy-MM-dd");
const baseDateKeys = isoDateRangeInclusive(baseStartIso, baseEndIso);
if (baseDateKeys.length === 0) {
return [] as SlotResult[];
}
const perBaseDay = await Promise.all(
baseDateKeys.map((baseDateIso) =>
calculateSlots(staffId, zonedDateOnlyToUtc(baseDateIso), {
config,
staffIds: options?.staffIds
})
)
);
const slotsByStaff = new Map<string, Set<string>>();
const seenStaffIds = new Set<string>();
for (const dayResults of perBaseDay) {
for (const entry of dayResults) {
seenStaffIds.add(entry.staffId);
if (!slotsByStaff.has(entry.staffId)) {
slotsByStaff.set(entry.staffId, new Set<string>());
}
const slotSet = slotsByStaff.get(entry.staffId)!;
for (const slotTime of entry.slots) {
const slotInstant = zonedDateFromParts(entry.date, slotTime);
const localDateKey = formatInTimeZone(slotInstant, displayTimezone, "yyyy-MM-dd");
if (localDateKey !== displayDateIso) continue;
slotSet.add(formatInTimeZone(slotInstant, displayTimezone, "HH:mm"));
}
}
}
const outputStaffIds =
staffId !== undefined
? [staffId]
: Array.from(seenStaffIds).sort((a, b) => a.localeCompare(b));
return outputStaffIds.map((id) => {
const slotList = Array.from(slotsByStaff.get(id) ?? []).sort((a, b) => a.localeCompare(b));
return {
staffId: id,
date: displayDateIso,
slots: slotList,
total: slotList.length
};
});
}
export async function findBestStaffForDate(dayDate: Date): Promise<string | null> {
const results = await calculateSlots(undefined, dayDate);
const withSlots = results.filter((r) => r.slots.length > 0);
if (!withSlots.length) return null;
withSlots.sort((a, b) => a.slots[0]!.localeCompare(b.slots[0]!));
return withSlots[0]!.staffId;
}