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€)
486 lines
17 KiB
TypeScript
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 };
|
|
}),
|
|
};
|