Files
beauty-bookings/src/server/rpc/cancellation.ts
elpatron 889e110dd9 fix: Preisanzeige korrigieren - Cent zu Euro Konvertierung
Problem: Preise wurden in Cent gespeichert aber fälschlicherweise als Euro angezeigt
Lösung: Alle Backend-APIs konvertieren Preise korrekt von Cent zu Euro (/100)

Betroffene Dateien:
- bookings.ts: E-Mail-Templates und Preisberechnungen
- cancellation.ts: Preisberechnungen für Stornierungen
- email-templates.ts: HTML-E-Mail-Templates
- email.ts: ICS-Kalender-Integration
- caldav.ts: CalDAV-Kalender-Export

Jetzt werden Preise konsistent in Euro angezeigt (z.B. 50.00€ statt 5000.00€)
2025-10-09 15:59:00 +02:00

486 lines
17 KiB
TypeScript

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<typeof BookingAccessTokenSchema>;
// Backwards compatibility alias
type CancellationToken = BookingAccessToken;
const cancellationKV = createKV<BookingAccessToken>("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<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}`;
}
// Helper to invalidate all reschedule tokens for a specific booking
async function invalidateRescheduleTokensForBooking(bookingId: string): Promise<void> {
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<any>("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<any>("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<any>("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 };
}),
};