diff --git a/.env.example b/.env.example index ff8456a..64ec7cd 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,6 @@ +# Domain +DOMAIN= + # Email Configuration RESEND_API_KEY=your_resend_api_key_here EMAIL_FROM=noreply@yourdomain.com @@ -7,6 +10,9 @@ ADMIN_EMAIL=admin@yourdomain.com ADMIN_USERNAME=owner ADMIN_PASSWORD_HASH=YWRtaW4xMjM= +# Min-Storno Time Span in hours +MIN_STORNO_TIMESPAN=24 + # OpenAI Configuration (optional) OPENAI_API_KEY=your_openai_api_key_here diff --git a/README.md b/README.md index 84fdf8d..f266953 100644 --- a/README.md +++ b/README.md @@ -59,10 +59,19 @@ Bearbeite deine `.env` Datei und setze die generierten Werte: ADMIN_USERNAME=owner ADMIN_PASSWORD_HASH=ZGVpbl9zaWNoZXJlc19wYXNzd29ydA== # Dein generierter Hash +# Domain Configuration +DOMAIN=localhost:5173 # Für Produktion: deine-domain.de + # Email Configuration RESEND_API_KEY=your_resend_api_key_here EMAIL_FROM=noreply@yourdomain.com ADMIN_EMAIL=admin@yourdomain.com + +# Frontend URL (für E-Mail Links) +FRONTEND_URL=http://localhost:5173 + +# Stornierungsfrist (in Stunden) +MIN_STORNO_TIMESPAN=24 ``` ### 4. Anwendung starten @@ -155,6 +164,8 @@ docker run -d \ - 📆 **Kalender-Ansicht**: Übersichtliche Darstellung aller Termine - ⏰ **Verfügbarkeits-Slots**: Flexible Slot-Verwaltung mit behandlungsspezifischen Dauern - 📧 **E-Mail-Benachrichtigungen**: Automatische Benachrichtigungen bei Buchungen +- ❌ **Termin-Stornierung**: Kunden können Termine über sichere Links stornieren +- ⏰ **Stornierungsfrist**: Konfigurierbare Mindestfrist vor dem Termin (MIN_STORNO_TIMESPAN) - 🔐 **Admin-Panel**: Geschützter Bereich für Inhaber ## Admin-Zugang diff --git a/src/client/app.tsx b/src/client/app.tsx index 3596ee5..25bf30f 100644 --- a/src/client/app.tsx +++ b/src/client/app.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, useEffect } from "react"; import { useAuth } from "@/client/components/auth-provider"; import { LoginForm } from "@/client/components/login-form"; import { UserProfile } from "@/client/components/user-profile"; @@ -8,11 +8,34 @@ import { AdminBookings } from "@/client/components/admin-bookings"; import { AdminCalendar } from "@/client/components/admin-calendar"; import { InitialDataLoader } from "@/client/components/initial-data-loader"; import { AdminAvailability } from "@/client/components/admin-availability"; +import CancellationPage from "@/client/components/cancellation-page"; function App() { const { user, isLoading, isOwner } = useAuth(); const [activeTab, setActiveTab] = useState<"booking" | "admin-treatments" | "admin-bookings" | "admin-calendar" | "admin-availability" | "profile">("booking"); + // Check for cancellation token in URL + useEffect(() => { + const path = window.location.pathname; + if (path.startsWith('/cancel/')) { + const token = path.split('/cancel/')[1]; + if (token) { + // Set a special state to show cancellation page + setActiveTab("cancellation" as any); + return; + } + } + }, []); + + // Handle cancellation page + const path = window.location.pathname; + if (path.startsWith('/cancel/')) { + const token = path.split('/cancel/')[1]; + if (token) { + return ; + } + } + // Show loading spinner while checking authentication if (isLoading) { return ( diff --git a/src/client/components/admin-availability.tsx b/src/client/components/admin-availability.tsx index 5730381..1f76a67 100644 --- a/src/client/components/admin-availability.tsx +++ b/src/client/components/admin-availability.tsx @@ -110,8 +110,6 @@ export function AdminAvailability() { return (
-

Verfügbarkeiten verwalten

- {/* Slot Type Selection */}

Neuen Slot anlegen

diff --git a/src/client/components/admin-bookings.tsx b/src/client/components/admin-bookings.tsx index fbd3bd1..ebad65e 100644 --- a/src/client/components/admin-bookings.tsx +++ b/src/client/components/admin-bookings.tsx @@ -66,8 +66,6 @@ export function AdminBookings() { return (
-

Manage Bookings

- {/* Quick Stats */}
diff --git a/src/client/components/booking-form.tsx b/src/client/components/booking-form.tsx index cc5aed1..415d39c 100644 --- a/src/client/components/booking-form.tsx +++ b/src/client/components/booking-form.tsx @@ -13,6 +13,7 @@ export function BookingForm() { const [agbAccepted, setAgbAccepted] = useState(false); const [inspirationPhoto, setInspirationPhoto] = useState(""); const [photoPreview, setPhotoPreview] = useState(""); + const [errorMessage, setErrorMessage] = useState(""); const { data: treatments } = useQuery( queryClient.treatments.live.list.experimental_liveOptions() @@ -22,7 +23,26 @@ export function BookingForm() { const { data: allSlots } = useQuery( queryClient.availability.live.list.experimental_liveOptions() ); - const freeSlots = (allSlots || []).filter((s) => s.status === "free"); + + // Filtere freie Slots und entferne vergangene Termine + const today = new Date().toISOString().split("T")[0]; // YYYY-MM-DD + const freeSlots = (allSlots || []).filter((s) => { + // Nur freie Slots + if (s.status !== "free") return false; + + // Nur zukünftige oder heutige Termine + if (s.date < today) return false; + + // Für heute: nur zukünftige Uhrzeiten + if (s.date === today) { + const now = new Date(); + const currentTime = `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`; + if (s.time <= currentTime) return false; + } + + return true; + }); + const availableDates = Array.from(new Set(freeSlots.map((s) => s.date))).sort(); const slotsByDate = appointmentDate ? freeSlots.filter((s) => s.date === appointmentDate) @@ -33,15 +53,32 @@ export function BookingForm() { ); const selectedTreatmentData = treatments?.find((t) => t.id === selectedTreatment); - const availableSlots = (slotsByDate || []).filter((s) => s.status === "free"); + const availableSlots = slotsByDate || []; // Slots sind bereits gefiltert + + // Debug logging (commented out - uncomment if needed) + // console.log("Debug - All slots:", allSlots); + // console.log("Debug - Free slots:", freeSlots); + // console.log("Debug - Available dates:", availableDates); + // console.log("Debug - Selected date:", appointmentDate); + // console.log("Debug - Slots by date:", slotsByDate); + // console.log("Debug - Available slots:", availableSlots); + + // Additional debugging for slot status + // if (allSlots && allSlots.length > 0) { + // const statusCounts = allSlots.reduce((acc, slot) => { + // acc[slot.status] = (acc[slot.status] || 0) + 1; + // return acc; + // }, {} as Record); + // console.log("Debug - Slot status counts:", statusCounts); + // } - const handlePhotoUpload = (e: React.ChangeEvent) => { + const handlePhotoUpload = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; - - // Check file size (max 5MB) - if (file.size > 5 * 1024 * 1024) { - alert("Das Foto ist zu groß. Bitte wähle ein Bild unter 5MB."); + + // Check file size (max 2MB for better performance) + if (file.size > 2 * 1024 * 1024) { + alert("Das Foto ist zu groß. Bitte wähle ein Bild unter 2MB."); return; } @@ -51,13 +88,46 @@ export function BookingForm() { return; } - const reader = new FileReader(); - reader.onload = (event) => { - const result = event.target?.result as string; - setInspirationPhoto(result); - setPhotoPreview(result); + // Compress the image before converting to base64 + const compressImage = (file: File, maxWidth: number = 800, quality: number = 0.8): Promise => { + return new Promise((resolve, reject) => { + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + const img = new Image(); + + img.onload = () => { + // Calculate new dimensions + let { width, height } = img; + if (width > maxWidth) { + height = (height * maxWidth) / width; + width = maxWidth; + } + + canvas.width = width; + canvas.height = height; + + // Draw and compress + ctx?.drawImage(img, 0, 0, width, height); + const compressedDataUrl = canvas.toDataURL('image/jpeg', quality); + resolve(compressedDataUrl); + }; + + img.onerror = reject; + img.src = URL.createObjectURL(file); + }); }; - reader.readAsDataURL(file); + + try { + const compressedDataUrl = await compressImage(file); + setInspirationPhoto(compressedDataUrl); + setPhotoPreview(compressedDataUrl); + // console.log(`Photo compressed: ${file.size} bytes → ${compressedDataUrl.length} chars`); + } catch (error) { + console.error('Photo compression failed:', error); + alert('Fehler beim Verarbeiten des Bildes. Bitte versuche es mit einem anderen Bild.'); + return; + } + }; const removePhoto = () => { @@ -70,16 +140,39 @@ export function BookingForm() { const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); + setErrorMessage(""); // Clear any previous error messages + + // console.log("Form submitted with data:", { + // selectedTreatment, + // customerName, + // customerEmail, + // customerPhone, + // appointmentDate, + // selectedSlotId, + // agbAccepted + // }); + if (!selectedTreatment || !customerName || !customerEmail || !customerPhone || !appointmentDate || !selectedSlotId) { - alert("Bitte fülle alle erforderlichen Felder aus"); + setErrorMessage("Bitte fülle alle erforderlichen Felder aus."); return; } if (!agbAccepted) { - alert("Bitte bestätige die Kenntnisnahme der Allgemeinen Geschäftsbedingungen"); + setErrorMessage("Bitte bestätige die Kenntnisnahme der Allgemeinen Geschäftsbedingungen."); return; } const slot = availableSlots.find((s) => s.id === selectedSlotId); const appointmentTime = slot?.time || ""; + // console.log("Creating booking with data:", { + // treatmentId: selectedTreatment, + // customerName, + // customerEmail, + // customerPhone, + // appointmentDate, + // appointmentTime, + // notes, + // inspirationPhoto, + // slotId: selectedSlotId, + // }); createBooking( { treatmentId: selectedTreatment, @@ -104,17 +197,22 @@ export function BookingForm() { setAgbAccepted(false); setInspirationPhoto(""); setPhotoPreview(""); + setErrorMessage(""); // Reset file input const fileInput = document.getElementById('photo-upload') as HTMLInputElement; if (fileInput) fileInput.value = ''; alert("Buchung erfolgreich erstellt! Wir werden dich kontaktieren, um deinen Termin zu bestätigen."); }, + onError: (error: any) => { + console.error("Booking error:", error); + const errorText = error?.message || error?.toString() || "Ein unbekannter Fehler ist aufgetreten."; + setErrorMessage(errorText); + }, } ); }; // Get minimum date (today) – nicht mehr genutzt, Datumsauswahl erfolgt aus freien Slots - const today = new Date().toISOString().split("T")[0]; return (
@@ -226,6 +324,11 @@ export function BookingForm() { ))} + {appointmentDate && availableSlots.length === 0 && ( +

+ Keine freien Zeitslots für {appointmentDate} verfügbar. +

+ )}
@@ -307,6 +410,18 @@ export function BookingForm() {
+ {/* Error Message */} + {errorMessage && ( +
+
+ + + + {errorMessage} +
+
+ )} + +
+ )} + +
+ + Zurück zur Startseite + +
+
+ + ); +} diff --git a/src/server/lib/email-templates.ts b/src/server/lib/email-templates.ts index 966b45a..0fdc53d 100644 --- a/src/server/lib/email-templates.ts +++ b/src/server/lib/email-templates.ts @@ -27,6 +27,10 @@ async function getLogoDataUrl(): Promise { async function renderBrandedEmail(title: string, bodyHtml: string): Promise { const logo = await getLogoDataUrl(); + const domain = process.env.DOMAIN || 'localhost:5173'; + const protocol = domain.includes('localhost') ? 'http' : 'https'; + const homepageUrl = `${protocol}://${domain}`; + return `
@@ -42,6 +46,9 @@ async function renderBrandedEmail(title: string, bodyHtml: string): Promise
+
© ${new Date().getFullYear()} Stargirlnails Kiel • Professional Nail Care
@@ -63,8 +70,8 @@ export async function renderBookingPendingHTML(params: { name: string; date: str return renderBrandedEmail("Deine Terminanfrage ist eingegangen", inner); } -export async function renderBookingConfirmedHTML(params: { name: string; date: string; time: string }) { - const { name, date, time } = params; +export async function renderBookingConfirmedHTML(params: { name: string; date: string; time: string; cancellationUrl?: string }) { + const { name, date, time, cancellationUrl } = params; const formattedDate = formatDateGerman(date); const inner = `

Hallo ${name},

@@ -74,6 +81,13 @@ export async function renderBookingConfirmedHTML(params: { name: string; date: s

📋 Wichtiger Hinweis:

Die Allgemeinen Geschäftsbedingungen (AGB) findest du im Anhang dieser E-Mail. Bitte lies sie vor deinem Termin durch.

+ ${cancellationUrl ? ` +
+

❌ Termin stornieren:

+

Falls du den Termin stornieren möchtest, kannst du das hier tun:

+ Termin stornieren +
+ ` : ''}

Liebe Grüße,
Stargirlnails Kiel

`; return renderBrandedEmail("Termin bestätigt", inner); diff --git a/src/server/lib/email.ts b/src/server/lib/email.ts index 6ba3c5e..45f9061 100644 --- a/src/server/lib/email.ts +++ b/src/server/lib/email.ts @@ -108,6 +108,14 @@ export async function sendEmailWithInspirationPhoto( const [, extension, base64Content] = match; const filename = `inspiration_${customerName.replace(/[^a-zA-Z0-9]/g, '_')}_${Date.now()}.${extension}`; + // Check if attachment is too large (max 1MB base64 content) + if (base64Content.length > 1024 * 1024) { + console.warn(`Photo attachment too large: ${base64Content.length} chars, skipping attachment`); + return sendEmail(params); + } + + // console.log(`Sending email with photo attachment: ${filename}, size: ${base64Content.length} chars`); + params.attachments = [ ...(params.attachments || []), { diff --git a/src/server/rpc/availability.ts b/src/server/rpc/availability.ts index 84a19d9..eb01e06 100644 --- a/src/server/rpc/availability.ts +++ b/src/server/rpc/availability.ts @@ -85,7 +85,34 @@ const remove = os }); const list = os.handler(async () => { - return kv.getAllItems(); + const allSlots = await kv.getAllItems(); + + // Filter out past slots automatically + const today = new Date().toISOString().split("T")[0]; // YYYY-MM-DD + const now = new Date(); + const currentTime = `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`; + + const filteredSlots = allSlots.filter(slot => { + // Keep slots for future dates + if (slot.date > today) return true; + + // For today: only keep future time slots + if (slot.date === today) { + return slot.time > currentTime; + } + + // Remove past slots + return false; + }); + + // Debug logging (commented out - uncomment if needed) + // const statusCounts = filteredSlots.reduce((acc, slot) => { + // acc[slot.status] = (acc[slot.status] || 0) + 1; + // return acc; + // }, {} as Record); + // console.log(`Availability list: ${filteredSlots.length} slots (${JSON.stringify(statusCounts)})`); + + return filteredSlots; }); const get = os.input(z.string()).handler(async ({ input }) => { diff --git a/src/server/rpc/bookings.ts b/src/server/rpc/bookings.ts index 700795a..4bd47c6 100644 --- a/src/server/rpc/bookings.ts +++ b/src/server/rpc/bookings.ts @@ -5,6 +5,13 @@ import { createKV } from "@/server/lib/create-kv"; import { createKV as createAvailabilityKV } from "@/server/lib/create-kv"; import { sendEmail, sendEmailWithAGB, sendEmailWithInspirationPhoto } from "@/server/lib/email"; import { renderBookingPendingHTML, renderBookingConfirmedHTML, renderBookingCancelledHTML, renderAdminBookingNotificationHTML } from "@/server/lib/email-templates"; +import { router } from "@/server/rpc"; +import { createORPCClient } from "@orpc/client"; +import { RPCLink } from "@orpc/client/fetch"; + +// Create a server-side client to call other RPC endpoints +const link = new RPCLink({ url: "http://localhost:5173/rpc" }); +const queryClient = createORPCClient(link); // Helper function to convert date from yyyy-mm-dd to dd.mm.yyyy function formatDateGerman(dateString: string): string { @@ -57,6 +64,27 @@ 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 { + // 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 => @@ -91,11 +119,14 @@ const create = os // Notify customer: request received (pending) void (async () => { const formattedDate = formatDateGerman(input.appointmentDate); + const domain = process.env.DOMAIN || 'localhost:5173'; + const protocol = domain.includes('localhost') ? 'http' : 'https'; + const homepageUrl = `${protocol}://${domain}`; const html = await renderBookingPendingHTML({ name: input.customerName, date: input.appointmentDate, time: input.appointmentTime }); 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.\n\nLiebe Grüße\nStargirlnails Kiel`, + text: `Hallo ${input.customerName},\n\nwir haben deine Anfrage für ${formattedDate} um ${input.appointmentTime} erhalten. Wir bestätigen deinen Termin in Kürze.\n\nZur Website: ${homepageUrl}\n\nLiebe Grüße\nStargirlnails Kiel`, html, }).catch(() => {}); })(); @@ -119,6 +150,10 @@ const create = os hasInspirationPhoto: !!input.inspirationPhoto }); + const domain = process.env.DOMAIN || 'localhost:5173'; + const protocol = domain.includes('localhost') ? 'http' : 'https'; + const homepageUrl = `${protocol}://${domain}`; + const adminText = `Neue Buchungsanfrage eingegangen:\n\n` + `Name: ${input.customerName}\n` + `Telefon: ${input.customerPhone}\n` + @@ -127,6 +162,7 @@ const create = os `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) { @@ -146,13 +182,17 @@ const create = os } })(); 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 = createAvailabilityKV("sessions"); -const usersKV = createAvailabilityKV("users"); +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"); @@ -180,6 +220,8 @@ const updateStatus = os 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, { @@ -187,6 +229,7 @@ const updateStatus = os 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") { @@ -195,6 +238,7 @@ const updateStatus = os 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 @@ -204,6 +248,7 @@ const updateStatus = os status: "reserved", reservedByBookingId: booking.id, }); + // console.log(`Slot ${slot.id} confirmed as reserved`); } } } @@ -211,22 +256,40 @@ const updateStatus = os // Email notifications on status changes try { if (input.status === "confirmed") { + // Create cancellation token for this booking + const cancellationToken = await queryClient.cancellation.createToken({ bookingId: booking.id }); + const formattedDate = formatDateGerman(booking.appointmentDate); - const html = await renderBookingConfirmedHTML({ name: booking.customerName, date: booking.appointmentDate, time: booking.appointmentTime }); + const cancellationUrl = `${process.env.FRONTEND_URL || 'http://localhost:5173'}/cancel/${cancellationToken.token}`; + + const html = await renderBookingConfirmedHTML({ + name: booking.customerName, + date: booking.appointmentDate, + time: booking.appointmentTime, + cancellationUrl + }); + + const domain = process.env.DOMAIN || 'localhost:5173'; + const protocol = domain.includes('localhost') ? 'http' : 'https'; + const homepageUrl = `${protocol}://${domain}`; + await sendEmailWithAGB({ 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\nBis bald!\nStargirlnails Kiel`, + 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\nFalls du den Termin stornieren möchtest, kannst du das hier tun: ${cancellationUrl}\n\nZur Website: ${homepageUrl}\n\nBis bald!\nStargirlnails Kiel`, html, cc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined, }); } else if (input.status === "cancelled") { const formattedDate = formatDateGerman(booking.appointmentDate); + const domain = process.env.DOMAIN || 'localhost:5173'; + const protocol = domain.includes('localhost') ? 'http' : 'https'; + const homepageUrl = `${protocol}://${domain}`; 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\nLiebe Grüße\nStargirlnails Kiel`, + text: `Hallo ${booking.customerName},\n\nleider wurde dein Termin am ${formattedDate} um ${booking.appointmentTime} abgesagt. Bitte buche einen neuen Termin.\n\nZur Website: ${homepageUrl}\n\nLiebe Grüße\nStargirlnails Kiel`, html, cc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined, }); diff --git a/src/server/rpc/cancellation.ts b/src/server/rpc/cancellation.ts new file mode 100644 index 0000000..f6cbdd1 --- /dev/null +++ b/src/server/rpc/cancellation.ts @@ -0,0 +1,199 @@ +import { call, os } from "@orpc/server"; +import { z } from "zod"; +import { createKV } from "@/server/lib/create-kv"; +import { createKV as createAvailabilityKV } from "@/server/lib/create-kv"; +import { randomUUID } from "crypto"; + +// Schema for cancellation token +const CancellationTokenSchema = z.object({ + id: z.string(), + bookingId: z.string(), + token: z.string(), + expiresAt: z.string(), + createdAt: z.string(), +}); + +type CancellationToken = z.output; + +const cancellationKV = createKV("cancellation_tokens"); + +// Types for booking and availability +type Booking = { + id: string; + treatmentId: string; + 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}`; +} + +// 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: CancellationToken = { + id: randomUUID(), + bookingId: input.bookingId, + token, + expiresAt: expiresAt.toISOString(), + createdAt: new Date().toISOString(), + }; + + 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() + ); + + 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"); + } + + // Get treatment details + const treatmentsKV = createKV("treatments"); + const treatment = await treatmentsKV.getItem(booking.treatmentId); + + return { + id: booking.id, + customerName: booking.customerName, + appointmentDate: booking.appointmentDate, + appointmentTime: booking.appointmentTime, + treatmentId: booking.treatmentId, + treatmentName: treatment?.name || "Unbekannte Behandlung", + status: booking.status, + formattedDate: formatDateGerman(booking.appointmentDate), + }; + }); + +// 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, +}; diff --git a/src/server/rpc/index.ts b/src/server/rpc/index.ts index 38c847b..6419933 100644 --- a/src/server/rpc/index.ts +++ b/src/server/rpc/index.ts @@ -3,6 +3,7 @@ import { router as treatments } from "./treatments"; import { router as bookings } from "./bookings"; import { router as auth } from "./auth"; import { router as availability } from "./availability"; +import { router as cancellation } from "./cancellation"; export const router = { demo, @@ -10,4 +11,5 @@ export const router = { bookings, auth, availability, + cancellation, };