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(); for (const item of results) { for (const slot of item.slots) { unique.add(slot); } } return unique.size; } type MonthAvailabilityCacheEntry = { availability: Record; expiresAt: number; }; declare global { // eslint-disable-next-line no-var var calbookMonthAvailabilityCache: Map | undefined; } const monthAvailabilityCache = global.calbookMonthAvailabilityCache ?? new Map(); 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( items: T[], concurrency: number, mapper: (item: T) => Promise ) { 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 = {}; 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 }); }