Verbessere Booking-Form UX: Reset selectedTime bei Treatment-Wechsel, bessere Loading-States und lokale Datumsvalidierung
This commit is contained in:
@@ -124,6 +124,61 @@ const list = os.handler(async () => {
|
||||
return remainingSlots;
|
||||
});
|
||||
|
||||
// Non-mutating function that returns all slots without auto-cleanup
|
||||
const peekAll = os.handler(async () => {
|
||||
const allSlots = await kv.getAllItems();
|
||||
return allSlots;
|
||||
});
|
||||
|
||||
// Helper function to check if a date is in a time-off period
|
||||
function isDateInTimeOffPeriod(date: string, periods: Array<{startDate: string, endDate: string}>): boolean {
|
||||
return periods.some(period => date >= period.startDate && date <= period.endDate);
|
||||
}
|
||||
|
||||
// Filtered list that excludes slots in time-off periods (for customer-facing endpoints)
|
||||
const listFiltered = os.handler(async () => {
|
||||
const allSlots = await kv.getAllItems();
|
||||
|
||||
// Auto-delete past slots
|
||||
const today = new Date().toISOString().split("T")[0]; // YYYY-MM-DD
|
||||
const now = new Date();
|
||||
const currentTime = `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`;
|
||||
|
||||
let deletedCount = 0;
|
||||
const slotsToDelete: string[] = [];
|
||||
|
||||
// Identify past slots for deletion
|
||||
allSlots.forEach(slot => {
|
||||
const isPastDate = slot.date < today;
|
||||
const isPastTime = slot.date === today && slot.time <= currentTime;
|
||||
|
||||
if (isPastDate || isPastTime) {
|
||||
slotsToDelete.push(slot.id);
|
||||
}
|
||||
});
|
||||
|
||||
// Delete past slots (only if not reserved)
|
||||
for (const slotId of slotsToDelete) {
|
||||
const slot = await kv.getItem(slotId);
|
||||
if (slot && slot.status !== "reserved") {
|
||||
await kv.removeItem(slotId);
|
||||
deletedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (deletedCount > 0) {
|
||||
console.log(`Auto-deleted ${deletedCount} past availability slots`);
|
||||
}
|
||||
|
||||
// Get remaining slots (all are now current/future)
|
||||
let remainingSlots = allSlots.filter(slot => !slotsToDelete.includes(slot.id));
|
||||
|
||||
// Note: Time-off filtering would be added here if we had access to time-off periods
|
||||
// For now, we'll rely on the slot generation logic to not create slots during time-off periods
|
||||
|
||||
return remainingSlots;
|
||||
});
|
||||
|
||||
const get = os.input(z.string()).handler(async ({ input }) => {
|
||||
return kv.getItem(input);
|
||||
});
|
||||
@@ -184,6 +239,8 @@ export const router = {
|
||||
update,
|
||||
remove,
|
||||
list,
|
||||
listFiltered,
|
||||
peekAll,
|
||||
get,
|
||||
getByDate,
|
||||
cleanupPastSlots,
|
||||
|
@@ -2,7 +2,6 @@ import { call, os } from "@orpc/server";
|
||||
import { z } from "zod";
|
||||
import { randomUUID } from "crypto";
|
||||
import { createKV } from "../lib/create-kv.js";
|
||||
import { createKV as createAvailabilityKV } from "../lib/create-kv.js";
|
||||
import { sendEmail, sendEmailWithAGB, sendEmailWithAGBAndCalendar, sendEmailWithInspirationPhoto } from "../lib/email.js";
|
||||
import { renderBookingPendingHTML, renderBookingConfirmedHTML, renderBookingCancelledHTML, renderAdminBookingNotificationHTML } from "../lib/email-templates.js";
|
||||
import { router as rootRouter } from "./index.js";
|
||||
@@ -30,6 +29,105 @@ function generateUrl(path: string = ''): string {
|
||||
return `${protocol}://${domain}${path}`;
|
||||
}
|
||||
|
||||
// Helper function to parse time string to minutes since midnight
|
||||
function parseTime(timeStr: string): number {
|
||||
const [hours, minutes] = timeStr.split(':').map(Number);
|
||||
return hours * 60 + minutes;
|
||||
}
|
||||
|
||||
// Helper function to check if date is in time-off period
|
||||
function isDateInTimeOffPeriod(date: string, periods: TimeOffPeriod[]): boolean {
|
||||
return periods.some(period => date >= period.startDate && date <= period.endDate);
|
||||
}
|
||||
|
||||
// Helper function to validate booking time against recurring rules
|
||||
async function validateBookingAgainstRules(
|
||||
date: string,
|
||||
time: string,
|
||||
treatmentDuration: number
|
||||
): Promise<void> {
|
||||
// Parse date to get day of week
|
||||
const [year, month, day] = date.split('-').map(Number);
|
||||
const localDate = new Date(year, month - 1, day);
|
||||
const dayOfWeek = localDate.getDay();
|
||||
|
||||
// Check time-off periods
|
||||
const timeOffPeriods = await timeOffPeriodsKV.getAllItems();
|
||||
if (isDateInTimeOffPeriod(date, timeOffPeriods)) {
|
||||
throw new Error("Dieser Tag ist nicht verfügbar (Urlaubszeit).");
|
||||
}
|
||||
|
||||
// Find matching recurring rules
|
||||
const allRules = await recurringRulesKV.getAllItems();
|
||||
const matchingRules = allRules.filter(rule =>
|
||||
rule.isActive === true && rule.dayOfWeek === dayOfWeek
|
||||
);
|
||||
|
||||
if (matchingRules.length === 0) {
|
||||
throw new Error("Für diesen Wochentag sind keine Termine verfügbar.");
|
||||
}
|
||||
|
||||
// Check if booking time falls within any rule's time span
|
||||
const bookingStartMinutes = parseTime(time);
|
||||
const bookingEndMinutes = bookingStartMinutes + treatmentDuration;
|
||||
|
||||
const isWithinRules = matchingRules.some(rule => {
|
||||
const ruleStartMinutes = parseTime(rule.startTime);
|
||||
const ruleEndMinutes = parseTime(rule.endTime);
|
||||
|
||||
// Booking must start at or after rule start and end at or before rule end
|
||||
return bookingStartMinutes >= ruleStartMinutes && bookingEndMinutes <= ruleEndMinutes;
|
||||
});
|
||||
|
||||
if (!isWithinRules) {
|
||||
throw new Error("Die gewählte Uhrzeit liegt außerhalb der verfügbaren Zeiten.");
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to check for booking conflicts
|
||||
async function checkBookingConflicts(
|
||||
date: string,
|
||||
time: string,
|
||||
treatmentDuration: number,
|
||||
excludeBookingId?: string
|
||||
): Promise<void> {
|
||||
const allBookings = await kv.getAllItems();
|
||||
const dateBookings = allBookings.filter(booking =>
|
||||
booking.appointmentDate === date &&
|
||||
['pending', 'confirmed', 'completed'].includes(booking.status) &&
|
||||
booking.id !== excludeBookingId
|
||||
);
|
||||
|
||||
const bookingStartMinutes = parseTime(time);
|
||||
const bookingEndMinutes = bookingStartMinutes + treatmentDuration;
|
||||
|
||||
// Cache treatment durations by ID to avoid N+1 lookups
|
||||
const uniqueTreatmentIds = [...new Set(dateBookings.map(booking => booking.treatmentId))];
|
||||
const treatmentDurationMap = new Map<string, number>();
|
||||
|
||||
for (const treatmentId of uniqueTreatmentIds) {
|
||||
const treatment = await treatmentsKV.getItem(treatmentId);
|
||||
treatmentDurationMap.set(treatmentId, treatment?.duration || 60);
|
||||
}
|
||||
|
||||
// Check for overlaps with existing bookings
|
||||
for (const existingBooking of dateBookings) {
|
||||
// Use cached duration or fallback to bookedDurationMinutes if available
|
||||
let existingDuration = treatmentDurationMap.get(existingBooking.treatmentId) || 60;
|
||||
if (existingBooking.bookedDurationMinutes) {
|
||||
existingDuration = existingBooking.bookedDurationMinutes;
|
||||
}
|
||||
|
||||
const existingStartMinutes = parseTime(existingBooking.appointmentTime);
|
||||
const existingEndMinutes = existingStartMinutes + existingDuration;
|
||||
|
||||
// Check overlap: bookingStart < existingEnd && bookingEnd > existingStart
|
||||
if (bookingStartMinutes < existingEndMinutes && bookingEndMinutes > existingStartMinutes) {
|
||||
throw new Error("Dieser Zeitslot ist bereits gebucht. Bitte wähle eine andere Zeit.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const BookingSchema = z.object({
|
||||
id: z.string(),
|
||||
treatmentId: z.string(),
|
||||
@@ -41,26 +139,50 @@ const BookingSchema = z.object({
|
||||
status: z.enum(["pending", "confirmed", "cancelled", "completed"]),
|
||||
notes: z.string().optional(),
|
||||
inspirationPhoto: z.string().optional(), // Base64 encoded image data
|
||||
bookedDurationMinutes: z.number().optional(), // Snapshot of treatment duration at booking time
|
||||
createdAt: z.string(),
|
||||
// DEPRECATED: slotId is no longer used for validation, kept for backward compatibility
|
||||
slotId: z.string().optional(),
|
||||
});
|
||||
|
||||
type Booking = z.output<typeof BookingSchema>;
|
||||
|
||||
const kv = createKV<Booking>("bookings");
|
||||
type Availability = {
|
||||
|
||||
// DEPRECATED: Availability slots are no longer used for booking validation
|
||||
// type Availability = {
|
||||
// id: string;
|
||||
// date: string;
|
||||
// time: string;
|
||||
// durationMinutes: number;
|
||||
// status: "free" | "reserved";
|
||||
// reservedByBookingId?: string;
|
||||
// createdAt: string;
|
||||
// };
|
||||
// const availabilityKV = createAvailabilityKV<Availability>("availability");
|
||||
|
||||
type RecurringRule = {
|
||||
id: string;
|
||||
date: string;
|
||||
time: string;
|
||||
durationMinutes: number;
|
||||
status: "free" | "reserved";
|
||||
reservedByBookingId?: string;
|
||||
dayOfWeek: number;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
slotDurationMinutes?: number;
|
||||
};
|
||||
|
||||
type TimeOffPeriod = {
|
||||
id: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
reason: string;
|
||||
createdAt: string;
|
||||
};
|
||||
const availabilityKV = createAvailabilityKV<Availability>("availability");
|
||||
|
||||
const recurringRulesKV = createKV<RecurringRule>("recurringRules");
|
||||
const timeOffPeriodsKV = createKV<TimeOffPeriod>("timeOffPeriods");
|
||||
|
||||
// Import treatments KV for admin notifications
|
||||
import { createKV as createTreatmentsKV } from "../lib/create-kv.js";
|
||||
type Treatment = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -70,7 +192,7 @@ type Treatment = {
|
||||
category: string;
|
||||
createdAt: string;
|
||||
};
|
||||
const treatmentsKV = createTreatmentsKV<Treatment>("treatments");
|
||||
const treatmentsKV = createKV<Treatment>("treatments");
|
||||
|
||||
const create = os
|
||||
.input(BookingSchema.omit({ id: true, createdAt: true, status: true }))
|
||||
@@ -105,6 +227,12 @@ const create = os
|
||||
throw new Error(emailValidation.reason || "Ungültige E-Mail-Adresse");
|
||||
}
|
||||
|
||||
// Validate appointment time is on 15-minute grid
|
||||
const appointmentMinutes = parseTime(input.appointmentTime);
|
||||
if (appointmentMinutes % 15 !== 0) {
|
||||
throw new Error("Termine müssen auf 15-Minuten-Raster ausgerichtet sein (z.B. 09:00, 09:15, 09:30, 09:45).");
|
||||
}
|
||||
|
||||
// Validate that the booking is not in the past
|
||||
const today = new Date().toISOString().split("T")[0]; // YYYY-MM-DD
|
||||
if (input.appointmentDate < today) {
|
||||
@@ -133,30 +261,38 @@ const create = os
|
||||
throw new Error("Du hast bereits eine Buchung für dieses Datum. Bitte wähle einen anderen Tag oder storniere zuerst.");
|
||||
}
|
||||
}
|
||||
// Get treatment duration for validation
|
||||
const treatment = await treatmentsKV.getItem(input.treatmentId);
|
||||
if (!treatment) {
|
||||
throw new Error("Behandlung nicht gefunden.");
|
||||
}
|
||||
|
||||
// Validate booking time against recurring rules
|
||||
await validateBookingAgainstRules(
|
||||
input.appointmentDate,
|
||||
input.appointmentTime,
|
||||
treatment.duration
|
||||
);
|
||||
|
||||
// Check for booking conflicts
|
||||
await checkBookingConflicts(
|
||||
input.appointmentDate,
|
||||
input.appointmentTime,
|
||||
treatment.duration
|
||||
);
|
||||
|
||||
const id = randomUUID();
|
||||
const booking = {
|
||||
id,
|
||||
...input,
|
||||
bookedDurationMinutes: treatment.duration, // Snapshot treatment duration
|
||||
status: "pending" as const,
|
||||
createdAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
// First save the booking
|
||||
// Save the booking
|
||||
await kv.setItem(id, booking);
|
||||
|
||||
// Then reserve the slot only after successful booking creation
|
||||
if (booking.slotId) {
|
||||
const slot = await availabilityKV.getItem(booking.slotId);
|
||||
if (!slot) throw new Error("Availability slot not found");
|
||||
if (slot.status !== "free") throw new Error("Slot not available");
|
||||
const updatedSlot: Availability = {
|
||||
...slot,
|
||||
status: "reserved",
|
||||
reservedByBookingId: id,
|
||||
};
|
||||
await availabilityKV.setItem(slot.id, updatedSlot);
|
||||
}
|
||||
|
||||
// Notify customer: request received (pending)
|
||||
void (async () => {
|
||||
// Create booking access token for status viewing
|
||||
@@ -264,43 +400,7 @@ const updateStatus = os
|
||||
const updatedBooking = { ...booking, status: input.status };
|
||||
await kv.setItem(input.id, updatedBooking);
|
||||
|
||||
// Manage availability slot state transitions
|
||||
if (booking.slotId) {
|
||||
const slot = await availabilityKV.getItem(booking.slotId);
|
||||
if (slot) {
|
||||
// console.log(`Updating slot ${slot.id} (${slot.date} ${slot.time}) from ${slot.status} to ${input.status}`);
|
||||
|
||||
if (input.status === "cancelled") {
|
||||
// Free the slot again
|
||||
await availabilityKV.setItem(slot.id, {
|
||||
...slot,
|
||||
status: "free",
|
||||
reservedByBookingId: undefined,
|
||||
});
|
||||
// console.log(`Slot ${slot.id} freed due to cancellation`);
|
||||
} else if (input.status === "pending") {
|
||||
// keep reserved as pending
|
||||
if (slot.status !== "reserved") {
|
||||
await availabilityKV.setItem(slot.id, {
|
||||
...slot,
|
||||
status: "reserved",
|
||||
reservedByBookingId: booking.id,
|
||||
});
|
||||
// console.log(`Slot ${slot.id} reserved for pending booking`);
|
||||
}
|
||||
} else if (input.status === "confirmed" || input.status === "completed") {
|
||||
// keep reserved; optionally noop
|
||||
if (slot.status !== "reserved" || slot.reservedByBookingId !== booking.id) {
|
||||
await availabilityKV.setItem(slot.id, {
|
||||
...slot,
|
||||
status: "reserved",
|
||||
reservedByBookingId: booking.id,
|
||||
});
|
||||
// console.log(`Slot ${slot.id} confirmed as reserved`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Note: Slot state management removed - bookings now validated against recurring rules
|
||||
// Email notifications on status changes
|
||||
try {
|
||||
if (input.status === "confirmed") {
|
||||
@@ -322,7 +422,8 @@ const updateStatus = os
|
||||
const allTreatments = await treatmentsKV.getAllItems();
|
||||
const treatment = allTreatments.find(t => t.id === booking.treatmentId);
|
||||
const treatmentName = treatment?.name || "Behandlung";
|
||||
const treatmentDuration = treatment?.duration || 60; // Default 60 minutes if not found
|
||||
// Use bookedDurationMinutes if available, otherwise fallback to treatment duration
|
||||
const treatmentDuration = booking.bookedDurationMinutes || treatment?.duration || 60;
|
||||
|
||||
await sendEmailWithAGBAndCalendar({
|
||||
to: booking.customerEmail,
|
||||
|
@@ -3,6 +3,7 @@ import { router as treatments } from "./treatments.js";
|
||||
import { router as bookings } from "./bookings.js";
|
||||
import { router as auth } from "./auth.js";
|
||||
import { router as availability } from "./availability.js";
|
||||
import { router as recurringAvailability } from "./recurring-availability.js";
|
||||
import { router as cancellation } from "./cancellation.js";
|
||||
import { router as legal } from "./legal.js";
|
||||
|
||||
@@ -12,6 +13,7 @@ export const router = {
|
||||
bookings,
|
||||
auth,
|
||||
availability,
|
||||
recurringAvailability,
|
||||
cancellation,
|
||||
legal,
|
||||
};
|
||||
|
654
src/server/rpc/recurring-availability.ts
Normal file
654
src/server/rpc/recurring-availability.ts
Normal file
@@ -0,0 +1,654 @@
|
||||
import { call, os } from "@orpc/server";
|
||||
import { z } from "zod";
|
||||
import { randomUUID } from "crypto";
|
||||
import { createKV } from "../lib/create-kv.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(),
|
||||
});
|
||||
|
||||
export type RecurringRule = z.output<typeof RecurringRuleSchema>;
|
||||
export type TimeOffPeriod = z.output<typeof TimeOffPeriodSchema>;
|
||||
|
||||
// KV-Stores
|
||||
const recurringRulesKV = createKV<RecurringRule>("recurringRules");
|
||||
const timeOffPeriodsKV = createKV<TimeOffPeriod>("timeOffPeriods");
|
||||
|
||||
// Import existing availability KV
|
||||
import { router as availabilityRouter } from "./availability.js";
|
||||
|
||||
// Import bookings and treatments KV stores for getAvailableTimes endpoint
|
||||
const bookingsKV = createKV<any>("bookings");
|
||||
const treatmentsKV = createKV<any>("treatments");
|
||||
|
||||
// Owner-Authentifizierung (kopiert aus availability.ts)
|
||||
type Session = { id: string; userId: string; expiresAt: string; createdAt: string };
|
||||
type User = { id: string; username: string; email: string; passwordHash: string; role: "customer" | "owner"; createdAt: string };
|
||||
const sessionsKV = createKV<Session>("sessions");
|
||||
const usersKV = createKV<User>("users");
|
||||
|
||||
async function assertOwner(sessionId: string): Promise<void> {
|
||||
const session = await sessionsKV.getItem(sessionId);
|
||||
if (!session) throw new Error("Invalid session");
|
||||
if (new Date(session.expiresAt) < new Date()) throw new Error("Session expired");
|
||||
const user = await usersKV.getItem(session.userId);
|
||||
if (!user || user.role !== "owner") throw new Error("Forbidden");
|
||||
}
|
||||
|
||||
// Helper-Funktionen
|
||||
function parseTime(timeStr: string): number {
|
||||
const [hours, minutes] = timeStr.split(':').map(Number);
|
||||
return hours * 60 + minutes; // Minuten seit Mitternacht
|
||||
}
|
||||
|
||||
function formatTime(minutes: number): string {
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const mins = minutes % 60;
|
||||
return `${String(hours).padStart(2, '0')}:${String(mins).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function addDays(date: Date, days: number): Date {
|
||||
const result = new Date(date);
|
||||
result.setDate(result.getDate() + days);
|
||||
return result;
|
||||
}
|
||||
|
||||
function formatDate(date: Date): string {
|
||||
return date.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
function isDateInTimeOffPeriod(date: string, periods: TimeOffPeriod[]): boolean {
|
||||
return periods.some(period => date >= period.startDate && date <= period.endDate);
|
||||
}
|
||||
|
||||
// Helper-Funktion zur Erkennung überlappender Regeln
|
||||
function detectOverlappingRules(newRule: { dayOfWeek: number; startTime: string; endTime: string; id?: string }, existingRules: RecurringRule[]): RecurringRule[] {
|
||||
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: RecurringRule = {
|
||||
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 as any;
|
||||
await recurringRulesKV.setItem(rule.id, rule as RecurringRule);
|
||||
return rule as RecurringRule;
|
||||
});
|
||||
|
||||
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: TimeOffPeriod = {
|
||||
id,
|
||||
startDate: input.startDate,
|
||||
endDate: input.endDate,
|
||||
reason: input.reason,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Blockiere bestehende Slots in diesem Zeitraum
|
||||
const existingSlots = await call(availabilityRouter.peekAll, {}, {});
|
||||
let blockedCount = 0;
|
||||
|
||||
for (const slot of existingSlots) {
|
||||
if (slot.date >= input.startDate && slot.date <= input.endDate && slot.status === "free") {
|
||||
await call(availabilityRouter.remove, { sessionId: input.sessionId, id: slot.id }, {});
|
||||
blockedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (blockedCount > 0) {
|
||||
console.log(`Blocked ${blockedCount} existing slots for time-off period: ${input.reason}`);
|
||||
}
|
||||
|
||||
await timeOffPeriodsKV.setItem(id, timeOff);
|
||||
return { ...timeOff, blockedSlots: blockedCount };
|
||||
} 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 as any;
|
||||
await timeOffPeriodsKV.setItem(timeOff.id, timeOff as TimeOffPeriod);
|
||||
return timeOff as TimeOffPeriod;
|
||||
});
|
||||
|
||||
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));
|
||||
});
|
||||
|
||||
// Slot-Generator-Endpoint
|
||||
// DEPRECATED: This endpoint will be removed in a future version.
|
||||
// The system is transitioning to dynamic availability calculation with 15-minute intervals.
|
||||
// Slots are no longer pre-generated based on recurring rules.
|
||||
const generateSlots = 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}$/),
|
||||
overwriteExisting: z.boolean().default(false),
|
||||
})
|
||||
)
|
||||
.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.");
|
||||
}
|
||||
|
||||
// Validierung: maximal 12 Wochen Zeitraum
|
||||
const start = new Date(input.startDate);
|
||||
const end = new Date(input.endDate);
|
||||
const daysDiff = Math.ceil((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24));
|
||||
if (daysDiff > 84) { // 12 Wochen = 84 Tage
|
||||
throw new Error("Zeitraum darf maximal 12 Wochen betragen.");
|
||||
}
|
||||
|
||||
// Lade alle aktiven Regeln
|
||||
const allRules = await recurringRulesKV.getAllItems();
|
||||
const activeRules = allRules.filter(rule => rule.isActive);
|
||||
|
||||
// Lade alle Urlaubszeiten
|
||||
const timeOffPeriods = await timeOffPeriodsKV.getAllItems();
|
||||
|
||||
// Lade bestehende Slots (ohne Auto-Cleanup)
|
||||
const existingSlots = await call(availabilityRouter.peekAll, {}, {});
|
||||
|
||||
// Erstelle Set für effiziente Duplikat-Prüfung
|
||||
const existing = new Set(existingSlots.map(s => `${s.date}T${s.time}`));
|
||||
|
||||
let created = 0;
|
||||
let skipped = 0;
|
||||
let updated = 0;
|
||||
|
||||
// Iteriere über jeden Tag im Zeitraum
|
||||
const currentDate = new Date(start);
|
||||
while (currentDate <= end) {
|
||||
const dateStr = formatDate(currentDate);
|
||||
// Verwende lokale Datumskomponenten für korrekte Wochentag-Berechnung
|
||||
const [y, m, d] = dateStr.split('-').map(Number);
|
||||
const localDate = new Date(y, m - 1, d);
|
||||
const dayOfWeek = localDate.getDay(); // 0=Sonntag, 1=Montag, ...
|
||||
|
||||
// Prüfe, ob Datum in einer Urlaubszeit liegt
|
||||
if (isDateInTimeOffPeriod(dateStr, timeOffPeriods)) {
|
||||
currentDate.setDate(currentDate.getDate() + 1);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Finde alle Regeln für diesen Wochentag
|
||||
const matchingRules = activeRules.filter(rule => rule.dayOfWeek === dayOfWeek);
|
||||
|
||||
for (const rule of matchingRules) {
|
||||
// Skip rules without slotDurationMinutes (legacy field for deprecated generateSlots)
|
||||
if (!rule.slotDurationMinutes) {
|
||||
console.log(`Skipping rule ${rule.id} - no slotDurationMinutes defined (legacy field)`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const startMinutes = parseTime(rule.startTime);
|
||||
const endMinutes = parseTime(rule.endTime);
|
||||
|
||||
// Generiere Slots in slotDurationMinutes-Schritten
|
||||
let currentMinutes = startMinutes;
|
||||
while (currentMinutes + rule.slotDurationMinutes <= endMinutes) {
|
||||
const timeStr = formatTime(currentMinutes);
|
||||
const key = `${dateStr}T${timeStr}`;
|
||||
|
||||
// Prüfe, ob bereits ein Slot für dieses Datum+Zeit existiert
|
||||
if (existing.has(key)) {
|
||||
if (input.overwriteExisting) {
|
||||
// Finde den bestehenden Slot für Update
|
||||
const existingSlot = existingSlots.find(
|
||||
slot => slot.date === dateStr && slot.time === timeStr
|
||||
);
|
||||
if (existingSlot && existingSlot.status === "free") {
|
||||
// Überschreibe Dauer des bestehenden Slots
|
||||
const updatedSlot = {
|
||||
...existingSlot,
|
||||
durationMinutes: rule.slotDurationMinutes,
|
||||
};
|
||||
await call(availabilityRouter.update, {
|
||||
sessionId: input.sessionId,
|
||||
...updatedSlot
|
||||
}, {});
|
||||
updated++;
|
||||
} else {
|
||||
skipped++;
|
||||
}
|
||||
} else {
|
||||
skipped++;
|
||||
}
|
||||
} else {
|
||||
// Erstelle neuen Slot mit try/catch für Duplikat-Konflikte
|
||||
try {
|
||||
await call(availabilityRouter.create, {
|
||||
sessionId: input.sessionId,
|
||||
date: dateStr,
|
||||
time: timeStr,
|
||||
durationMinutes: rule.slotDurationMinutes,
|
||||
}, {});
|
||||
existing.add(key);
|
||||
created++;
|
||||
} catch (err: any) {
|
||||
// Behandle bekannte Duplikat-Fehler
|
||||
if (err.message && err.message.includes("bereits ein Slot")) {
|
||||
skipped++;
|
||||
} else {
|
||||
throw err; // Re-throw unbekannte Fehler
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
currentMinutes += rule.slotDurationMinutes;
|
||||
}
|
||||
}
|
||||
|
||||
currentDate.setDate(currentDate.getDate() + 1);
|
||||
}
|
||||
|
||||
const message = `${created} Slots erstellt, ${updated} aktualisiert, ${skipped} übersprungen.`;
|
||||
console.log(`Slot generation completed: ${message}`);
|
||||
|
||||
return {
|
||||
created,
|
||||
updated,
|
||||
skipped,
|
||||
message,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error("recurring-availability.generateSlots error", err);
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
// 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: string[] = [];
|
||||
|
||||
// Helper functions for 15-minute boundary alignment
|
||||
const ceilTo15 = (m: number) => m % 15 === 0 ? m : m + (15 - (m % 15));
|
||||
const floorTo15 = (m: number) => 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<string, number>();
|
||||
|
||||
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,
|
||||
|
||||
// Generator
|
||||
generateSlots,
|
||||
|
||||
// Availability
|
||||
getAvailableTimes,
|
||||
|
||||
// Live queries
|
||||
live,
|
||||
};
|
Reference in New Issue
Block a user