438 lines
18 KiB
JavaScript
438 lines
18 KiB
JavaScript
import { call, os } from "@orpc/server";
|
|
import { z } from "zod";
|
|
import { randomUUID } from "crypto";
|
|
import { createKV } from "../lib/create-kv.js";
|
|
import { assertOwner, getSessionFromCookies } from "../lib/auth.js";
|
|
import { checkAdminRateLimit, getClientIP, enforceAdminRateLimit } from "../lib/rate-limiter.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({
|
|
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, context }) => {
|
|
try {
|
|
await assertOwner(context);
|
|
// Admin Rate Limiting
|
|
const ip = getClientIP(context.req.raw.headers);
|
|
const session = await getSessionFromCookies(context);
|
|
if (session) {
|
|
const result = checkAdminRateLimit({ ip, userId: session.userId });
|
|
if (!result.allowed) {
|
|
throw new Error(`Zu viele Admin-Anfragen. Bitte versuche es in ${result.retryAfterSeconds} Sekunden erneut.`);
|
|
}
|
|
}
|
|
// 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.passthrough())
|
|
.handler(async ({ input, context }) => {
|
|
await assertOwner(context);
|
|
// Admin Rate Limiting
|
|
const ip = getClientIP(context.req.raw.headers);
|
|
const session = await getSessionFromCookies(context);
|
|
if (session) {
|
|
const result = checkAdminRateLimit({ ip, userId: session.userId });
|
|
if (!result.allowed) {
|
|
throw new Error(`Zu viele Admin-Anfragen. Bitte versuche es in ${result.retryAfterSeconds} Sekunden erneut.`);
|
|
}
|
|
}
|
|
// 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 rule = input;
|
|
await recurringRulesKV.setItem(rule.id, rule);
|
|
return rule;
|
|
});
|
|
const deleteRule = os
|
|
.input(z.object({ id: z.string() }))
|
|
.handler(async ({ input, context }) => {
|
|
await assertOwner(context);
|
|
// Admin Rate Limiting
|
|
const ip = getClientIP(context.req.raw.headers);
|
|
const session = await getSessionFromCookies(context);
|
|
if (session) {
|
|
const result = checkAdminRateLimit({ ip, userId: session.userId });
|
|
if (!result.allowed) {
|
|
throw new Error(`Zu viele Admin-Anfragen. Bitte versuche es in ${result.retryAfterSeconds} Sekunden erneut.`);
|
|
}
|
|
}
|
|
await recurringRulesKV.removeItem(input.id);
|
|
});
|
|
const toggleRuleActive = os
|
|
.input(z.object({ id: z.string() }))
|
|
.handler(async ({ input, context }) => {
|
|
await assertOwner(context);
|
|
// Admin Rate Limiting
|
|
const ip = getClientIP(context.req.raw.headers);
|
|
const session = await getSessionFromCookies(context);
|
|
if (session) {
|
|
const result = checkAdminRateLimit({ ip, userId: session.userId });
|
|
if (!result.allowed) {
|
|
throw new Error(`Zu viele Admin-Anfragen. Bitte versuche es in ${result.retryAfterSeconds} Sekunden erneut.`);
|
|
}
|
|
}
|
|
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({}))
|
|
.handler(async ({ context }) => {
|
|
await assertOwner(context);
|
|
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({
|
|
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, context }) => {
|
|
try {
|
|
await assertOwner(context);
|
|
// Admin Rate Limiting direkt nach Owner-Check
|
|
await enforceAdminRateLimit(context);
|
|
// 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.passthrough())
|
|
.handler(async ({ input, context }) => {
|
|
await assertOwner(context);
|
|
// Admin Rate Limiting direkt nach Owner-Check
|
|
await enforceAdminRateLimit(context);
|
|
// Validierung: startDate <= endDate
|
|
if (input.startDate > input.endDate) {
|
|
throw new Error("Startdatum muss vor oder am Enddatum liegen.");
|
|
}
|
|
const timeOff = input;
|
|
await timeOffPeriodsKV.setItem(timeOff.id, timeOff);
|
|
return timeOff;
|
|
});
|
|
const deleteTimeOff = os
|
|
.input(z.object({ id: z.string() }))
|
|
.handler(async ({ input, context }) => {
|
|
await assertOwner(context);
|
|
// Admin Rate Limiting direkt nach Owner-Check
|
|
await enforceAdminRateLimit(context);
|
|
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({}))
|
|
.handler(async ({ context }) => {
|
|
await assertOwner(context);
|
|
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({}))
|
|
.handler(async function* ({ context, signal }) {
|
|
await assertOwner(context);
|
|
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({}))
|
|
.handler(async function* ({ context, signal }) {
|
|
await assertOwner(context);
|
|
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,
|
|
};
|