Fix reschedule token handling and improve admin notifications

- Fix getBookingByToken to only accept booking_access tokens
- Add sweepExpiredRescheduleProposals with admin notifications
- Return isExpired flag instead of throwing errors for expired proposals
- Fix email template to use actual token expiry time
- Remove duplicate admin emails in acceptReschedule
- Add one-click accept/decline support via URL parameters
This commit is contained in:
2025-10-05 16:11:37 +02:00
parent 97c1d3493f
commit a8cec16d7a
6 changed files with 1433 additions and 36 deletions

View File

@@ -11,7 +11,12 @@ const BookingAccessTokenSchema = z.object({
token: z.string(),
expiresAt: z.string(),
createdAt: z.string(),
purpose: z.enum(["booking_access"]), // For future extensibility
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>;
@@ -25,8 +30,8 @@ type Booking = {
id: string;
treatmentId: string;
customerName: string;
customerEmail: string;
customerPhone: string;
customerEmail?: string;
customerPhone?: string;
appointmentDate: string;
appointmentTime: string;
notes?: string;
@@ -55,6 +60,15 @@ function formatDateGerman(dateString: string): string {
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() }))
@@ -93,7 +107,8 @@ const getBookingByToken = os
const tokens = await cancellationKV.getAllItems();
const validToken = tokens.find(t =>
t.token === input.token &&
new Date(t.expiresAt) > new Date()
new Date(t.expiresAt) > new Date() &&
t.purpose === 'booking_access'
);
if (!validToken) {
@@ -217,4 +232,161 @@ 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");
}
const treatmentsKV = createKV<any>("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<any>("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 };
}),
};