330 lines
8.5 KiB
TypeScript
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;
|
|
}
|