Implementiere Stornierungssystem und E-Mail-Links zur Hauptseite

- Neues Stornierungssystem mit sicheren Token-basierten Links
- Stornierungsfrist konfigurierbar über MIN_STORNO_TIMESPAN (24h Standard)
- Stornierungs-Seite mit Buchungsdetails und Ein-Klick-Stornierung
- Automatische Slot-Freigabe bei Stornierung
- Stornierungs-Link in Bestätigungs-E-Mails integriert
- Alle E-Mails enthalten jetzt Links zur Hauptseite (DOMAIN Variable)
- Schöne HTML-Buttons und Text-Links in allen E-Mail-Templates
- Vollständige Validierung: Vergangenheits-Check, Token-Ablauf, Stornierungsfrist
- Responsive Stornierungs-Seite mit Loading-States und Fehlerbehandlung
- Dokumentation in README.md aktualisiert
This commit is contained in:
2025-09-30 17:48:03 +02:00
parent e5384e46ce
commit 55923e0426
13 changed files with 741 additions and 30 deletions

View File

@@ -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<typeof CancellationTokenSchema>;
const cancellationKV = createKV<CancellationToken>("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<Booking>("bookings");
const availabilityKV = createAvailabilityKV<Availability>("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<any>("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,
};