181 lines
4.9 KiB
TypeScript
181 lines
4.9 KiB
TypeScript
export const dynamic = "force-dynamic";
|
|
|
|
import { eachDayOfInterval } from "date-fns";
|
|
import { prisma } from "@/lib/prisma";
|
|
import {
|
|
calculateSlotsForDisplayDate,
|
|
loadSlotConfig
|
|
} from "@/lib/services/availability";
|
|
import { monthSlotsQuerySchema } from "@/lib/validators/public";
|
|
import { fail, ok } from "@/lib/api";
|
|
import { enforceRateLimit } from "@/lib/rate-limit";
|
|
import { resolveTimeZone } from "@/lib/date";
|
|
|
|
function getMonthDays(monat: string) {
|
|
const [yearRaw, monthRaw] = monat.split("-");
|
|
const year = Number(yearRaw);
|
|
const month = Number(monthRaw);
|
|
|
|
// Use UTC noon to avoid any timezone/DST rollover to adjacent dates.
|
|
const start = new Date(Date.UTC(year, month - 1, 1, 12, 0, 0));
|
|
const end = new Date(Date.UTC(year, month, 0, 12, 0, 0));
|
|
|
|
return eachDayOfInterval({ start, end }).map((day) => day.toISOString().slice(0, 10));
|
|
}
|
|
|
|
function countSlotsForDay(
|
|
results: Array<{ slots: string[] }>,
|
|
requireAll: boolean
|
|
) {
|
|
if (results.length === 0) return 0;
|
|
|
|
if (requireAll) {
|
|
const first = results[0];
|
|
if (!first) return 0;
|
|
|
|
const intersection = new Set(first.slots);
|
|
for (const item of results.slice(1)) {
|
|
const next = new Set(item.slots);
|
|
for (const value of Array.from(intersection)) {
|
|
if (!next.has(value)) {
|
|
intersection.delete(value);
|
|
}
|
|
}
|
|
}
|
|
return intersection.size;
|
|
}
|
|
|
|
const unique = new Set<string>();
|
|
for (const item of results) {
|
|
for (const slot of item.slots) {
|
|
unique.add(slot);
|
|
}
|
|
}
|
|
return unique.size;
|
|
}
|
|
|
|
type MonthAvailabilityCacheEntry = {
|
|
availability: Record<string, number>;
|
|
expiresAt: number;
|
|
};
|
|
|
|
declare global {
|
|
// eslint-disable-next-line no-var
|
|
var calbookMonthAvailabilityCache: Map<string, MonthAvailabilityCacheEntry> | undefined;
|
|
}
|
|
|
|
const monthAvailabilityCache =
|
|
global.calbookMonthAvailabilityCache ?? new Map<string, MonthAvailabilityCacheEntry>();
|
|
if (!global.calbookMonthAvailabilityCache) {
|
|
global.calbookMonthAvailabilityCache = monthAvailabilityCache;
|
|
}
|
|
|
|
const MONTH_CACHE_TTL_MS = Number(process.env.SLOTS_MONTH_CACHE_TTL_MS ?? "12000");
|
|
const DAYS_CONCURRENCY = Math.max(1, Number(process.env.SLOTS_MONTH_CONCURRENCY ?? "4"));
|
|
|
|
async function mapWithConcurrency<T, R>(
|
|
items: T[],
|
|
concurrency: number,
|
|
mapper: (item: T) => Promise<R>
|
|
) {
|
|
const results: R[] = new Array(items.length);
|
|
let nextIndex = 0;
|
|
|
|
async function worker() {
|
|
while (true) {
|
|
const current = nextIndex;
|
|
nextIndex += 1;
|
|
if (current >= items.length) return;
|
|
results[current] = await mapper(items[current] as T);
|
|
}
|
|
}
|
|
|
|
await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, () => worker()));
|
|
return results;
|
|
}
|
|
|
|
export async function GET(req: Request) {
|
|
const limit = enforceRateLimit({
|
|
req,
|
|
scope: "public-slots-month",
|
|
limit: 60,
|
|
windowMs: 60_000
|
|
});
|
|
|
|
if (!limit.ok) {
|
|
return fail("Zu viele Anfragen. Bitte kurz warten.", 429, {
|
|
retryAfterSeconds: limit.retryAfterSeconds
|
|
});
|
|
}
|
|
|
|
const url = new URL(req.url);
|
|
const parsed = monthSlotsQuerySchema.safeParse({
|
|
mitarbeiterId: url.searchParams.get("mitarbeiterId") ?? undefined,
|
|
monat: url.searchParams.get("monat"),
|
|
timezone: url.searchParams.get("timezone") ?? undefined,
|
|
requireAll: url.searchParams.get("requireAll") ?? undefined
|
|
});
|
|
|
|
if (!parsed.success) {
|
|
return fail("Ungültige Parameter", 400, parsed.error.flatten());
|
|
}
|
|
|
|
const timezone = resolveTimeZone(parsed.data.timezone);
|
|
const cacheKey = [
|
|
parsed.data.monat,
|
|
parsed.data.mitarbeiterId ?? "all",
|
|
timezone,
|
|
parsed.data.requireAll ? "all-required" : "any"
|
|
].join("|");
|
|
const cached = monthAvailabilityCache.get(cacheKey);
|
|
if (cached && cached.expiresAt > Date.now()) {
|
|
return ok({ availability: cached.availability });
|
|
}
|
|
|
|
const dayKeys = getMonthDays(parsed.data.monat);
|
|
const slotConfig = await loadSlotConfig();
|
|
|
|
const availability: Record<string, number> = {};
|
|
const sharedStaffIds =
|
|
parsed.data.mitarbeiterId === undefined
|
|
? (
|
|
await prisma.user.findMany({
|
|
where: {
|
|
isActive: true,
|
|
role: "STAFF",
|
|
calendars: { some: {} }
|
|
},
|
|
select: { id: true }
|
|
})
|
|
).map((staff) => staff.id)
|
|
: undefined;
|
|
|
|
const pairs = await mapWithConcurrency(dayKeys, DAYS_CONCURRENCY, async (dayKey) => {
|
|
const dayResults = await calculateSlotsForDisplayDate(
|
|
parsed.data.mitarbeiterId,
|
|
dayKey,
|
|
{
|
|
displayTimezone: timezone,
|
|
staffIds: sharedStaffIds,
|
|
config: slotConfig
|
|
}
|
|
);
|
|
const count = countSlotsForDay(dayResults, Boolean(parsed.data.requireAll));
|
|
return {
|
|
dayKey,
|
|
count
|
|
};
|
|
});
|
|
|
|
for (const pair of pairs) {
|
|
availability[pair.dayKey] = pair.count;
|
|
}
|
|
|
|
monthAvailabilityCache.set(cacheKey, {
|
|
availability,
|
|
expiresAt: Date.now() + MONTH_CACHE_TTL_MS
|
|
});
|
|
|
|
return ok({ availability });
|
|
}
|