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 "; 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.` }; }), };