import { call, os } from "@orpc/server"; import { z } from "zod"; import { randomUUID } from "crypto"; import { createKV } from "../lib/create-kv"; import { createKV as createAvailabilityKV } from "../lib/create-kv"; import { sendEmail, sendEmailWithAGB, sendEmailWithAGBAndCalendar, sendEmailWithInspirationPhoto } from "../lib/email"; import { renderBookingPendingHTML, renderBookingConfirmedHTML, renderBookingCancelledHTML, renderAdminBookingNotificationHTML } from "../lib/email-templates"; import { router as rootRouter } from "./index"; import { createORPCClient } from "@orpc/client"; import { RPCLink } from "@orpc/client/fetch"; import { checkBookingRateLimit, getClientIP } from "../lib/rate-limiter"; import { validateEmail } from "../lib/email-validator"; // Create a server-side client to call other RPC endpoints const link = new RPCLink({ url: "http://localhost:5173/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}`; } 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"), customerPhone: z.string().min(5, "Telefonnummer muss mindestens 5 Zeichen lang sein"), 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(), slotId: z.string().optional(), }); type Booking = z.output; const kv = createKV("bookings"); type Availability = { id: string; date: string; time: string; durationMinutes: number; status: "free" | "reserved"; reservedByBookingId?: string; createdAt: string; }; const availabilityKV = createAvailabilityKV("availability"); // Import treatments KV for admin notifications import { createKV as createTreatmentsKV } from "../lib/create-kv"; type Treatment = { id: string; name: string; description: string; price: number; duration: number; category: string; createdAt: string; }; const treatmentsKV = createTreatmentsKV("treatments"); const create = os .input(BookingSchema.omit({ id: true, createdAt: true, status: true })) .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.` ); } // Deep email validation using Rapid Email Validator API const emailValidation = await validateEmail(input.customerEmail); if (!emailValidation.valid) { throw new Error(emailValidation.reason || "Ungültige E-Mail-Adresse"); } // 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 const existing = await kv.getAllItems(); const hasConflict = existing.some(b => 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."); } const id = randomUUID(); const booking = { id, ...input, status: "pending" as const, createdAt: new Date().toISOString() }; // If a slotId is provided, tentatively reserve the slot (mark reserved but pending) if (booking.slotId) { const slot = await availabilityKV.getItem(booking.slotId); if (!slot) throw new Error("Availability slot not found"); if (slot.status !== "free") throw new Error("Slot not available"); const updatedSlot: Availability = { ...slot, status: "reserved", reservedByBookingId: id, }; await availabilityKV.setItem(slot.id, updatedSlot); } 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({ input: { 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, notes: input.notes, hasInspirationPhoto: !!input.inspirationPhoto }); const homepageUrl = generateUrl(); const adminText = `Neue Buchungsanfrage eingegangen:\n\n` + `Name: ${input.customerName}\n` + `Telefon: ${input.customerPhone}\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); 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); // Manage availability slot state transitions if (booking.slotId) { const slot = await availabilityKV.getItem(booking.slotId); if (slot) { // console.log(`Updating slot ${slot.id} (${slot.date} ${slot.time}) from ${slot.status} to ${input.status}`); if (input.status === "cancelled") { // Free the slot again await availabilityKV.setItem(slot.id, { ...slot, status: "free", reservedByBookingId: undefined, }); // console.log(`Slot ${slot.id} freed due to cancellation`); } else if (input.status === "pending") { // keep reserved as pending if (slot.status !== "reserved") { await availabilityKV.setItem(slot.id, { ...slot, status: "reserved", reservedByBookingId: booking.id, }); // console.log(`Slot ${slot.id} reserved for pending booking`); } } else if (input.status === "confirmed" || input.status === "completed") { // keep reserved; optionally noop if (slot.status !== "reserved" || slot.reservedByBookingId !== booking.id) { await availabilityKV.setItem(slot.id, { ...slot, status: "reserved", reservedByBookingId: booking.id, }); // console.log(`Slot ${slot.id} confirmed as reserved`); } } } } // 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({ input: { 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 }); // 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"; const treatmentDuration = treatment?.duration || 60; // Default 60 minutes if not found 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, cc: 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 }); 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, cc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined, }); } } catch (e) { console.error("Email send failed:", e); } return updatedBooking; }); const remove = os.input(z.string()).handler(async ({ input }) => { await kv.removeItem(input); }); 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, updateStatus, remove, list, get, getByDate, live, };