790 lines
41 KiB
JavaScript
790 lines
41 KiB
JavaScript
import { z } from "zod";
|
|
import { randomUUID } from "crypto";
|
|
import { createKV } from "../lib/create-kv.js";
|
|
import { sanitizeText, sanitizeHtml, sanitizePhone } from "../lib/sanitize.js";
|
|
import { sendEmail, sendEmailWithAGBAndCalendar, sendEmailWithInspirationPhoto } from "../lib/email.js";
|
|
import { renderBookingPendingHTML, renderBookingConfirmedHTML, renderBookingCancelledHTML, renderAdminBookingNotificationHTML, renderBookingRescheduleProposalHTML, renderAdminRescheduleAcceptedHTML, renderAdminRescheduleDeclinedHTML } from "../lib/email-templates.js";
|
|
import { os as baseOs, call as baseCall } from "@orpc/server";
|
|
const osAny = baseOs;
|
|
const os = (osAny.withContext ? osAny.withContext() : (osAny.context ? osAny.context() : baseOs));
|
|
const call = baseCall;
|
|
import { createORPCClient } from "@orpc/client";
|
|
import { RPCLink } from "@orpc/client/fetch";
|
|
import { checkBookingRateLimit, enforceAdminRateLimit } from "../lib/rate-limiter.js";
|
|
import { validateEmail } from "../lib/email-validator.js";
|
|
import { assertOwner, getSessionFromCookies } from "../lib/auth.js";
|
|
// Using centrally typed os and call from rpc/index
|
|
// Create a server-side client to call other RPC endpoints
|
|
const serverPort = process.env.PORT ? parseInt(process.env.PORT) : 3000;
|
|
const link = new RPCLink({ url: `http://localhost:${serverPort}/rpc` });
|
|
// Typisierung über any, um Build-Inkompatibilität mit NestedClient zu vermeiden (nur für interne Server-Calls)
|
|
const queryClient = createORPCClient(link);
|
|
// Helper function to convert date from yyyy-mm-dd to dd.mm.yyyy
|
|
function formatDateGerman(dateString) {
|
|
const [year, month, day] = dateString.split('-');
|
|
return `${day}.${month}.${year}`;
|
|
}
|
|
// Helper function to generate URLs from DOMAIN environment variable
|
|
function generateUrl(path = '') {
|
|
const domain = process.env.DOMAIN || 'localhost:5173';
|
|
const protocol = domain.includes('localhost') ? 'http' : 'https';
|
|
return `${protocol}://${domain}${path}`;
|
|
}
|
|
// Helper function to parse time string to minutes since midnight
|
|
function parseTime(timeStr) {
|
|
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, periods) {
|
|
return periods.some(period => date >= period.startDate && date <= period.endDate);
|
|
}
|
|
// Helper function to validate booking time against recurring rules
|
|
async function validateBookingAgainstRules(date, time, treatmentDuration) {
|
|
// 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, time, treatmentDuration, excludeBookingId) {
|
|
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();
|
|
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 CreateBookingInputSchema = z.object({
|
|
treatmentId: z.string(),
|
|
customerName: z.string().min(2, "Name muss mindestens 2 Zeichen lang sein"),
|
|
customerEmail: z.string().email("Ungültige E-Mail-Adresse"),
|
|
customerPhone: z.string().min(5, "Telefonnummer muss mindestens 5 Zeichen lang sein").optional(),
|
|
appointmentDate: z.string(), // ISO date string
|
|
appointmentTime: z.string(), // HH:MM format
|
|
notes: z.string().optional(),
|
|
inspirationPhoto: z.string().optional(), // Base64 encoded image data
|
|
});
|
|
const BookingSchema = z.object({
|
|
id: z.string(),
|
|
treatmentId: z.string(),
|
|
customerName: z.string().min(2, "Name muss mindestens 2 Zeichen lang sein"),
|
|
customerEmail: z.string().email("Ungültige E-Mail-Adresse").optional(),
|
|
customerPhone: z.string().min(5, "Telefonnummer muss mindestens 5 Zeichen lang sein").optional(),
|
|
appointmentDate: z.string(), // ISO date string
|
|
appointmentTime: z.string(), // HH:MM format
|
|
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(),
|
|
});
|
|
const kv = createKV("bookings");
|
|
const recurringRulesKV = createKV("recurringRules");
|
|
const timeOffPeriodsKV = createKV("timeOffPeriods");
|
|
const treatmentsKV = createKV("treatments");
|
|
const create = os
|
|
.input(CreateBookingInputSchema)
|
|
.handler(async ({ input }) => {
|
|
// console.log("Booking create called with input:", {
|
|
// ...input,
|
|
// inspirationPhoto: input.inspirationPhoto ? `[${input.inspirationPhoto.length} chars]` : null
|
|
// });
|
|
try {
|
|
// Rate limiting check (ohne IP, falls Context-Header im Build nicht verfügbar sind)
|
|
const rateLimitResult = checkBookingRateLimit({
|
|
ip: undefined,
|
|
email: input.customerEmail,
|
|
});
|
|
if (!rateLimitResult.allowed) {
|
|
const retryMinutes = rateLimitResult.retryAfterSeconds
|
|
? Math.ceil(rateLimitResult.retryAfterSeconds / 60)
|
|
: 10;
|
|
throw new Error(`Zu viele Buchungsanfragen. Bitte versuche es in ${retryMinutes} Minute${retryMinutes > 1 ? 'n' : ''} erneut.`);
|
|
}
|
|
// Email validation before slot reservation
|
|
console.log(`Validating email: ${input.customerEmail}`);
|
|
const emailValidation = await validateEmail(input.customerEmail);
|
|
console.log(`Email validation result:`, emailValidation);
|
|
if (!emailValidation.valid) {
|
|
console.log(`Email validation failed: ${emailValidation.reason}`);
|
|
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) {
|
|
throw new Error("Buchungen für vergangene Termine sind nicht möglich.");
|
|
}
|
|
// For today's bookings, check if the time is not in the past
|
|
if (input.appointmentDate === today) {
|
|
const now = new Date();
|
|
const currentTime = `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`;
|
|
if (input.appointmentTime <= currentTime) {
|
|
throw new Error("Buchungen für vergangene Uhrzeiten sind nicht möglich.");
|
|
}
|
|
}
|
|
// Prevent double booking: same customer email with pending/confirmed on same date
|
|
// Skip duplicate check when DISABLE_DUPLICATE_CHECK is set
|
|
if (!process.env.DISABLE_DUPLICATE_CHECK) {
|
|
const existing = await kv.getAllItems();
|
|
const hasConflict = existing.some(b => (b.customerEmail && b.customerEmail.toLowerCase() === input.customerEmail.toLowerCase()) &&
|
|
b.appointmentDate === input.appointmentDate &&
|
|
(b.status === "pending" || b.status === "confirmed"));
|
|
if (hasConflict) {
|
|
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);
|
|
// Sanitize user-provided fields before storage
|
|
const sanitizedName = sanitizeText(input.customerName);
|
|
const sanitizedPhone = input.customerPhone ? sanitizePhone(input.customerPhone) : undefined;
|
|
const sanitizedNotes = input.notes ? sanitizeHtml(input.notes) : undefined;
|
|
const id = randomUUID();
|
|
const booking = {
|
|
id,
|
|
treatmentId: input.treatmentId,
|
|
customerName: sanitizedName,
|
|
customerEmail: input.customerEmail,
|
|
customerPhone: sanitizedPhone,
|
|
appointmentDate: input.appointmentDate,
|
|
appointmentTime: input.appointmentTime,
|
|
notes: sanitizedNotes,
|
|
inspirationPhoto: input.inspirationPhoto,
|
|
bookedDurationMinutes: treatment.duration, // Snapshot treatment duration
|
|
status: "pending",
|
|
createdAt: new Date().toISOString()
|
|
};
|
|
// Save the booking
|
|
await kv.setItem(id, booking);
|
|
// Notify customer: request received (pending)
|
|
void (async () => {
|
|
// Create booking access token for status viewing
|
|
const bookingAccessToken = await queryClient.cancellation.createToken({ bookingId: id });
|
|
const bookingUrl = generateUrl(`/booking/${bookingAccessToken.token}`);
|
|
const formattedDate = formatDateGerman(input.appointmentDate);
|
|
const homepageUrl = generateUrl();
|
|
const html = await renderBookingPendingHTML({
|
|
name: sanitizedName,
|
|
date: input.appointmentDate,
|
|
time: input.appointmentTime,
|
|
statusUrl: bookingUrl
|
|
});
|
|
await sendEmail({
|
|
to: input.customerEmail,
|
|
subject: "Deine Terminanfrage ist eingegangen",
|
|
text: `Hallo ${sanitizedName},\n\nwir haben deine Anfrage für ${formattedDate} um ${input.appointmentTime} erhalten. Wir bestätigen deinen Termin in Kürze. Du erhältst eine weitere E-Mail, sobald der Termin bestätigt ist.\n\nTermin-Status ansehen: ${bookingUrl}\n\nRechtliche Informationen: ${generateUrl('/legal')}\nZur Website: ${homepageUrl}\n\nLiebe Grüße\nStargirlnails Kiel`,
|
|
html,
|
|
}).catch(() => { });
|
|
})();
|
|
// Notify admin: new booking request (with photo if available)
|
|
void (async () => {
|
|
if (!process.env.ADMIN_EMAIL)
|
|
return;
|
|
// Get treatment name from KV
|
|
const allTreatments = await treatmentsKV.getAllItems();
|
|
const treatment = allTreatments.find(t => t.id === input.treatmentId);
|
|
const treatmentName = treatment?.name || "Unbekannte Behandlung";
|
|
const adminHtml = await renderAdminBookingNotificationHTML({
|
|
name: sanitizedName,
|
|
date: input.appointmentDate,
|
|
time: input.appointmentTime,
|
|
treatment: treatmentName,
|
|
phone: sanitizedPhone || "Nicht angegeben",
|
|
notes: sanitizedNotes,
|
|
hasInspirationPhoto: !!input.inspirationPhoto
|
|
});
|
|
const homepageUrl = generateUrl();
|
|
const adminText = `Neue Buchungsanfrage eingegangen:\n\n` +
|
|
`Name: ${sanitizedName}\n` +
|
|
`Telefon: ${sanitizedPhone || "Nicht angegeben"}\n` +
|
|
`Behandlung: ${treatmentName}\n` +
|
|
`Datum: ${formatDateGerman(input.appointmentDate)}\n` +
|
|
`Uhrzeit: ${input.appointmentTime}\n` +
|
|
`${sanitizedNotes ? `Notizen: ${sanitizedNotes}\n` : ''}` +
|
|
`Inspiration-Foto: ${input.inspirationPhoto ? 'Im Anhang verfügbar' : 'Kein Foto hochgeladen'}\n\n` +
|
|
`Zur Website: ${homepageUrl}\n\n` +
|
|
`Bitte logge dich in das Admin-Panel ein, um die Buchung zu bearbeiten.`;
|
|
if (input.inspirationPhoto) {
|
|
await sendEmailWithInspirationPhoto({
|
|
to: process.env.ADMIN_EMAIL,
|
|
subject: `Neue Buchungsanfrage - ${sanitizedName}`,
|
|
text: adminText,
|
|
html: adminHtml,
|
|
}, input.inspirationPhoto, sanitizedName).catch(() => { });
|
|
}
|
|
else {
|
|
await sendEmail({
|
|
to: process.env.ADMIN_EMAIL,
|
|
subject: `Neue Buchungsanfrage - ${sanitizedName}`,
|
|
text: adminText,
|
|
html: adminHtml,
|
|
}).catch(() => { });
|
|
}
|
|
})();
|
|
return booking;
|
|
}
|
|
catch (error) {
|
|
console.error("Booking creation error:", error);
|
|
// Re-throw the error for oRPC to handle
|
|
throw error;
|
|
}
|
|
});
|
|
// Owner check reuse (simple inline version)
|
|
const updateStatus = os
|
|
.input(z.object({
|
|
id: z.string(),
|
|
status: z.enum(["pending", "confirmed", "cancelled", "completed"])
|
|
}))
|
|
.handler(async ({ input, context }) => {
|
|
await assertOwner(context);
|
|
// Admin Rate Limiting nach erfolgreicher Owner-Prüfung
|
|
await enforceAdminRateLimit(context);
|
|
const booking = await kv.getItem(input.id);
|
|
if (!booking)
|
|
throw new Error("Booking not found");
|
|
const previousStatus = booking.status;
|
|
const updatedBooking = { ...booking, status: input.status };
|
|
await kv.setItem(input.id, updatedBooking);
|
|
// Note: Slot state management removed - bookings now validated against recurring rules
|
|
// Email notifications on status changes
|
|
try {
|
|
if (input.status === "confirmed") {
|
|
// Create booking access token for this booking (status + cancellation)
|
|
const bookingAccessToken = await queryClient.cancellation.createToken({ bookingId: booking.id });
|
|
const formattedDate = formatDateGerman(booking.appointmentDate);
|
|
const bookingUrl = generateUrl(`/booking/${bookingAccessToken.token}`);
|
|
const homepageUrl = generateUrl();
|
|
const html = await renderBookingConfirmedHTML({
|
|
name: booking.customerName,
|
|
date: booking.appointmentDate,
|
|
time: booking.appointmentTime,
|
|
cancellationUrl: bookingUrl, // Now points to booking status page
|
|
reviewUrl: generateUrl(`/review/${bookingAccessToken.token}`)
|
|
});
|
|
// Get treatment information for ICS file
|
|
const allTreatments = await treatmentsKV.getAllItems();
|
|
const treatment = allTreatments.find(t => t.id === booking.treatmentId);
|
|
const treatmentName = treatment?.name || "Behandlung";
|
|
// Use bookedDurationMinutes if available, otherwise fallback to treatment duration
|
|
const treatmentDuration = booking.bookedDurationMinutes || treatment?.duration || 60;
|
|
if (booking.customerEmail) {
|
|
await sendEmailWithAGBAndCalendar({
|
|
to: booking.customerEmail,
|
|
subject: "Dein Termin wurde bestätigt - AGB im Anhang",
|
|
text: `Hallo ${sanitizeText(booking.customerName)},\n\nwir haben deinen Termin am ${formattedDate} um ${booking.appointmentTime} bestätigt.\n\nWichtiger Hinweis: Die Allgemeinen Geschäftsbedingungen (AGB) findest du im Anhang dieser E-Mail. Bitte lies sie vor deinem Termin durch.\n\nTermin-Status ansehen und verwalten: ${bookingUrl}\nFalls du den Termin stornieren möchtest, kannst du das über den obigen Link tun.\n\nRechtliche Informationen: ${generateUrl('/legal')}\nZur Website: ${homepageUrl}\n\nBis bald!\nStargirlnails Kiel`,
|
|
html,
|
|
bcc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined,
|
|
}, {
|
|
date: booking.appointmentDate,
|
|
time: booking.appointmentTime,
|
|
durationMinutes: treatmentDuration,
|
|
customerName: booking.customerName,
|
|
treatmentName: treatmentName
|
|
});
|
|
}
|
|
}
|
|
else if (input.status === "cancelled") {
|
|
const formattedDate = formatDateGerman(booking.appointmentDate);
|
|
const homepageUrl = generateUrl();
|
|
const html = await renderBookingCancelledHTML({ name: booking.customerName, date: booking.appointmentDate, time: booking.appointmentTime });
|
|
if (booking.customerEmail) {
|
|
await sendEmail({
|
|
to: booking.customerEmail,
|
|
subject: "Dein Termin wurde abgesagt",
|
|
text: `Hallo ${sanitizeText(booking.customerName)},\n\nleider wurde dein Termin am ${formattedDate} um ${booking.appointmentTime} abgesagt. Bitte buche einen neuen Termin.\n\nRechtliche Informationen: ${generateUrl('/legal')}\nZur Website: ${homepageUrl}\n\nLiebe Grüße\nStargirlnails Kiel`,
|
|
html,
|
|
bcc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
catch (e) {
|
|
console.error("Email send failed:", e);
|
|
}
|
|
return updatedBooking;
|
|
});
|
|
const remove = os
|
|
.input(z.object({
|
|
id: z.string(),
|
|
sendEmail: z.boolean().optional().default(false)
|
|
}))
|
|
.handler(async ({ input, context }) => {
|
|
await assertOwner(context);
|
|
// Admin Rate Limiting nach erfolgreicher Owner-Prüfung
|
|
await enforceAdminRateLimit(context);
|
|
const booking = await kv.getItem(input.id);
|
|
if (!booking)
|
|
throw new Error("Booking not found");
|
|
// Guard against deletion of past bookings or completed bookings
|
|
const today = new Date().toISOString().split("T")[0];
|
|
const isPastDate = booking.appointmentDate < today;
|
|
const isCompleted = booking.status === 'completed';
|
|
if (isPastDate || isCompleted) {
|
|
// For past/completed bookings, disable email sending to avoid confusing customers
|
|
if (input.sendEmail) {
|
|
console.log(`Email sending disabled for past/completed booking ${input.id}`);
|
|
}
|
|
input.sendEmail = false;
|
|
}
|
|
const wasAlreadyCancelled = booking.status === 'cancelled';
|
|
const updatedBooking = { ...booking, status: "cancelled" };
|
|
await kv.setItem(input.id, updatedBooking);
|
|
if (input.sendEmail && !wasAlreadyCancelled && booking.customerEmail) {
|
|
try {
|
|
const formattedDate = formatDateGerman(booking.appointmentDate);
|
|
const homepageUrl = generateUrl();
|
|
const html = await renderBookingCancelledHTML({ name: booking.customerName, date: booking.appointmentDate, time: booking.appointmentTime });
|
|
await sendEmail({
|
|
to: booking.customerEmail,
|
|
subject: "Dein Termin wurde abgesagt",
|
|
text: `Hallo ${sanitizeText(booking.customerName)},\n\nleider wurde dein Termin am ${formattedDate} um ${booking.appointmentTime} abgesagt. Bitte buche einen neuen Termin.\n\nRechtliche Informationen: ${generateUrl('/legal')}\nZur Website: ${homepageUrl}\n\nLiebe Grüße\nStargirlnails Kiel`,
|
|
html,
|
|
bcc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined,
|
|
});
|
|
}
|
|
catch (e) {
|
|
console.error("Email send failed:", e);
|
|
}
|
|
}
|
|
return updatedBooking;
|
|
});
|
|
// Admin-only manual booking creation (immediately confirmed)
|
|
const createManual = os
|
|
.input(z.object({
|
|
treatmentId: z.string(),
|
|
customerName: z.string().min(2, "Name muss mindestens 2 Zeichen lang sein"),
|
|
customerEmail: z.string().email("Ungültige E-Mail-Adresse").optional(),
|
|
customerPhone: z.string().min(5, "Telefonnummer muss mindestens 5 Zeichen lang sein").optional(),
|
|
appointmentDate: z.string(),
|
|
appointmentTime: z.string(),
|
|
notes: z.string().optional(),
|
|
}))
|
|
.handler(async ({ input, context }) => {
|
|
// Admin authentication
|
|
await assertOwner(context);
|
|
// Admin Rate Limiting nach erfolgreicher Owner-Prüfung
|
|
await enforceAdminRateLimit(context);
|
|
// 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];
|
|
if (input.appointmentDate < today) {
|
|
throw new Error("Buchungen für vergangene Termine sind nicht möglich.");
|
|
}
|
|
// For today's bookings, check if the time is not in the past
|
|
if (input.appointmentDate === today) {
|
|
const now = new Date();
|
|
const currentTime = `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`;
|
|
if (input.appointmentTime <= currentTime) {
|
|
throw new Error("Buchungen für vergangene Uhrzeiten sind nicht möglich.");
|
|
}
|
|
}
|
|
// 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);
|
|
// Sanitize user-provided fields before storage (admin manual booking)
|
|
const sanitizedName = sanitizeText(input.customerName);
|
|
const sanitizedPhone = input.customerPhone ? sanitizePhone(input.customerPhone) : undefined;
|
|
const sanitizedNotes = input.notes ? sanitizeHtml(input.notes) : undefined;
|
|
const id = randomUUID();
|
|
const booking = {
|
|
id,
|
|
treatmentId: input.treatmentId,
|
|
customerName: sanitizedName,
|
|
customerEmail: input.customerEmail,
|
|
customerPhone: sanitizedPhone,
|
|
appointmentDate: input.appointmentDate,
|
|
appointmentTime: input.appointmentTime,
|
|
notes: sanitizedNotes,
|
|
bookedDurationMinutes: treatment.duration,
|
|
status: "confirmed",
|
|
createdAt: new Date().toISOString()
|
|
};
|
|
// Save the booking
|
|
await kv.setItem(id, booking);
|
|
// Create booking access token for status viewing and cancellation (always create token)
|
|
const bookingAccessToken = await queryClient.cancellation.createToken({ bookingId: id });
|
|
// Send confirmation email if email is provided
|
|
if (input.customerEmail) {
|
|
void (async () => {
|
|
try {
|
|
const bookingUrl = generateUrl(`/booking/${bookingAccessToken.token}`);
|
|
const formattedDate = formatDateGerman(input.appointmentDate);
|
|
const homepageUrl = generateUrl();
|
|
const html = await renderBookingConfirmedHTML({
|
|
name: sanitizedName,
|
|
date: input.appointmentDate,
|
|
time: input.appointmentTime,
|
|
cancellationUrl: bookingUrl,
|
|
reviewUrl: generateUrl(`/review/${bookingAccessToken.token}`)
|
|
});
|
|
await sendEmailWithAGBAndCalendar({
|
|
to: input.customerEmail,
|
|
subject: "Dein Termin wurde bestätigt - AGB im Anhang",
|
|
text: `Hallo ${sanitizedName},\n\nwir haben deinen Termin am ${formattedDate} um ${input.appointmentTime} bestätigt.\n\nWichtiger Hinweis: Die Allgemeinen Geschäftsbedingungen (AGB) findest du im Anhang dieser E-Mail. Bitte lies sie vor deinem Termin durch.\n\nTermin-Status ansehen und verwalten: ${bookingUrl}\nFalls du den Termin stornieren möchtest, kannst du das über den obigen Link tun.\n\nRechtliche Informationen: ${generateUrl('/legal')}\nZur Website: ${homepageUrl}\n\nBis bald!\nStargirlnails Kiel`,
|
|
html,
|
|
}, {
|
|
date: input.appointmentDate,
|
|
time: input.appointmentTime,
|
|
durationMinutes: treatment.duration,
|
|
customerName: sanitizedName,
|
|
treatmentName: treatment.name
|
|
});
|
|
}
|
|
catch (e) {
|
|
console.error("Email send failed for manual booking:", e);
|
|
}
|
|
})();
|
|
}
|
|
// Optionally return the token in the RPC response for UI to copy/share (admin usage only)
|
|
return {
|
|
...booking,
|
|
bookingAccessToken: bookingAccessToken.token
|
|
};
|
|
});
|
|
const list = os.handler(async () => {
|
|
return kv.getAllItems();
|
|
});
|
|
const get = os.input(z.string()).handler(async ({ input }) => {
|
|
return kv.getItem(input);
|
|
});
|
|
const getByDate = os
|
|
.input(z.string()) // YYYY-MM-DD format
|
|
.handler(async ({ input }) => {
|
|
const allBookings = await kv.getAllItems();
|
|
return allBookings.filter(booking => booking.appointmentDate === input);
|
|
});
|
|
const live = {
|
|
list: os.handler(async function* ({ signal }) {
|
|
yield call(list, {}, { signal });
|
|
for await (const _ of kv.subscribe()) {
|
|
yield call(list, {}, { signal });
|
|
}
|
|
}),
|
|
byDate: os
|
|
.input(z.string())
|
|
.handler(async function* ({ input, signal }) {
|
|
yield call(getByDate, input, { signal });
|
|
for await (const _ of kv.subscribe()) {
|
|
yield call(getByDate, input, { signal });
|
|
}
|
|
}),
|
|
};
|
|
export const router = {
|
|
create,
|
|
createManual,
|
|
updateStatus,
|
|
remove,
|
|
list,
|
|
get,
|
|
getByDate,
|
|
live,
|
|
// Admin proposes a reschedule for a confirmed booking
|
|
proposeReschedule: os
|
|
.input(z.object({
|
|
bookingId: z.string(),
|
|
proposedDate: z.string(),
|
|
proposedTime: z.string(),
|
|
}))
|
|
.handler(async ({ input, context }) => {
|
|
await assertOwner(context);
|
|
const booking = await kv.getItem(input.bookingId);
|
|
if (!booking)
|
|
throw new Error("Booking not found");
|
|
if (booking.status !== "confirmed")
|
|
throw new Error("Nur bestätigte Termine können umgebucht werden.");
|
|
const treatment = await treatmentsKV.getItem(booking.treatmentId);
|
|
if (!treatment)
|
|
throw new Error("Behandlung nicht gefunden.");
|
|
// Validate grid and not in past
|
|
const appointmentMinutes = parseTime(input.proposedTime);
|
|
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).");
|
|
}
|
|
const today = new Date().toISOString().split("T")[0];
|
|
if (input.proposedDate < today) {
|
|
throw new Error("Buchungen für vergangene Termine sind nicht möglich.");
|
|
}
|
|
if (input.proposedDate === today) {
|
|
const now = new Date();
|
|
const currentTime = `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`;
|
|
if (input.proposedTime <= currentTime) {
|
|
throw new Error("Buchungen für vergangene Uhrzeiten sind nicht möglich.");
|
|
}
|
|
}
|
|
await validateBookingAgainstRules(input.proposedDate, input.proposedTime, booking.bookedDurationMinutes || treatment.duration);
|
|
await checkBookingConflicts(input.proposedDate, input.proposedTime, booking.bookedDurationMinutes || treatment.duration, booking.id);
|
|
// Invalidate and create new reschedule token via cancellation router
|
|
const res = await queryClient.cancellation.createRescheduleToken({
|
|
bookingId: booking.id,
|
|
proposedDate: input.proposedDate,
|
|
proposedTime: input.proposedTime,
|
|
});
|
|
const acceptUrl = generateUrl(`/booking/${res.token}?action=accept`);
|
|
const declineUrl = generateUrl(`/booking/${res.token}?action=decline`);
|
|
// Send proposal email to customer
|
|
if (booking.customerEmail) {
|
|
const html = await renderBookingRescheduleProposalHTML({
|
|
name: booking.customerName,
|
|
originalDate: booking.appointmentDate,
|
|
originalTime: booking.appointmentTime,
|
|
proposedDate: input.proposedDate,
|
|
proposedTime: input.proposedTime,
|
|
treatmentName: (await treatmentsKV.getItem(booking.treatmentId))?.name || "Behandlung",
|
|
acceptUrl,
|
|
declineUrl,
|
|
expiresAt: res.expiresAt,
|
|
});
|
|
await sendEmail({
|
|
to: booking.customerEmail,
|
|
subject: "Vorschlag zur Terminänderung",
|
|
text: `Hallo ${booking.customerName}, wir schlagen vor, deinen Termin von ${formatDateGerman(booking.appointmentDate)} ${booking.appointmentTime} auf ${formatDateGerman(input.proposedDate)} ${input.proposedTime} zu verschieben. Akzeptieren: ${acceptUrl} | Ablehnen: ${declineUrl}`,
|
|
html,
|
|
bcc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined,
|
|
}).catch(() => { });
|
|
}
|
|
return { success: true, token: res.token };
|
|
}),
|
|
// Customer accepts reschedule via token
|
|
acceptReschedule: os
|
|
.input(z.object({ token: z.string() }))
|
|
.handler(async ({ input }) => {
|
|
const proposal = await queryClient.cancellation.getRescheduleProposal({ token: input.token });
|
|
const booking = await kv.getItem(proposal.booking.id);
|
|
if (!booking)
|
|
throw new Error("Booking not found");
|
|
if (booking.status !== "confirmed")
|
|
throw new Error("Buchung ist nicht mehr in bestätigtem Zustand.");
|
|
const treatment = await treatmentsKV.getItem(booking.treatmentId);
|
|
const duration = booking.bookedDurationMinutes || treatment?.duration || 60;
|
|
// Re-validate slot to ensure still available
|
|
await validateBookingAgainstRules(proposal.proposed.date, proposal.proposed.time, duration);
|
|
await checkBookingConflicts(proposal.proposed.date, proposal.proposed.time, duration, booking.id);
|
|
const updated = { ...booking, appointmentDate: proposal.proposed.date, appointmentTime: proposal.proposed.time };
|
|
await kv.setItem(updated.id, updated);
|
|
// Remove token
|
|
await queryClient.cancellation.removeRescheduleToken({ token: input.token });
|
|
// Send confirmation to customer (no BCC to avoid duplicate admin emails)
|
|
if (updated.customerEmail) {
|
|
const bookingAccessToken = await queryClient.cancellation.createToken({ bookingId: updated.id });
|
|
const html = await renderBookingConfirmedHTML({
|
|
name: updated.customerName,
|
|
date: updated.appointmentDate,
|
|
time: updated.appointmentTime,
|
|
cancellationUrl: generateUrl(`/booking/${bookingAccessToken.token}`),
|
|
reviewUrl: generateUrl(`/review/${bookingAccessToken.token}`),
|
|
});
|
|
await sendEmailWithAGBAndCalendar({
|
|
to: updated.customerEmail,
|
|
subject: "Terminänderung bestätigt",
|
|
text: `Hallo ${updated.customerName}, dein neuer Termin ist am ${formatDateGerman(updated.appointmentDate)} um ${updated.appointmentTime}.`,
|
|
html,
|
|
}, {
|
|
date: updated.appointmentDate,
|
|
time: updated.appointmentTime,
|
|
durationMinutes: duration,
|
|
customerName: updated.customerName,
|
|
treatmentName: (await treatmentsKV.getItem(updated.treatmentId))?.name || "Behandlung",
|
|
}).catch(() => { });
|
|
}
|
|
if (process.env.ADMIN_EMAIL) {
|
|
const adminHtml = await renderAdminRescheduleAcceptedHTML({
|
|
customerName: updated.customerName,
|
|
originalDate: proposal.original.date,
|
|
originalTime: proposal.original.time,
|
|
newDate: updated.appointmentDate,
|
|
newTime: updated.appointmentTime,
|
|
treatmentName: (await treatmentsKV.getItem(updated.treatmentId))?.name || "Behandlung",
|
|
});
|
|
await sendEmail({
|
|
to: process.env.ADMIN_EMAIL,
|
|
subject: `Reschedule akzeptiert - ${updated.customerName}`,
|
|
text: `Reschedule akzeptiert: ${updated.customerName} von ${formatDateGerman(proposal.original.date)} ${proposal.original.time} auf ${formatDateGerman(updated.appointmentDate)} ${updated.appointmentTime}.`,
|
|
html: adminHtml,
|
|
}).catch(() => { });
|
|
}
|
|
return { success: true, message: `Termin auf ${formatDateGerman(updated.appointmentDate)} um ${updated.appointmentTime} aktualisiert.` };
|
|
}),
|
|
// Customer declines reschedule via token
|
|
declineReschedule: os
|
|
.input(z.object({ token: z.string() }))
|
|
.handler(async ({ input }) => {
|
|
const proposal = await queryClient.cancellation.getRescheduleProposal({ token: input.token });
|
|
const booking = await kv.getItem(proposal.booking.id);
|
|
if (!booking)
|
|
throw new Error("Booking not found");
|
|
// Remove token
|
|
await queryClient.cancellation.removeRescheduleToken({ token: input.token });
|
|
// Notify customer that original stays
|
|
if (booking.customerEmail) {
|
|
const bookingAccessToken = await queryClient.cancellation.createToken({ bookingId: booking.id });
|
|
await sendEmail({
|
|
to: booking.customerEmail,
|
|
subject: "Terminänderung abgelehnt",
|
|
text: `Du hast den Vorschlag zur Terminänderung abgelehnt. Dein ursprünglicher Termin am ${formatDateGerman(booking.appointmentDate)} um ${booking.appointmentTime} bleibt bestehen.`,
|
|
html: await renderBookingConfirmedHTML({ name: booking.customerName, date: booking.appointmentDate, time: booking.appointmentTime, cancellationUrl: generateUrl(`/booking/${bookingAccessToken.token}`), reviewUrl: generateUrl(`/review/${bookingAccessToken.token}`) }),
|
|
}).catch(() => { });
|
|
}
|
|
// Notify admin
|
|
if (process.env.ADMIN_EMAIL) {
|
|
const html = await renderAdminRescheduleDeclinedHTML({
|
|
customerName: booking.customerName,
|
|
originalDate: proposal.original.date,
|
|
originalTime: proposal.original.time,
|
|
proposedDate: proposal.proposed.date,
|
|
proposedTime: proposal.proposed.time,
|
|
treatmentName: (await treatmentsKV.getItem(booking.treatmentId))?.name || "Behandlung",
|
|
customerEmail: booking.customerEmail,
|
|
customerPhone: booking.customerPhone,
|
|
});
|
|
await sendEmail({
|
|
to: process.env.ADMIN_EMAIL,
|
|
subject: `Reschedule abgelehnt - ${booking.customerName}`,
|
|
text: `Abgelehnt: ${booking.customerName}. Ursprünglich: ${formatDateGerman(proposal.original.date)} ${proposal.original.time}. Vorschlag: ${formatDateGerman(proposal.proposed.date)} ${proposal.proposed.time}.`,
|
|
html,
|
|
}).catch(() => { });
|
|
}
|
|
return { success: true, message: "Du hast den Vorschlag abgelehnt. Dein ursprünglicher Termin bleibt bestehen." };
|
|
}),
|
|
// CalDAV Token für Admin generieren
|
|
generateCalDAVToken: os
|
|
.input(z.object({}))
|
|
.handler(async ({ input, context }) => {
|
|
await assertOwner(context);
|
|
// Generiere einen sicheren Token für CalDAV-Zugriff
|
|
const token = randomUUID();
|
|
// Hole Session-Daten aus Cookies
|
|
const session = await getSessionFromCookies(context);
|
|
if (!session)
|
|
throw new Error("Invalid session");
|
|
// Speichere Token mit Ablaufzeit (24 Stunden)
|
|
const tokenData = {
|
|
id: token,
|
|
userId: session.userId,
|
|
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), // 24 Stunden
|
|
createdAt: new Date().toISOString(),
|
|
};
|
|
// Dedizierten KV-Store für CalDAV-Token verwenden
|
|
const caldavTokensKV = createKV("caldavTokens");
|
|
await caldavTokensKV.setItem(token, tokenData);
|
|
const domain = process.env.DOMAIN || 'localhost:3000';
|
|
const protocol = domain.includes('localhost') ? 'http' : 'https';
|
|
const caldavUrl = `${protocol}://${domain}/caldav/calendar/events.ics`;
|
|
return {
|
|
token,
|
|
caldavUrl,
|
|
expiresAt: tokenData.expiresAt,
|
|
instructions: {
|
|
title: "CalDAV-Kalender abonnieren",
|
|
steps: [
|
|
"⚠️ WICHTIG: Der Token darf NICHT in der URL stehen, sondern im Authorization-Header!",
|
|
"",
|
|
"📋 Dein CalDAV-Token (kopieren):",
|
|
token,
|
|
"",
|
|
"🔗 CalDAV-URL (ohne Token):",
|
|
caldavUrl,
|
|
"",
|
|
"📱 Einrichtung nach Kalender-App:",
|
|
"",
|
|
"🍎 Apple Calendar (macOS/iOS):",
|
|
"- Leider keine native Unterstützung für Authorization-Header",
|
|
"- Alternative: Verwende eine CalDAV-Bridge oder importiere die ICS-Datei manuell",
|
|
"",
|
|
"📧 Outlook:",
|
|
"- Datei → Kontoeinstellungen → Internetkalender",
|
|
"- URL eingeben (ohne Token)",
|
|
"- Erweiterte Einstellungen → Benutzerdefinierte Header hinzufügen:",
|
|
" Authorization: Bearer <DEIN_TOKEN>",
|
|
"",
|
|
"🌐 Google Calendar:",
|
|
"- Andere Kalender → Von URL hinzufügen",
|
|
"- Hinweis: Google Calendar unterstützt keine Authorization-Header",
|
|
"- Alternative: Verwende Google Apps Script oder importiere manuell",
|
|
"",
|
|
"🦅 Thunderbird:",
|
|
"- Kalender → Neuer Kalender → Im Netzwerk",
|
|
"- Format: CalDAV",
|
|
"- URL eingeben",
|
|
"- Anmeldung: Basic Auth mit Token als Benutzername (Passwort leer/optional)",
|
|
"",
|
|
"💻 cURL-Beispiel zum Testen:",
|
|
`# Bearer\ncurl -H "Authorization: Bearer ${token}" ${caldavUrl}\n\n# Basic (Token als Benutzername, Passwort leer)\ncurl -H "Authorization: Basic $(printf \"%s\" \"${token}:\" | base64)" ${caldavUrl}`,
|
|
"",
|
|
"⏰ Token-Gültigkeit: 24 Stunden",
|
|
"🔄 Bei Bedarf kannst du jederzeit einen neuen Token generieren."
|
|
],
|
|
note: "Aus Sicherheitsgründen wird der Token NICHT in der URL übertragen. Verwende den Authorization-Header: Bearer oder Basic (Token als Benutzername)."
|
|
}
|
|
};
|
|
}),
|
|
};
|