Files
beauty-bookings/server-dist/rpc/bookings.js

808 lines
42 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { call, os } from "@orpc/server";
import { z } from "zod";
import { randomUUID } from "crypto";
import { createKV } from "../lib/create-kv.js";
import { sendEmail, sendEmailWithAGBAndCalendar, sendEmailWithInspirationPhoto } from "../lib/email.js";
import { renderBookingPendingHTML, renderBookingConfirmedHTML, renderBookingCancelledHTML, renderAdminBookingNotificationHTML, renderBookingRescheduleProposalHTML, renderAdminRescheduleAcceptedHTML, renderAdminRescheduleDeclinedHTML, renderCustomerMessageHTML } from "../lib/email-templates.js";
import { createORPCClient } from "@orpc/client";
import { RPCLink } from "@orpc/client/fetch";
import { checkBookingRateLimit } from "../lib/rate-limiter.js";
import { validateEmail } from "../lib/email-validator.js";
// 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);
const id = randomUUID();
const booking = {
id,
...input,
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: input.customerName,
date: input.appointmentDate,
time: input.appointmentTime,
statusUrl: bookingUrl
});
await sendEmail({
to: input.customerEmail,
subject: "Deine Terminanfrage ist eingegangen",
text: `Hallo ${input.customerName},\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: input.customerName,
date: input.appointmentDate,
time: input.appointmentTime,
treatment: treatmentName,
phone: input.customerPhone || "Nicht angegeben",
notes: input.notes,
hasInspirationPhoto: !!input.inspirationPhoto
});
const homepageUrl = generateUrl();
const adminText = `Neue Buchungsanfrage eingegangen:\n\n` +
`Name: ${input.customerName}\n` +
`Telefon: ${input.customerPhone || "Nicht angegeben"}\n` +
`Behandlung: ${treatmentName}\n` +
`Datum: ${formatDateGerman(input.appointmentDate)}\n` +
`Uhrzeit: ${input.appointmentTime}\n` +
`${input.notes ? `Notizen: ${input.notes}\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 - ${input.customerName}`,
text: adminText,
html: adminHtml,
}, input.inspirationPhoto, input.customerName).catch(() => { });
}
else {
await sendEmail({
to: process.env.ADMIN_EMAIL,
subject: `Neue Buchungsanfrage - ${input.customerName}`,
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;
}
});
const sessionsKV = createKV("sessions");
const usersKV = createKV("users");
async function assertOwner(sessionId) {
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");
}
const updateStatus = os
.input(z.object({
sessionId: z.string(),
id: z.string(),
status: z.enum(["pending", "confirmed", "cancelled", "completed"])
}))
.handler(async ({ input }) => {
await assertOwner(input.sessionId);
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 ${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 ${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({
sessionId: z.string(),
id: z.string(),
sendEmail: z.boolean().optional().default(false)
}))
.handler(async ({ input }) => {
await assertOwner(input.sessionId);
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 ${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({
sessionId: 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(),
appointmentTime: z.string(),
notes: z.string().optional(),
}))
.handler(async ({ input }) => {
// Admin authentication
await assertOwner(input.sessionId);
// 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);
const id = randomUUID();
const booking = {
id,
treatmentId: input.treatmentId,
customerName: input.customerName,
customerEmail: input.customerEmail,
customerPhone: input.customerPhone,
appointmentDate: input.appointmentDate,
appointmentTime: input.appointmentTime,
notes: input.notes,
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: input.customerName,
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 ${input.customerName},\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: input.customerName,
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({
sessionId: z.string(),
bookingId: z.string(),
proposedDate: z.string(),
proposedTime: z.string(),
}))
.handler(async ({ input }) => {
await assertOwner(input.sessionId);
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({ sessionId: z.string() }))
.handler(async ({ input }) => {
await assertOwner(input.sessionId);
// Generiere einen sicheren Token für CalDAV-Zugriff
const token = randomUUID();
// Hole Session-Daten für Token-Erstellung
const session = await sessionsKV.getItem(input.sessionId);
if (!session)
throw new Error("Session nicht gefunden");
// Speichere Token mit Ablaufzeit (24 Stunden)
const tokenData = {
id: token,
sessionId: input.sessionId,
userId: session.userId, // Benötigt für Session-Typ
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), // 24 Stunden
createdAt: new Date().toISOString(),
};
// Verwende den sessionsKV Store für Token-Speicherung
await sessionsKV.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?token=${token}`;
return {
token,
caldavUrl,
expiresAt: tokenData.expiresAt,
instructions: {
title: "CalDAV-Kalender abonnieren",
steps: [
"Kopiere die CalDAV-URL unten",
"Füge sie in deiner Kalender-App als Abonnement hinzu:",
"- Outlook: Datei → Konto hinzufügen → Internetkalender",
"- Google Calendar: Andere Kalender hinzufügen → Von URL",
"- Apple Calendar: Abonnement → Neue Abonnements",
"- Thunderbird: Kalender hinzufügen → Im Netzwerk",
"Der Kalender wird automatisch aktualisiert"
],
note: "Dieser Token ist 24 Stunden gültig. Bei Bedarf kannst du einen neuen Token generieren."
}
};
}),
// Admin sendet Nachricht an Kunden
sendCustomerMessage: os
.input(z.object({
sessionId: z.string(),
bookingId: z.string(),
message: z.string().min(1, "Nachricht darf nicht leer sein").max(5000, "Nachricht ist zu lang (max. 5000 Zeichen)"),
}))
.handler(async ({ input }) => {
await assertOwner(input.sessionId);
const booking = await kv.getItem(input.bookingId);
if (!booking)
throw new Error("Buchung nicht gefunden");
// Check if booking has customer email
if (!booking.customerEmail) {
throw new Error("Diese Buchung hat keine E-Mail-Adresse. Bitte kontaktiere den Kunden telefonisch.");
}
// Check if booking is in the future
const today = new Date().toISOString().split("T")[0];
const bookingDate = booking.appointmentDate;
if (bookingDate < today) {
throw new Error("Nachrichten können nur für zukünftige Termine gesendet werden.");
}
// Get treatment name for context
const treatment = await treatmentsKV.getItem(booking.treatmentId);
const treatmentName = treatment?.name || "Behandlung";
// Prepare email with Reply-To header
const ownerName = process.env.OWNER_NAME || "Stargirlnails Kiel";
const emailFrom = process.env.EMAIL_FROM || "Stargirlnails <no-reply@stargirlnails.de>";
const replyToEmail = process.env.ADMIN_EMAIL;
const formattedDate = formatDateGerman(bookingDate);
const html = await renderCustomerMessageHTML({
customerName: booking.customerName,
message: input.message,
appointmentDate: bookingDate,
appointmentTime: booking.appointmentTime,
treatmentName: treatmentName,
});
const textContent = `Hallo ${booking.customerName},\n\nZu deinem Termin:\nBehandlung: ${treatmentName}\nDatum: ${formattedDate}\nUhrzeit: ${booking.appointmentTime}\n\nNachricht von ${ownerName}:\n${input.message}\n\nBei Fragen oder Anliegen kannst du einfach auf diese E-Mail antworten wir helfen dir gerne weiter!\n\nLiebe Grüße,\n${ownerName}`;
// Send email with BCC to admin for monitoring
// Note: Not using explicit 'from' or 'replyTo' to match behavior of other system emails
console.log(`Sending customer message to ${booking.customerEmail} for booking ${input.bookingId}`);
console.log(`Email config: from=${emailFrom}, replyTo=${replyToEmail}, bcc=${replyToEmail}`);
const emailResult = await sendEmail({
to: booking.customerEmail,
subject: `Nachricht zu deinem Termin am ${formattedDate}`,
text: textContent,
html: html,
bcc: replyToEmail ? [replyToEmail] : undefined,
});
if (!emailResult.success) {
console.error(`Failed to send customer message to ${booking.customerEmail}`);
throw new Error("E-Mail konnte nicht versendet werden. Bitte überprüfe die E-Mail-Konfiguration oder versuche es später erneut.");
}
console.log(`Successfully sent customer message to ${booking.customerEmail}`);
return {
success: true,
message: `Nachricht wurde erfolgreich an ${booking.customerEmail} gesendet.`
};
}),
};