import { call, os } from "@orpc/server"; import { z } from "zod"; import { randomUUID } from "crypto"; import { createKV } from "../lib/create-kv.js"; import { assertOwner } from "../lib/auth.js"; // Datenmodelle const RecurringRuleSchema = z.object({ id: z.string(), dayOfWeek: z.number().int().min(0).max(6), // 0=Sonntag, 1=Montag, ..., 6=Samstag startTime: z.string().regex(/^\d{2}:\d{2}$/), // HH:MM Format endTime: z.string().regex(/^\d{2}:\d{2}$/), // HH:MM Format isActive: z.boolean(), createdAt: z.string(), // LEGACY: slotDurationMinutes - deprecated field for generateSlots only, will be removed slotDurationMinutes: z.number().int().min(1).optional(), }); const TimeOffPeriodSchema = z.object({ id: z.string(), startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), // YYYY-MM-DD endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), // YYYY-MM-DD reason: z.string(), createdAt: z.string(), }); // KV-Stores const recurringRulesKV = createKV("recurringRules"); const timeOffPeriodsKV = createKV("timeOffPeriods"); // Import bookings and treatments KV stores for getAvailableTimes endpoint const bookingsKV = createKV("bookings"); const treatmentsKV = createKV("treatments"); // Owner-Authentifizierung zentralisiert in ../lib/auth.ts // Helper-Funktionen function parseTime(timeStr) { const [hours, minutes] = timeStr.split(':').map(Number); return hours * 60 + minutes; // Minuten seit Mitternacht } function formatTime(minutes) { const hours = Math.floor(minutes / 60); const mins = minutes % 60; return `${String(hours).padStart(2, '0')}:${String(mins).padStart(2, '0')}`; } function addDays(date, days) { const result = new Date(date); result.setDate(result.getDate() + days); return result; } function formatDate(date) { return date.toISOString().split('T')[0]; } function isDateInTimeOffPeriod(date, periods) { return periods.some(period => date >= period.startDate && date <= period.endDate); } // Helper-Funktion zur Erkennung überlappender Regeln function detectOverlappingRules(newRule, existingRules) { const newStart = parseTime(newRule.startTime); const newEnd = parseTime(newRule.endTime); return existingRules.filter(rule => { // Gleicher Wochentag und nicht dieselbe Regel (bei Updates) if (rule.dayOfWeek !== newRule.dayOfWeek || rule.id === newRule.id) { return false; } const existingStart = parseTime(rule.startTime); const existingEnd = parseTime(rule.endTime); // Überlappung wenn: neue Startzeit < bestehende Endzeit UND neue Endzeit > bestehende Startzeit return newStart < existingEnd && newEnd > existingStart; }); } // CRUD-Endpoints für Recurring Rules const createRule = os .input(z.object({ sessionId: z.string(), dayOfWeek: z.number().int().min(0).max(6), startTime: z.string().regex(/^\d{2}:\d{2}$/), endTime: z.string().regex(/^\d{2}:\d{2}$/), }).passthrough()) .handler(async ({ input }) => { try { await assertOwner(input.sessionId); // Validierung: startTime < endTime const startMinutes = parseTime(input.startTime); const endMinutes = parseTime(input.endTime); if (startMinutes >= endMinutes) { throw new Error("Startzeit muss vor der Endzeit liegen."); } // Überlappungsprüfung const allRules = await recurringRulesKV.getAllItems(); const overlappingRules = detectOverlappingRules(input, allRules); if (overlappingRules.length > 0) { const overlappingTimes = overlappingRules.map(rule => `${rule.startTime}-${rule.endTime}`).join(", "); throw new Error(`Überlappung mit bestehenden Regeln erkannt: ${overlappingTimes}. Bitte Zeitfenster anpassen.`); } const id = randomUUID(); const rule = { id, dayOfWeek: input.dayOfWeek, startTime: input.startTime, endTime: input.endTime, isActive: true, createdAt: new Date().toISOString(), }; await recurringRulesKV.setItem(id, rule); return rule; } catch (err) { console.error("recurring-availability.createRule error", err); throw err; } }); const updateRule = os .input(RecurringRuleSchema.extend({ sessionId: z.string() }).passthrough()) .handler(async ({ input }) => { await assertOwner(input.sessionId); // Validierung: startTime < endTime const startMinutes = parseTime(input.startTime); const endMinutes = parseTime(input.endTime); if (startMinutes >= endMinutes) { throw new Error("Startzeit muss vor der Endzeit liegen."); } // Überlappungsprüfung const allRules = await recurringRulesKV.getAllItems(); const overlappingRules = detectOverlappingRules(input, allRules); if (overlappingRules.length > 0) { const overlappingTimes = overlappingRules.map(rule => `${rule.startTime}-${rule.endTime}`).join(", "); throw new Error(`Überlappung mit bestehenden Regeln erkannt: ${overlappingTimes}. Bitte Zeitfenster anpassen.`); } const { sessionId, ...rule } = input; await recurringRulesKV.setItem(rule.id, rule); return rule; }); const deleteRule = os .input(z.object({ sessionId: z.string(), id: z.string() })) .handler(async ({ input }) => { await assertOwner(input.sessionId); await recurringRulesKV.removeItem(input.id); }); const toggleRuleActive = os .input(z.object({ sessionId: z.string(), id: z.string() })) .handler(async ({ input }) => { await assertOwner(input.sessionId); const rule = await recurringRulesKV.getItem(input.id); if (!rule) throw new Error("Regel nicht gefunden."); rule.isActive = !rule.isActive; await recurringRulesKV.setItem(input.id, rule); return rule; }); const listRules = os.handler(async () => { const allRules = await recurringRulesKV.getAllItems(); return allRules.sort((a, b) => { if (a.dayOfWeek !== b.dayOfWeek) return a.dayOfWeek - b.dayOfWeek; return a.startTime.localeCompare(b.startTime); }); }); const adminListRules = os .input(z.object({ sessionId: z.string() })) .handler(async ({ input }) => { await assertOwner(input.sessionId); const allRules = await recurringRulesKV.getAllItems(); return allRules.sort((a, b) => { if (a.dayOfWeek !== b.dayOfWeek) return a.dayOfWeek - b.dayOfWeek; return a.startTime.localeCompare(b.startTime); }); }); // CRUD-Endpoints für Time-Off Periods const createTimeOff = os .input(z.object({ sessionId: z.string(), startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), reason: z.string(), })) .handler(async ({ input }) => { try { await assertOwner(input.sessionId); // Validierung: startDate <= endDate if (input.startDate > input.endDate) { throw new Error("Startdatum muss vor oder am Enddatum liegen."); } const id = randomUUID(); const timeOff = { id, startDate: input.startDate, endDate: input.endDate, reason: input.reason, createdAt: new Date().toISOString(), }; await timeOffPeriodsKV.setItem(id, timeOff); return timeOff; } catch (err) { console.error("recurring-availability.createTimeOff error", err); throw err; } }); const updateTimeOff = os .input(TimeOffPeriodSchema.extend({ sessionId: z.string() }).passthrough()) .handler(async ({ input }) => { await assertOwner(input.sessionId); // Validierung: startDate <= endDate if (input.startDate > input.endDate) { throw new Error("Startdatum muss vor oder am Enddatum liegen."); } const { sessionId, ...timeOff } = input; await timeOffPeriodsKV.setItem(timeOff.id, timeOff); return timeOff; }); const deleteTimeOff = os .input(z.object({ sessionId: z.string(), id: z.string() })) .handler(async ({ input }) => { await assertOwner(input.sessionId); await timeOffPeriodsKV.removeItem(input.id); }); const listTimeOff = os.handler(async () => { const allTimeOff = await timeOffPeriodsKV.getAllItems(); return allTimeOff.sort((a, b) => a.startDate.localeCompare(b.startDate)); }); const adminListTimeOff = os .input(z.object({ sessionId: z.string() })) .handler(async ({ input }) => { await assertOwner(input.sessionId); const allTimeOff = await timeOffPeriodsKV.getAllItems(); return allTimeOff.sort((a, b) => a.startDate.localeCompare(b.startDate)); }); // Get Available Times Endpoint const getAvailableTimes = os .input(z.object({ date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), treatmentId: z.string(), })) .handler(async ({ input }) => { try { // Validate that the date is not in the past const today = new Date(); const inputDate = new Date(input.date); today.setHours(0, 0, 0, 0); inputDate.setHours(0, 0, 0, 0); if (inputDate < today) { return []; } // Get treatment duration const treatment = await treatmentsKV.getItem(input.treatmentId); if (!treatment) { throw new Error("Behandlung nicht gefunden."); } const treatmentDuration = treatment.duration; // Parse the date to get day of week const [year, month, day] = input.date.split('-').map(Number); const localDate = new Date(year, month - 1, day); const dayOfWeek = localDate.getDay(); // 0=Sonntag, 1=Montag, ... // Find matching recurring rules const allRules = await recurringRulesKV.getAllItems(); const matchingRules = allRules.filter(rule => rule.isActive === true && rule.dayOfWeek === dayOfWeek); if (matchingRules.length === 0) { return []; // No rules for this day of week } // Check time-off periods const timeOffPeriods = await timeOffPeriodsKV.getAllItems(); if (isDateInTimeOffPeriod(input.date, timeOffPeriods)) { return []; // Date is blocked by time-off period } // Generate 15-minute intervals with boundary alignment const availableTimes = []; // Helper functions for 15-minute boundary alignment const ceilTo15 = (m) => m % 15 === 0 ? m : m + (15 - (m % 15)); const floorTo15 = (m) => m - (m % 15); for (const rule of matchingRules) { const startMinutes = parseTime(rule.startTime); const endMinutes = parseTime(rule.endTime); let currentMinutes = ceilTo15(startMinutes); const endBound = floorTo15(endMinutes); while (currentMinutes + treatmentDuration <= endBound) { const timeStr = formatTime(currentMinutes); availableTimes.push(timeStr); currentMinutes += 15; // 15-minute intervals } } // Get all bookings for this date and their treatments const allBookings = await bookingsKV.getAllItems(); const dateBookings = allBookings.filter(booking => booking.appointmentDate === input.date && ['pending', 'confirmed', 'completed'].includes(booking.status)); // Optimize treatment duration lookup with Map caching const uniqueTreatmentIds = [...new Set(dateBookings.map(booking => booking.treatmentId))]; const treatmentDurationMap = new Map(); for (const treatmentId of uniqueTreatmentIds) { const treatment = await treatmentsKV.getItem(treatmentId); treatmentDurationMap.set(treatmentId, treatment?.duration || 60); } // Get treatment durations for all bookings using the cached map const bookingTreatments = new Map(); for (const booking of dateBookings) { // Use bookedDurationMinutes if available, otherwise fallback to treatment duration const duration = booking.bookedDurationMinutes || treatmentDurationMap.get(booking.treatmentId) || 60; bookingTreatments.set(booking.id, duration); } // Filter out booking conflicts const availableTimesFiltered = availableTimes.filter(slotTime => { const slotStartMinutes = parseTime(slotTime); const slotEndMinutes = slotStartMinutes + treatmentDuration; // Check if this slot overlaps with any existing booking const hasConflict = dateBookings.some(booking => { const bookingStartMinutes = parseTime(booking.appointmentTime); const bookingDuration = bookingTreatments.get(booking.id) || 60; const bookingEndMinutes = bookingStartMinutes + bookingDuration; // Check overlap: slotStart < bookingEnd && slotEnd > bookingStart return slotStartMinutes < bookingEndMinutes && slotEndMinutes > bookingStartMinutes; }); return !hasConflict; }); // Filter out past times for today const now = new Date(); const isToday = inputDate.getTime() === today.getTime(); const finalAvailableTimes = isToday ? availableTimesFiltered.filter(timeStr => { const slotTime = parseTime(timeStr); const currentTime = now.getHours() * 60 + now.getMinutes(); return slotTime > currentTime; }) : availableTimesFiltered; // Deduplicate and sort chronologically const unique = Array.from(new Set(finalAvailableTimes)); return unique.sort((a, b) => a.localeCompare(b)); } catch (err) { console.error("recurring-availability.getAvailableTimes error", err); throw err; } }); // Live-Queries const live = { listRules: os.handler(async function* ({ signal }) { yield call(listRules, {}, { signal }); for await (const _ of recurringRulesKV.subscribe()) { yield call(listRules, {}, { signal }); } }), listTimeOff: os.handler(async function* ({ signal }) { yield call(listTimeOff, {}, { signal }); for await (const _ of timeOffPeriodsKV.subscribe()) { yield call(listTimeOff, {}, { signal }); } }), adminListRules: os .input(z.object({ sessionId: z.string() })) .handler(async function* ({ input, signal }) { await assertOwner(input.sessionId); const allRules = await recurringRulesKV.getAllItems(); const sortedRules = allRules.sort((a, b) => { if (a.dayOfWeek !== b.dayOfWeek) return a.dayOfWeek - b.dayOfWeek; return a.startTime.localeCompare(b.startTime); }); yield sortedRules; for await (const _ of recurringRulesKV.subscribe()) { const updatedRules = await recurringRulesKV.getAllItems(); const sortedUpdatedRules = updatedRules.sort((a, b) => { if (a.dayOfWeek !== b.dayOfWeek) return a.dayOfWeek - b.dayOfWeek; return a.startTime.localeCompare(b.startTime); }); yield sortedUpdatedRules; } }), adminListTimeOff: os .input(z.object({ sessionId: z.string() })) .handler(async function* ({ input, signal }) { await assertOwner(input.sessionId); const allTimeOff = await timeOffPeriodsKV.getAllItems(); const sortedTimeOff = allTimeOff.sort((a, b) => a.startDate.localeCompare(b.startDate)); yield sortedTimeOff; for await (const _ of timeOffPeriodsKV.subscribe()) { const updatedTimeOff = await timeOffPeriodsKV.getAllItems(); const sortedUpdatedTimeOff = updatedTimeOff.sort((a, b) => a.startDate.localeCompare(b.startDate)); yield sortedUpdatedTimeOff; } }), }; export const router = { // Recurring Rules createRule, updateRule, deleteRule, toggleRuleActive, listRules, adminListRules, // Time-Off Periods createTimeOff, updateTimeOff, deleteTimeOff, listTimeOff, adminListTimeOff, // Availability getAvailableTimes, // Live queries live, };