feat: CalDAV-Integration für Admin-Kalender
- Neue CalDAV-Route mit PROPFIND und GET-Endpoints - ICS-Format-Generator für Buchungsdaten - Token-basierte Authentifizierung für CalDAV-Zugriff - Admin-Interface mit CalDAV-Link-Generator - Schritt-für-Schritt-Anleitung für Kalender-Apps - 24h-Token-Ablaufzeit für Sicherheit - Unterstützung für Outlook, Google Calendar, Apple Calendar, Thunderbird Fixes: Admin kann jetzt Terminkalender in externen Apps abonnieren
This commit is contained in:
310
server-dist/rpc/cancellation.js
Normal file
310
server-dist/rpc/cancellation.js
Normal file
@@ -0,0 +1,310 @@
|
||||
import { 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(),
|
||||
});
|
||||
const cancellationKV = createKV("cancellation_tokens");
|
||||
const bookingsKV = createKV("bookings");
|
||||
const availabilityKV = createAvailabilityKV("availability");
|
||||
// Helper function to convert date from yyyy-mm-dd to dd.mm.yyyy
|
||||
function formatDateGerman(dateString) {
|
||||
const [year, month, day] = dateString.split('-');
|
||||
return `${day}.${month}.${year}`;
|
||||
}
|
||||
// Helper to invalidate all reschedule tokens for a specific booking
|
||||
async function invalidateRescheduleTokensForBooking(bookingId) {
|
||||
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 = {
|
||||
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");
|
||||
}
|
||||
// Get treatment details
|
||||
const treatmentsKV = createKV("treatments");
|
||||
const treatment = await treatmentsKV.getItem(booking.treatmentId);
|
||||
// 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,
|
||||
treatmentId: booking.treatmentId,
|
||||
treatmentName: treatment?.name || "Unbekannte Behandlung",
|
||||
treatmentDuration: treatment?.duration || 60,
|
||||
treatmentPrice: treatment?.price || 0,
|
||||
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" };
|
||||
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 = {
|
||||
...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 = {
|
||||
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");
|
||||
}
|
||||
const treatmentsKV = createKV("treatments");
|
||||
const treatment = await treatmentsKV.getItem(booking.treatmentId);
|
||||
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,
|
||||
treatmentId: booking.treatmentId,
|
||||
treatmentName: treatment?.name || "Unbekannte Behandlung",
|
||||
},
|
||||
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 = [];
|
||||
for (const proposal of expiredProposals) {
|
||||
const booking = await bookingsKV.getItem(proposal.bookingId);
|
||||
if (booking) {
|
||||
const treatmentsKV = createKV("treatments");
|
||||
const treatment = await treatmentsKV.getItem(booking.treatmentId);
|
||||
expiredDetails.push({
|
||||
customerName: booking.customerName,
|
||||
originalDate: proposal.originalDate || booking.appointmentDate,
|
||||
originalTime: proposal.originalTime || booking.appointmentTime,
|
||||
proposedDate: proposal.proposedDate,
|
||||
proposedTime: proposal.proposedTime,
|
||||
treatmentName: treatment?.name || "Unbekannte Behandlung",
|
||||
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 };
|
||||
}),
|
||||
};
|
Reference in New Issue
Block a user