import { call, os } from "@orpc/server"; import { z } from "zod"; import { createKV } from "../lib/create-kv.js"; import { createKV as createAvailabilityKV } from "../lib/create-kv.js"; import { randomUUID } from "crypto"; // Schema for booking access token (used for both status viewing and cancellation) const BookingAccessTokenSchema = z.object({ id: z.string(), bookingId: z.string(), token: z.string(), expiresAt: z.string(), createdAt: z.string(), purpose: z.enum(["booking_access", "reschedule_proposal"]), // Extended for reschedule proposals // Optional metadata for reschedule proposals proposedDate: z.string().optional(), proposedTime: z.string().optional(), originalDate: z.string().optional(), originalTime: z.string().optional(), }); type BookingAccessToken = z.output; // Backwards compatibility alias type CancellationToken = BookingAccessToken; const cancellationKV = createKV("cancellation_tokens"); // Types for booking and availability type Booking = { id: string; treatments: Array<{ id: string; name: string; duration: number; price: number; }>; // Deprecated fields for backward compatibility treatmentId?: string; bookedDurationMinutes?: number; customerName: string; customerEmail?: string; customerPhone?: string; appointmentDate: string; appointmentTime: string; notes?: string; inspirationPhoto?: string; slotId?: string; status: "pending" | "confirmed" | "cancelled" | "completed"; createdAt: string; }; type Availability = { id: string; date: string; time: string; durationMinutes: number; status: "free" | "reserved"; reservedByBookingId?: string; createdAt: string; }; const bookingsKV = createKV("bookings"); const availabilityKV = createAvailabilityKV("availability"); // 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 to invalidate all reschedule tokens for a specific booking async function invalidateRescheduleTokensForBooking(bookingId: string): Promise { const tokens = await cancellationKV.getAllItems(); const related = tokens.filter(t => t.bookingId === bookingId && t.purpose === "reschedule_proposal"); for (const tok of related) { await cancellationKV.removeItem(tok.id); } } // Create cancellation token for a booking const createToken = os .input(z.object({ bookingId: z.string() })) .handler(async ({ input }) => { const booking = await bookingsKV.getItem(input.bookingId); if (!booking) { throw new Error("Booking not found"); } if (booking.status === "cancelled") { throw new Error("Booking is already cancelled"); } // Create token that expires in 30 days const expiresAt = new Date(); expiresAt.setDate(expiresAt.getDate() + 30); const token = randomUUID(); const cancellationToken: BookingAccessToken = { id: randomUUID(), bookingId: input.bookingId, token, expiresAt: expiresAt.toISOString(), createdAt: new Date().toISOString(), purpose: "booking_access", }; await cancellationKV.setItem(cancellationToken.id, cancellationToken); return { token, expiresAt: expiresAt.toISOString() }; }); // Get booking details by token const getBookingByToken = os .input(z.object({ token: z.string() })) .handler(async ({ input }) => { const tokens = await cancellationKV.getAllItems(); const validToken = tokens.find(t => t.token === input.token && new Date(t.expiresAt) > new Date() && t.purpose === 'booking_access' ); if (!validToken) { throw new Error("Invalid or expired cancellation token"); } const booking = await bookingsKV.getItem(validToken.bookingId); if (!booking) { throw new Error("Booking not found"); } // Handle treatments array let treatments: Array<{id: string; name: string; duration: number; price: number}>; let totalDuration: number; let totalPrice: number; if (booking.treatments && booking.treatments.length > 0) { // New bookings with treatments array treatments = booking.treatments; totalDuration = treatments.reduce((sum, t) => sum + t.duration, 0); totalPrice = treatments.reduce((sum, t) => sum + (t.price / 100), 0); } else if (booking.treatmentId) { // Old bookings with single treatmentId (backward compatibility) const treatmentsKV = createKV("treatments"); const treatment = await treatmentsKV.getItem(booking.treatmentId); if (treatment) { treatments = [{ id: treatment.id, name: treatment.name, duration: treatment.duration, price: treatment.price, }]; totalDuration = treatment.duration; totalPrice = treatment.price / 100; } else { // Fallback if treatment not found treatments = []; totalDuration = booking.bookedDurationMinutes || 60; totalPrice = 0; } } else { // Edge case: no treatments and no treatmentId treatments = []; totalDuration = 0; totalPrice = 0; } // Calculate if cancellation is still possible const minStornoTimespan = parseInt(process.env.MIN_STORNO_TIMESPAN || "24"); const appointmentDateTime = new Date(`${booking.appointmentDate}T${booking.appointmentTime}:00`); const now = new Date(); const timeDifferenceHours = (appointmentDateTime.getTime() - now.getTime()) / (1000 * 60 * 60); const canCancel = timeDifferenceHours >= minStornoTimespan && booking.status !== "cancelled" && booking.status !== "completed"; return { id: booking.id, customerName: booking.customerName, customerEmail: booking.customerEmail, customerPhone: booking.customerPhone, appointmentDate: booking.appointmentDate, appointmentTime: booking.appointmentTime, treatments, totalDuration, totalPrice, status: booking.status, notes: booking.notes, formattedDate: formatDateGerman(booking.appointmentDate), createdAt: booking.createdAt, canCancel, hoursUntilAppointment: Math.max(0, Math.round(timeDifferenceHours)), }; }); // Cancel booking by token const cancelByToken = os .input(z.object({ token: z.string() })) .handler(async ({ input }) => { const tokens = await cancellationKV.getAllItems(); const validToken = tokens.find(t => t.token === input.token && new Date(t.expiresAt) > new Date() ); if (!validToken) { throw new Error("Invalid or expired cancellation token"); } const booking = await bookingsKV.getItem(validToken.bookingId); if (!booking) { throw new Error("Booking not found"); } // Check if booking is already cancelled if (booking.status === "cancelled") { throw new Error("Booking is already cancelled"); } // Check minimum cancellation timespan from environment variable const minStornoTimespan = parseInt(process.env.MIN_STORNO_TIMESPAN || "24"); // Default: 24 hours const appointmentDateTime = new Date(`${booking.appointmentDate}T${booking.appointmentTime}:00`); const now = new Date(); const timeDifferenceHours = (appointmentDateTime.getTime() - now.getTime()) / (1000 * 60 * 60); if (timeDifferenceHours < minStornoTimespan) { throw new Error(`Stornierung ist nur bis ${minStornoTimespan} Stunden vor dem Termin möglich. Der Termin liegt nur noch ${Math.round(timeDifferenceHours)} Stunden in der Zukunft.`); } // Check if booking is in the past (additional safety check) const today = new Date().toISOString().split("T")[0]; if (booking.appointmentDate < today) { throw new Error("Cannot cancel past bookings"); } // For today's bookings, check if the time is not in the past if (booking.appointmentDate === today) { const currentTime = `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`; if (booking.appointmentTime <= currentTime) { throw new Error("Cannot cancel bookings that have already started"); } } // Update booking status const updatedBooking = { ...booking, status: "cancelled" as const }; await bookingsKV.setItem(booking.id, updatedBooking); // Free the slot if it exists if (booking.slotId) { const slot = await availabilityKV.getItem(booking.slotId); if (slot) { const updatedSlot: Availability = { ...slot, status: "free", reservedByBookingId: undefined, }; await availabilityKV.setItem(slot.id, updatedSlot); } } // Invalidate the token await cancellationKV.removeItem(validToken.id); return { success: true, message: "Booking cancelled successfully", formattedDate: formatDateGerman(booking.appointmentDate), }; }); export const router = { createToken, getBookingByToken, cancelByToken, // Create a reschedule proposal token (48h expiry) createRescheduleToken: os .input(z.object({ bookingId: z.string(), proposedDate: z.string(), proposedTime: z.string() })) .handler(async ({ input }) => { const booking = await bookingsKV.getItem(input.bookingId); if (!booking) { throw new Error("Booking not found"); } if (booking.status === "cancelled" || booking.status === "completed") { throw new Error("Reschedule not allowed for this booking"); } // Invalidate existing reschedule proposals for this booking await invalidateRescheduleTokensForBooking(input.bookingId); // Create token that expires in 48 hours const expiresAt = new Date(); expiresAt.setHours(expiresAt.getHours() + 48); const token = randomUUID(); const rescheduleToken: BookingAccessToken = { id: randomUUID(), bookingId: input.bookingId, token, expiresAt: expiresAt.toISOString(), createdAt: new Date().toISOString(), purpose: "reschedule_proposal", proposedDate: input.proposedDate, proposedTime: input.proposedTime, originalDate: booking.appointmentDate, originalTime: booking.appointmentTime, }; await cancellationKV.setItem(rescheduleToken.id, rescheduleToken); return { token, expiresAt: expiresAt.toISOString() }; }), // Get reschedule proposal details by token getRescheduleProposal: os .input(z.object({ token: z.string() })) .handler(async ({ input }) => { const tokens = await cancellationKV.getAllItems(); const proposal = tokens.find(t => t.token === input.token && t.purpose === "reschedule_proposal"); if (!proposal) { throw new Error("Ungültiger Reschedule-Token"); } const booking = await bookingsKV.getItem(proposal.bookingId); if (!booking) { throw new Error("Booking not found"); } // Handle treatments array let treatments: Array<{id: string; name: string; duration: number; price: number}>; let totalDuration: number; let totalPrice: number; if (booking.treatments && booking.treatments.length > 0) { // New bookings with treatments array treatments = booking.treatments; totalDuration = treatments.reduce((sum, t) => sum + t.duration, 0); totalPrice = treatments.reduce((sum, t) => sum + (t.price / 100), 0); } else if (booking.treatmentId) { // Old bookings with single treatmentId (backward compatibility) const treatmentsKV = createKV("treatments"); const treatment = await treatmentsKV.getItem(booking.treatmentId); if (treatment) { treatments = [{ id: treatment.id, name: treatment.name, duration: treatment.duration, price: treatment.price, }]; totalDuration = treatment.duration; totalPrice = treatment.price / 100; } else { // Fallback if treatment not found treatments = []; totalDuration = booking.bookedDurationMinutes || 60; totalPrice = 0; } } else { // Edge case: no treatments and no treatmentId treatments = []; totalDuration = 0; totalPrice = 0; } const now = new Date(); const isExpired = new Date(proposal.expiresAt) <= now; const hoursUntilExpiry = isExpired ? 0 : Math.max(0, Math.round((new Date(proposal.expiresAt).getTime() - now.getTime()) / (1000 * 60 * 60))); return { booking: { id: booking.id, customerName: booking.customerName, customerEmail: booking.customerEmail, customerPhone: booking.customerPhone, status: booking.status, treatments, totalDuration, totalPrice, }, original: { date: proposal.originalDate || booking.appointmentDate, time: proposal.originalTime || booking.appointmentTime, }, proposed: { date: proposal.proposedDate, time: proposal.proposedTime, }, expiresAt: proposal.expiresAt, hoursUntilExpiry, isExpired, canRespond: booking.status === "confirmed" && !isExpired, }; }), // Helper endpoint to remove a reschedule token by value (used after accept/decline) removeRescheduleToken: os .input(z.object({ token: z.string() })) .handler(async ({ input }) => { const tokens = await cancellationKV.getAllItems(); const proposal = tokens.find(t => t.token === input.token && t.purpose === "reschedule_proposal"); if (proposal) { await cancellationKV.removeItem(proposal.id); } return { success: true }; }), // Clean up expired reschedule proposals and notify admin sweepExpiredRescheduleProposals: os .handler(async () => { const tokens = await cancellationKV.getAllItems(); const now = new Date(); const expiredProposals = tokens.filter(t => t.purpose === "reschedule_proposal" && new Date(t.expiresAt) <= now ); if (expiredProposals.length === 0) { return { success: true, expiredCount: 0 }; } // Get booking details for each expired proposal const expiredDetails: Array<{ customerName: string; originalDate: string; originalTime: string; proposedDate: string; proposedTime: string; treatmentName: string; customerEmail?: string; customerPhone?: string; expiredAt: string; }> = []; for (const proposal of expiredProposals) { const booking = await bookingsKV.getItem(proposal.bookingId); if (booking) { const treatmentsKV = createKV("treatments"); // Get treatment name(s) from new treatments array or fallback to deprecated treatmentId let treatmentName = "Unbekannte Behandlung"; if (booking.treatments && Array.isArray(booking.treatments) && booking.treatments.length > 0) { treatmentName = booking.treatments.map((t: any) => t.name).join(", "); } else if (booking.treatmentId) { const treatment = await treatmentsKV.getItem(booking.treatmentId); treatmentName = treatment?.name || "Unbekannte Behandlung"; } expiredDetails.push({ customerName: booking.customerName, originalDate: proposal.originalDate || booking.appointmentDate, originalTime: proposal.originalTime || booking.appointmentTime, proposedDate: proposal.proposedDate!, proposedTime: proposal.proposedTime!, treatmentName: treatmentName, customerEmail: booking.customerEmail, customerPhone: booking.customerPhone, expiredAt: proposal.expiresAt, }); } // Remove the expired token await cancellationKV.removeItem(proposal.id); } // Notify admin if there are expired proposals if (expiredDetails.length > 0 && process.env.ADMIN_EMAIL) { try { const { renderAdminRescheduleExpiredHTML } = await import("../lib/email-templates.js"); const { sendEmail } = await import("../lib/email.js"); const html = await renderAdminRescheduleExpiredHTML({ expiredProposals: expiredDetails, }); await sendEmail({ to: process.env.ADMIN_EMAIL, subject: `${expiredDetails.length} abgelaufene Terminänderungsvorschläge`, text: `Es sind ${expiredDetails.length} Terminänderungsvorschläge abgelaufen. Details in der HTML-Version.`, html, }); } catch (error) { console.error("Failed to send admin notification for expired proposals:", error); } } return { success: true, expiredCount: expiredDetails.length }; }), };