import { call, os } from "@orpc/server"; import { z } from "zod"; import { randomUUID } from "crypto"; import { createKV } from "../lib/create-kv.js"; import { sendEmail, sendEmailWithAGB, sendEmailWithAGBAndCalendar, sendEmailWithInspirationPhoto } from "../lib/email.js"; import { renderBookingPendingHTML, renderBookingConfirmedHTML, renderBookingCancelledHTML, renderAdminBookingNotificationHTML, renderBookingRescheduleProposalHTML, renderAdminRescheduleAcceptedHTML, renderAdminRescheduleDeclinedHTML, renderCustomerMessageHTML } from "../lib/email-templates.js"; import { router as rootRouter } from "./index.js"; import { createORPCClient } from "@orpc/client"; import { RPCLink } from "@orpc/client/fetch"; import { checkBookingRateLimit, getClientIP } 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: string): string { const [year, month, day] = dateString.split('-'); return `${day}.${month}.${year}`; } // Helper function to generate URLs from DOMAIN environment variable function generateUrl(path: string = ''): string { 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: string): number { const [hours, minutes] = timeStr.split(':').map(Number); return hours * 60 + minutes; } // Helper function to check if date is in time-off period function isDateInTimeOffPeriod(date: string, periods: TimeOffPeriod[]): boolean { return periods.some(period => date >= period.startDate && date <= period.endDate); } // Helper function to validate booking time against recurring rules async function validateBookingAgainstRules( date: string, time: string, totalDuration: number ): Promise { // 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 + totalDuration; const isWithinRules = matchingRules.some(rule => { const ruleStartMinutes = parseTime(rule.startTime); const ruleEndMinutes = parseTime(rule.endTime); // Booking must start at or after rule start and end at or before rule end return bookingStartMinutes >= ruleStartMinutes && bookingEndMinutes <= ruleEndMinutes; }); if (!isWithinRules) { throw new Error("Die gewählte Uhrzeit liegt außerhalb der verfügbaren Zeiten."); } } // Helper function to check for booking conflicts async function checkBookingConflicts( date: string, time: string, totalDuration: number, excludeBookingId?: string ): Promise { 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 + totalDuration; // Cache treatment durations by ID to avoid N+1 lookups (for backward compatibility with old bookings) const uniqueTreatmentIds = [...new Set(dateBookings.filter(b => b.treatmentId).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) { let existingDuration: number; // Handle both new bookings with treatments array and old bookings with treatmentId if (existingBooking.treatments && existingBooking.treatments.length > 0) { // New format: calculate duration from treatments array existingDuration = existingBooking.treatments.reduce((sum, t) => sum + t.duration, 0); } else if (existingBooking.treatmentId) { // Old format: use cached duration or fallback to bookedDurationMinutes if available existingDuration = treatmentDurationMap.get(existingBooking.treatmentId) || 60; if (existingBooking.bookedDurationMinutes) { existingDuration = existingBooking.bookedDurationMinutes; } } else { // Fallback for bookings without treatment info existingDuration = existingBooking.bookedDurationMinutes || 60; } 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."); } } } // Reusable treatments array schema with duplicate validation const TreatmentsArraySchema = z.array(z.object({ id: z.string(), name: z.string(), duration: z.number().positive(), price: z.number().nonnegative(), })) .min(1, "Mindestens eine Behandlung muss ausgewählt werden") .max(3, "Maximal 3 Behandlungen können ausgewählt werden") .refine(list => { const ids = list.map(t => t.id); return ids.length === new Set(ids).size; }, { message: "Doppelte Behandlungen sind nicht erlaubt" }); const CreateBookingInputSchema = z.object({ treatments: TreatmentsArraySchema, 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(), treatments: z.array(z.object({ id: z.string(), name: z.string(), duration: z.number().positive(), price: z.number().nonnegative() })), 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 createdAt: z.string(), // DEPRECATED: slotId is no longer used for validation, kept for backward compatibility slotId: z.string().optional(), // DEPRECATED: treatmentId and bookedDurationMinutes kept for backward compatibility treatmentId: z.string().optional(), bookedDurationMinutes: z.number().optional(), }); type Booking = z.output; const kv = createKV("bookings"); // DEPRECATED: Availability slots are no longer used for booking validation // type Availability = { // id: string; // date: string; // time: string; // durationMinutes: number; // status: "free" | "reserved"; // reservedByBookingId?: string; // createdAt: string; // }; // const availabilityKV = createAvailabilityKV("availability"); type RecurringRule = { id: string; dayOfWeek: number; startTime: string; endTime: string; isActive: boolean; createdAt: string; slotDurationMinutes?: number; }; type TimeOffPeriod = { id: string; startDate: string; endDate: string; reason: string; createdAt: string; }; const recurringRulesKV = createKV("recurringRules"); const timeOffPeriodsKV = createKV("timeOffPeriods"); // Import treatments KV for admin notifications type Treatment = { id: string; name: string; description: string; price: number; duration: number; category: string; createdAt: string; }; 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."); } } // Validate all treatments exist and snapshot them from KV const snapshottedTreatments = [] as Array<{id: string; name: string; duration: number; price: number}>; for (const inputTreatment of input.treatments) { const treatment = await treatmentsKV.getItem(inputTreatment.id); if (!treatment) { throw new Error(`Behandlung "${inputTreatment.name}" nicht gefunden.`); } // Verify snapshot data matches current treatment data if (treatment.name !== inputTreatment.name || treatment.duration !== inputTreatment.duration || treatment.price !== inputTreatment.price) { throw new Error(`Behandlungsdaten für "${inputTreatment.name}" stimmen nicht überein. Bitte lade die Seite neu.`); } snapshottedTreatments.push({ id: treatment.id, name: treatment.name, duration: treatment.duration, price: treatment.price }); } const totalDuration = snapshottedTreatments.reduce((sum, t) => sum + t.duration, 0); // Validate booking time against recurring rules await validateBookingAgainstRules( input.appointmentDate, input.appointmentTime, totalDuration ); // Check for booking conflicts await checkBookingConflicts( input.appointmentDate, input.appointmentTime, totalDuration ); const id = randomUUID(); const booking = { id, treatments: snapshottedTreatments, customerName: input.customerName, customerEmail: input.customerEmail, customerPhone: input.customerPhone, appointmentDate: input.appointmentDate, appointmentTime: input.appointmentTime, notes: input.notes, inspirationPhoto: input.inspirationPhoto, status: "pending" as const, 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, treatments: input.treatments }); const treatmentsText = input.treatments.map(t => `- ${t.name} (${t.duration} Min, ${t.price.toFixed(2)} €)`).join('\n'); const totalDuration = input.treatments.reduce((sum, t) => sum + t.duration, 0); const totalPrice = input.treatments.reduce((sum, t) => sum + t.price, 0); 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.\n\nBehandlungen:\n${treatmentsText}\n\nGesamt: ${totalDuration} Min, ${totalPrice.toFixed(2)} €\n\nWir 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; const adminHtml = await renderAdminBookingNotificationHTML({ name: input.customerName, date: input.appointmentDate, time: input.appointmentTime, treatments: input.treatments, phone: input.customerPhone || "Nicht angegeben", notes: input.notes, hasInspirationPhoto: !!input.inspirationPhoto }); const homepageUrl = generateUrl(); const treatmentsText = input.treatments.map(t => ` - ${t.name} (${t.duration} Min, ${t.price.toFixed(2)} €)`).join('\n'); const totalDuration = input.treatments.reduce((sum, t) => sum + t.duration, 0); const totalPrice = input.treatments.reduce((sum, t) => sum + t.price, 0); const adminText = `Neue Buchungsanfrage eingegangen:\n\n` + `Name: ${input.customerName}\n` + `Telefon: ${input.customerPhone || "Nicht angegeben"}\n` + `Behandlungen:\n${treatmentsText}\n` + `Gesamt: ${totalDuration} Min, ${totalPrice.toFixed(2)} €\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; } }); // Owner check reuse (simple inline version) type Session = { id: string; userId: string; expiresAt: string; createdAt: string }; type User = { id: string; username: string; email: string; passwordHash: string; role: "customer" | "owner"; createdAt: string }; const sessionsKV = createKV("sessions"); const usersKV = createKV("users"); async function assertOwner(sessionId: string): Promise { 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}`), treatments: booking.treatments }); const treatmentsText = booking.treatments.map(t => `- ${t.name} (${t.duration} Min, ${t.price.toFixed(2)} €)`).join('\n'); const totalDuration = booking.treatments.reduce((sum, t) => sum + t.duration, 0); const totalPrice = booking.treatments.reduce((sum, t) => sum + t.price, 0); 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\nBehandlungen:\n${treatmentsText}\n\nGesamt: ${totalDuration} Min, ${totalPrice.toFixed(2)} €\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, customerName: booking.customerName, customerEmail: booking.customerEmail, treatments: booking.treatments }); } } 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, treatments: booking.treatments }); const treatmentsText = booking.treatments.map(t => `- ${t.name} (${t.duration} Min, ${t.price.toFixed(2)} €)`).join('\n'); const totalDuration = booking.treatments.reduce((sum, t) => sum + t.duration, 0); const totalPrice = booking.treatments.reduce((sum, t) => sum + t.price, 0); 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.\n\nBehandlungen:\n${treatmentsText}\n\nGesamt: ${totalDuration} Min, ${totalPrice.toFixed(2)} €\n\nBitte 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" as const }; 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, treatments: booking.treatments }); const treatmentsText = booking.treatments.map(t => `- ${t.name} (${t.duration} Min, ${t.price.toFixed(2)} €)`).join('\n'); const totalDuration = booking.treatments.reduce((sum, t) => sum + t.duration, 0); const totalPrice = booking.treatments.reduce((sum, t) => sum + t.price, 0); 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.\n\nBehandlungen:\n${treatmentsText}\n\nGesamt: ${totalDuration} Min, ${totalPrice.toFixed(2)} €\n\nBitte 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(), treatments: TreatmentsArraySchema, 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."); } } // Validate all treatments exist and snapshot them from KV const snapshottedTreatments = [] as Array<{id: string; name: string; duration: number; price: number}>; for (const inputTreatment of input.treatments) { const treatment = await treatmentsKV.getItem(inputTreatment.id); if (!treatment) { throw new Error(`Behandlung "${inputTreatment.name}" nicht gefunden.`); } // Verify snapshot data matches current treatment data if (treatment.name !== inputTreatment.name || treatment.duration !== inputTreatment.duration || treatment.price !== inputTreatment.price) { throw new Error(`Behandlungsdaten für "${inputTreatment.name}" stimmen nicht überein. Bitte lade die Seite neu.`); } snapshottedTreatments.push({ id: treatment.id, name: treatment.name, duration: treatment.duration, price: treatment.price }); } const totalDuration = snapshottedTreatments.reduce((sum, t) => sum + t.duration, 0); // Validate booking time against recurring rules await validateBookingAgainstRules( input.appointmentDate, input.appointmentTime, totalDuration ); // Check for booking conflicts await checkBookingConflicts( input.appointmentDate, input.appointmentTime, totalDuration ); const id = randomUUID(); const booking = { id, treatments: snapshottedTreatments, customerName: input.customerName, customerEmail: input.customerEmail, customerPhone: input.customerPhone, appointmentDate: input.appointmentDate, appointmentTime: input.appointmentTime, notes: input.notes, status: "confirmed" as const, createdAt: new Date().toISOString() } as Booking; // 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}`), treatments: input.treatments }); const treatmentsText = input.treatments.map(t => `- ${t.name} (${t.duration} Min, ${t.price.toFixed(2)} €)`).join('\n'); const totalPrice = input.treatments.reduce((sum, t) => sum + t.price, 0); 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\nBehandlungen:\n${treatmentsText}\n\nGesamt: ${totalDuration} Min, ${totalPrice.toFixed(2)} €\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, customerName: input.customerName, customerEmail: input.customerEmail, treatments: input.treatments }); } 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."); // Calculate total duration from treatments array const totalDuration = booking.treatments && booking.treatments.length > 0 ? booking.treatments.reduce((sum, t) => sum + t.duration, 0) : (booking.bookedDurationMinutes || 60); // 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, totalDuration); await checkBookingConflicts(input.proposedDate, input.proposedTime, totalDuration, 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 treatmentName = booking.treatments && booking.treatments.length > 0 ? booking.treatments.map(t => t.name).join(', ') : "Behandlung"; const html = await renderBookingRescheduleProposalHTML({ name: booking.customerName, originalDate: booking.appointmentDate, originalTime: booking.appointmentTime, proposedDate: input.proposedDate, proposedTime: input.proposedTime, treatmentName: treatmentName, 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 duration = booking.treatments && booking.treatments.length > 0 ? booking.treatments.reduce((sum, t) => sum + t.duration, 0) : (booking.bookedDurationMinutes || 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 } as typeof booking; 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}`), treatments: updated.treatments, }); const treatmentsText = updated.treatments.map(t => `- ${t.name} (${t.duration} Min, ${t.price.toFixed(2)} €)`).join('\n'); const totalPrice = updated.treatments.reduce((sum, t) => sum + t.price, 0); 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}.\n\nBehandlungen:\n${treatmentsText}\n\nGesamt: ${duration} Min, ${totalPrice.toFixed(2)} €`, html, }, { date: updated.appointmentDate, time: updated.appointmentTime, customerName: updated.customerName, customerEmail: updated.customerEmail, treatments: updated.treatments, }).catch(() => {}); } if (process.env.ADMIN_EMAIL) { const treatmentName = updated.treatments && updated.treatments.length > 0 ? updated.treatments.map(t => t.name).join(', ') : "Behandlung"; const adminHtml = await renderAdminRescheduleAcceptedHTML({ customerName: updated.customerName, originalDate: proposal.original.date, originalTime: proposal.original.time, newDate: updated.appointmentDate, newTime: updated.appointmentTime, treatmentName: treatmentName, }); 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 }); const treatmentsText = booking.treatments.map(t => `- ${t.name} (${t.duration} Min, ${t.price.toFixed(2)} €)`).join('\n'); const totalDuration = booking.treatments.reduce((sum, t) => sum + t.duration, 0); const totalPrice = booking.treatments.reduce((sum, t) => sum + t.price, 0); 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.\n\nBehandlungen:\n${treatmentsText}\n\nGesamt: ${totalDuration} Min, ${totalPrice.toFixed(2)} €`, html: await renderBookingConfirmedHTML({ name: booking.customerName, date: booking.appointmentDate, time: booking.appointmentTime, cancellationUrl: generateUrl(`/booking/${bookingAccessToken.token}`), reviewUrl: generateUrl(`/review/${bookingAccessToken.token}`), treatments: booking.treatments }), }).catch(() => {}); } // Notify admin if (process.env.ADMIN_EMAIL) { const treatmentName = booking.treatments && booking.treatments.length > 0 ? booking.treatments.map(t => t.name).join(', ') : "Behandlung"; const html = await renderAdminRescheduleDeclinedHTML({ customerName: booking.customerName, originalDate: proposal.original.date, originalTime: proposal.original.time, proposedDate: proposal.proposed.date!, proposedTime: proposal.proposed.time!, treatmentName: treatmentName, 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 treatmentName = booking.treatments && booking.treatments.length > 0 ? booking.treatments.map(t => t.name).join(', ') : "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.` }; }), };