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:
199
src/server/rpc/cancellation.ts
Normal file
199
src/server/rpc/cancellation.ts
Normal 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,
|
||||
};
|
Reference in New Issue
Block a user