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:
@@ -169,3 +169,145 @@ export async function renderAdminBookingNotificationHTML(params: {
|
||||
}
|
||||
|
||||
|
||||
export async function renderBookingRescheduleProposalHTML(params: {
|
||||
name: string;
|
||||
originalDate: string;
|
||||
originalTime: string;
|
||||
proposedDate: string;
|
||||
proposedTime: string;
|
||||
treatmentName: string;
|
||||
acceptUrl: string;
|
||||
declineUrl: string;
|
||||
expiresAt: string;
|
||||
}) {
|
||||
const formattedOriginalDate = formatDateGerman(params.originalDate);
|
||||
const formattedProposedDate = formatDateGerman(params.proposedDate);
|
||||
const expiryDate = new Date(params.expiresAt);
|
||||
const formattedExpiry = `${expiryDate.toLocaleDateString('de-DE')} ${expiryDate.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}`;
|
||||
|
||||
const inner = `
|
||||
<p>Hallo ${params.name},</p>
|
||||
<p>wir müssen deinen Termin leider verschieben. Hier ist unser Vorschlag:</p>
|
||||
<div style="background-color: #f8fafc; border-left: 4px solid #f59e0b; padding: 16px; margin: 20px 0; border-radius: 4px;">
|
||||
<p style="margin: 0; font-weight: 600; color: #92400e;">📅 Übersicht</p>
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" style="width:100%; margin-top:8px; font-size:14px; color:#475569;">
|
||||
<tr>
|
||||
<td style="padding:6px 0; width:45%"><strong>Alter Termin</strong></td>
|
||||
<td style="padding:6px 0;">${formattedOriginalDate} um ${params.originalTime} Uhr</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:6px 0; width:45%"><strong>Neuer Vorschlag</strong></td>
|
||||
<td style="padding:6px 0; color:#b45309;"><strong>${formattedProposedDate} um ${params.proposedTime} Uhr</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:6px 0; width:45%"><strong>Behandlung</strong></td>
|
||||
<td style="padding:6px 0;">${params.treatmentName}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<div style="background-color: #fffbeb; border-left: 4px solid #f59e0b; padding: 12px; margin: 16px 0; border-radius: 4px; color:#92400e;">
|
||||
⏰ Bitte antworte bis ${formattedExpiry}.
|
||||
</div>
|
||||
<div style="text-align:center; margin: 20px 0;">
|
||||
<a href="${params.acceptUrl}" style="display:inline-block; background-color:#16a34a; color:#ffffff; padding:12px 18px; border-radius:8px; text-decoration:none; font-weight:700; margin-right:8px;">Neuen Termin akzeptieren</a>
|
||||
<a href="${params.declineUrl}" style="display:inline-block; background-color:#dc2626; color:#ffffff; padding:12px 18px; border-radius:8px; text-decoration:none; font-weight:700;">Termin ablehnen</a>
|
||||
</div>
|
||||
<div style="background-color: #f8fafc; border-left: 4px solid #10b981; padding: 12px; margin: 16px 0; border-radius: 4px; color:#065f46;">
|
||||
Wenn du den Vorschlag ablehnst, bleibt dein ursprünglicher Termin bestehen und wir kontaktieren dich für eine alternative Lösung.
|
||||
</div>
|
||||
<p>Falls du einen komplett neuen Termin buchen möchtest, kannst du deinen aktuellen Termin stornieren und einen neuen Termin auf unserer Website buchen.</p>
|
||||
<p>Liebe Grüße,<br/>Stargirlnails Kiel</p>
|
||||
`;
|
||||
return renderBrandedEmail("Terminänderung vorgeschlagen", inner);
|
||||
}
|
||||
|
||||
export async function renderAdminRescheduleDeclinedHTML(params: {
|
||||
customerName: string;
|
||||
originalDate: string;
|
||||
originalTime: string;
|
||||
proposedDate: string;
|
||||
proposedTime: string;
|
||||
treatmentName: string;
|
||||
customerEmail?: string;
|
||||
customerPhone?: string;
|
||||
}) {
|
||||
const inner = `
|
||||
<p>Hallo Admin,</p>
|
||||
<p>der Kunde <strong>${params.customerName}</strong> hat den Terminänderungsvorschlag abgelehnt.</p>
|
||||
<div style="background-color:#f8fafc; border-left:4px solid #ef4444; padding:16px; margin:16px 0; border-radius:4px;">
|
||||
<ul style="margin:0; padding:0; list-style:none; color:#475569; font-size:14px;">
|
||||
<li><strong>Kunde:</strong> ${params.customerName}</li>
|
||||
${params.customerEmail ? `<li><strong>E-Mail:</strong> ${params.customerEmail}</li>` : ''}
|
||||
${params.customerPhone ? `<li><strong>Telefon:</strong> ${params.customerPhone}</li>` : ''}
|
||||
<li><strong>Behandlung:</strong> ${params.treatmentName}</li>
|
||||
<li><strong>Ursprünglicher Termin:</strong> ${formatDateGerman(params.originalDate)} um ${params.originalTime} Uhr (bleibt bestehen)</li>
|
||||
<li><strong>Abgelehnter Vorschlag:</strong> ${formatDateGerman(params.proposedDate)} um ${params.proposedTime} Uhr</li>
|
||||
</ul>
|
||||
</div>
|
||||
<p>Bitte kontaktiere den Kunden, um eine alternative Lösung zu finden.</p>
|
||||
`;
|
||||
return renderBrandedEmail("Kunde hat Terminänderung abgelehnt", inner);
|
||||
}
|
||||
|
||||
export async function renderAdminRescheduleAcceptedHTML(params: {
|
||||
customerName: string;
|
||||
originalDate: string;
|
||||
originalTime: string;
|
||||
newDate: string;
|
||||
newTime: string;
|
||||
treatmentName: string;
|
||||
}) {
|
||||
const inner = `
|
||||
<p>Hallo Admin,</p>
|
||||
<p>der Kunde <strong>${params.customerName}</strong> hat den Terminänderungsvorschlag akzeptiert.</p>
|
||||
<div style="background-color:#ecfeff; border-left:4px solid #10b981; padding:16px; margin:16px 0; border-radius:4px;">
|
||||
<ul style="margin:0; padding:0; list-style:none; color:#475569; font-size:14px;">
|
||||
<li><strong>Kunde:</strong> ${params.customerName}</li>
|
||||
<li><strong>Behandlung:</strong> ${params.treatmentName}</li>
|
||||
<li><strong>Alter Termin:</strong> ${formatDateGerman(params.originalDate)} um ${params.originalTime} Uhr</li>
|
||||
<li><strong>Neuer Termin:</strong> ${formatDateGerman(params.newDate)} um ${params.newTime} Uhr ✅</li>
|
||||
</ul>
|
||||
</div>
|
||||
<p>Der Termin wurde automatisch aktualisiert.</p>
|
||||
`;
|
||||
return renderBrandedEmail("Kunde hat Terminänderung akzeptiert", inner);
|
||||
}
|
||||
|
||||
export async function renderAdminRescheduleExpiredHTML(params: {
|
||||
expiredProposals: Array<{
|
||||
customerName: string;
|
||||
originalDate: string;
|
||||
originalTime: string;
|
||||
proposedDate: string;
|
||||
proposedTime: string;
|
||||
treatmentName: string;
|
||||
customerEmail?: string;
|
||||
customerPhone?: string;
|
||||
expiredAt: string;
|
||||
}>;
|
||||
}) {
|
||||
const inner = `
|
||||
<p>Hallo Admin,</p>
|
||||
<p><strong>${params.expiredProposals.length} Terminänderungsvorschlag${params.expiredProposals.length > 1 ? 'e' : ''} ${params.expiredProposals.length > 1 ? 'sind' : 'ist'} abgelaufen</strong> und wurde${params.expiredProposals.length > 1 ? 'n' : ''} automatisch entfernt.</p>
|
||||
<div style="background-color:#fef2f2; border-left:4px solid #ef4444; padding:16px; margin:16px 0; border-radius:4px;">
|
||||
<p style="margin:0 0 12px 0; font-weight:600; color:#dc2626;">⚠️ Abgelaufene Vorschläge:</p>
|
||||
${params.expiredProposals.map(proposal => `
|
||||
<div style="background-color:#ffffff; border:1px solid #fecaca; border-radius:4px; padding:12px; margin:8px 0;">
|
||||
<ul style="margin:0; padding:0; list-style:none; color:#475569; font-size:13px;">
|
||||
<li><strong>Kunde:</strong> ${proposal.customerName}</li>
|
||||
${proposal.customerEmail ? `<li><strong>E-Mail:</strong> ${proposal.customerEmail}</li>` : ''}
|
||||
${proposal.customerPhone ? `<li><strong>Telefon:</strong> ${proposal.customerPhone}</li>` : ''}
|
||||
<li><strong>Behandlung:</strong> ${proposal.treatmentName}</li>
|
||||
<li><strong>Ursprünglicher Termin:</strong> ${formatDateGerman(proposal.originalDate)} um ${proposal.originalTime} Uhr</li>
|
||||
<li><strong>Vorgeschlagener Termin:</strong> ${formatDateGerman(proposal.proposedDate)} um ${proposal.proposedTime} Uhr</li>
|
||||
<li><strong>Abgelaufen am:</strong> ${new Date(proposal.expiredAt).toLocaleString('de-DE')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
<p style="color:#dc2626; font-weight:600;">Bitte kontaktiere die Kunden, um eine alternative Lösung zu finden.</p>
|
||||
<p>Die ursprünglichen Termine bleiben bestehen.</p>
|
||||
`;
|
||||
return renderBrandedEmail("Abgelaufene Terminänderungsvorschläge", inner);
|
||||
}
|
||||
|
||||
|
@@ -3,7 +3,7 @@ import { z } from "zod";
|
||||
import { randomUUID } from "crypto";
|
||||
import { createKV } from "../lib/create-kv.js";
|
||||
import { sendEmail, sendEmailWithAGB, sendEmailWithAGBAndCalendar, sendEmailWithInspirationPhoto } from "../lib/email.js";
|
||||
import { renderBookingPendingHTML, renderBookingConfirmedHTML, renderBookingCancelledHTML, renderAdminBookingNotificationHTML } from "../lib/email-templates.js";
|
||||
import { renderBookingPendingHTML, renderBookingConfirmedHTML, renderBookingCancelledHTML, renderAdminBookingNotificationHTML, renderBookingRescheduleProposalHTML, renderAdminRescheduleAcceptedHTML, renderAdminRescheduleDeclinedHTML } from "../lib/email-templates.js";
|
||||
import { router as rootRouter } from "./index.js";
|
||||
import { createORPCClient } from "@orpc/client";
|
||||
import { RPCLink } from "@orpc/client/fetch";
|
||||
@@ -128,12 +128,23 @@ async function checkBookingConflicts(
|
||||
}
|
||||
}
|
||||
|
||||
const CreateBookingInputSchema = z.object({
|
||||
treatmentId: z.string(),
|
||||
customerName: z.string().min(2, "Name muss mindestens 2 Zeichen lang sein"),
|
||||
customerEmail: z.string().email("Ungültige E-Mail-Adresse"),
|
||||
customerPhone: z.string().min(5, "Telefonnummer muss mindestens 5 Zeichen lang sein").optional(),
|
||||
appointmentDate: z.string(), // ISO date string
|
||||
appointmentTime: z.string(), // HH:MM format
|
||||
notes: z.string().optional(),
|
||||
inspirationPhoto: z.string().optional(), // Base64 encoded image data
|
||||
});
|
||||
|
||||
const BookingSchema = z.object({
|
||||
id: z.string(),
|
||||
treatmentId: z.string(),
|
||||
customerName: z.string().min(2, "Name muss mindestens 2 Zeichen lang sein"),
|
||||
customerEmail: z.string().email("Ungültige E-Mail-Adresse"),
|
||||
customerPhone: z.string().min(5, "Telefonnummer muss mindestens 5 Zeichen lang sein"),
|
||||
customerEmail: z.string().email("Ungültige E-Mail-Adresse").optional(),
|
||||
customerPhone: z.string().min(5, "Telefonnummer muss mindestens 5 Zeichen lang sein").optional(),
|
||||
appointmentDate: z.string(), // ISO date string
|
||||
appointmentTime: z.string(), // HH:MM format
|
||||
status: z.enum(["pending", "confirmed", "cancelled", "completed"]),
|
||||
@@ -195,7 +206,7 @@ type Treatment = {
|
||||
const treatmentsKV = createKV<Treatment>("treatments");
|
||||
|
||||
const create = os
|
||||
.input(BookingSchema.omit({ id: true, createdAt: true, status: true }))
|
||||
.input(CreateBookingInputSchema)
|
||||
.handler(async ({ input }) => {
|
||||
// console.log("Booking create called with input:", {
|
||||
// ...input,
|
||||
@@ -253,7 +264,7 @@ const create = os
|
||||
if (!process.env.DISABLE_DUPLICATE_CHECK) {
|
||||
const existing = await kv.getAllItems();
|
||||
const hasConflict = existing.some(b =>
|
||||
b.customerEmail.toLowerCase() === input.customerEmail.toLowerCase() &&
|
||||
(b.customerEmail && b.customerEmail.toLowerCase() === input.customerEmail.toLowerCase()) &&
|
||||
b.appointmentDate === input.appointmentDate &&
|
||||
(b.status === "pending" || b.status === "confirmed")
|
||||
);
|
||||
@@ -329,7 +340,7 @@ const create = os
|
||||
date: input.appointmentDate,
|
||||
time: input.appointmentTime,
|
||||
treatment: treatmentName,
|
||||
phone: input.customerPhone,
|
||||
phone: input.customerPhone || "Nicht angegeben",
|
||||
notes: input.notes,
|
||||
hasInspirationPhoto: !!input.inspirationPhoto
|
||||
});
|
||||
@@ -338,7 +349,7 @@ const create = os
|
||||
|
||||
const adminText = `Neue Buchungsanfrage eingegangen:\n\n` +
|
||||
`Name: ${input.customerName}\n` +
|
||||
`Telefon: ${input.customerPhone}\n` +
|
||||
`Telefon: ${input.customerPhone || "Nicht angegeben"}\n` +
|
||||
`Behandlung: ${treatmentName}\n` +
|
||||
`Datum: ${formatDateGerman(input.appointmentDate)}\n` +
|
||||
`Uhrzeit: ${input.appointmentTime}\n` +
|
||||
@@ -425,6 +436,7 @@ const updateStatus = os
|
||||
// Use bookedDurationMinutes if available, otherwise fallback to treatment duration
|
||||
const treatmentDuration = booking.bookedDurationMinutes || treatment?.duration || 60;
|
||||
|
||||
if (booking.customerEmail) {
|
||||
await sendEmailWithAGBAndCalendar({
|
||||
to: booking.customerEmail,
|
||||
subject: "Dein Termin wurde bestätigt - AGB im Anhang",
|
||||
@@ -438,7 +450,57 @@ const updateStatus = os
|
||||
customerName: booking.customerName,
|
||||
treatmentName: treatmentName
|
||||
});
|
||||
}
|
||||
} else if (input.status === "cancelled") {
|
||||
const formattedDate = formatDateGerman(booking.appointmentDate);
|
||||
const homepageUrl = generateUrl();
|
||||
const html = await renderBookingCancelledHTML({ name: booking.customerName, date: booking.appointmentDate, time: booking.appointmentTime });
|
||||
if (booking.customerEmail) {
|
||||
await sendEmail({
|
||||
to: booking.customerEmail,
|
||||
subject: "Dein Termin wurde abgesagt",
|
||||
text: `Hallo ${booking.customerName},\n\nleider wurde dein Termin am ${formattedDate} um ${booking.appointmentTime} abgesagt. Bitte buche einen neuen Termin.\n\nRechtliche Informationen: ${generateUrl('/legal')}\nZur Website: ${homepageUrl}\n\nLiebe Grüße\nStargirlnails Kiel`,
|
||||
html,
|
||||
bcc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Email send failed:", e);
|
||||
}
|
||||
return updatedBooking;
|
||||
});
|
||||
|
||||
const remove = os
|
||||
.input(z.object({
|
||||
sessionId: z.string(),
|
||||
id: z.string(),
|
||||
sendEmail: z.boolean().optional().default(false)
|
||||
}))
|
||||
.handler(async ({ input }) => {
|
||||
await assertOwner(input.sessionId);
|
||||
const booking = await kv.getItem(input.id);
|
||||
if (!booking) throw new Error("Booking not found");
|
||||
|
||||
// Guard against deletion of past bookings or completed bookings
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
const isPastDate = booking.appointmentDate < today;
|
||||
const isCompleted = booking.status === 'completed';
|
||||
|
||||
if (isPastDate || isCompleted) {
|
||||
// For past/completed bookings, disable email sending to avoid confusing customers
|
||||
if (input.sendEmail) {
|
||||
console.log(`Email sending disabled for past/completed booking ${input.id}`);
|
||||
}
|
||||
input.sendEmail = false;
|
||||
}
|
||||
|
||||
const wasAlreadyCancelled = booking.status === 'cancelled';
|
||||
const updatedBooking = { ...booking, status: "cancelled" as const };
|
||||
await kv.setItem(input.id, updatedBooking);
|
||||
|
||||
if (input.sendEmail && !wasAlreadyCancelled && booking.customerEmail) {
|
||||
try {
|
||||
const formattedDate = formatDateGerman(booking.appointmentDate);
|
||||
const homepageUrl = generateUrl();
|
||||
const html = await renderBookingCancelledHTML({ name: booking.customerName, date: booking.appointmentDate, time: booking.appointmentTime });
|
||||
@@ -449,16 +511,132 @@ const updateStatus = os
|
||||
html,
|
||||
bcc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Email send failed:", e);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Email send failed:", e);
|
||||
}
|
||||
|
||||
return updatedBooking;
|
||||
});
|
||||
|
||||
const remove = os.input(z.string()).handler(async ({ input }) => {
|
||||
await kv.removeItem(input);
|
||||
});
|
||||
// Admin-only manual booking creation (immediately confirmed)
|
||||
const createManual = os
|
||||
.input(z.object({
|
||||
sessionId: z.string(),
|
||||
treatmentId: z.string(),
|
||||
customerName: z.string().min(2, "Name muss mindestens 2 Zeichen lang sein"),
|
||||
customerEmail: z.string().email("Ungültige E-Mail-Adresse").optional(),
|
||||
customerPhone: z.string().min(5, "Telefonnummer muss mindestens 5 Zeichen lang sein").optional(),
|
||||
appointmentDate: z.string(),
|
||||
appointmentTime: z.string(),
|
||||
notes: z.string().optional(),
|
||||
}))
|
||||
.handler(async ({ input }) => {
|
||||
// Admin authentication
|
||||
await assertOwner(input.sessionId);
|
||||
|
||||
// Validate appointment time is on 15-minute grid
|
||||
const appointmentMinutes = parseTime(input.appointmentTime);
|
||||
if (appointmentMinutes % 15 !== 0) {
|
||||
throw new Error("Termine müssen auf 15-Minuten-Raster ausgerichtet sein (z.B. 09:00, 09:15, 09:30, 09:45).");
|
||||
}
|
||||
|
||||
// Validate that the booking is not in the past
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
if (input.appointmentDate < today) {
|
||||
throw new Error("Buchungen für vergangene Termine sind nicht möglich.");
|
||||
}
|
||||
|
||||
// For today's bookings, check if the time is not in the past
|
||||
if (input.appointmentDate === today) {
|
||||
const now = new Date();
|
||||
const currentTime = `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`;
|
||||
if (input.appointmentTime <= currentTime) {
|
||||
throw new Error("Buchungen für vergangene Uhrzeiten sind nicht möglich.");
|
||||
}
|
||||
}
|
||||
|
||||
// Get treatment duration for validation
|
||||
const treatment = await treatmentsKV.getItem(input.treatmentId);
|
||||
if (!treatment) {
|
||||
throw new Error("Behandlung nicht gefunden.");
|
||||
}
|
||||
|
||||
// Validate booking time against recurring rules
|
||||
await validateBookingAgainstRules(
|
||||
input.appointmentDate,
|
||||
input.appointmentTime,
|
||||
treatment.duration
|
||||
);
|
||||
|
||||
// Check for booking conflicts
|
||||
await checkBookingConflicts(
|
||||
input.appointmentDate,
|
||||
input.appointmentTime,
|
||||
treatment.duration
|
||||
);
|
||||
|
||||
const id = randomUUID();
|
||||
const booking = {
|
||||
id,
|
||||
treatmentId: input.treatmentId,
|
||||
customerName: input.customerName,
|
||||
customerEmail: input.customerEmail,
|
||||
customerPhone: input.customerPhone,
|
||||
appointmentDate: input.appointmentDate,
|
||||
appointmentTime: input.appointmentTime,
|
||||
notes: input.notes,
|
||||
bookedDurationMinutes: treatment.duration,
|
||||
status: "confirmed" as const,
|
||||
createdAt: new Date().toISOString()
|
||||
} as Booking;
|
||||
|
||||
// Save the booking
|
||||
await kv.setItem(id, booking);
|
||||
|
||||
// Create booking access token for status viewing and cancellation (always create token)
|
||||
const bookingAccessToken = await queryClient.cancellation.createToken({ bookingId: id });
|
||||
|
||||
// Send confirmation email if email is provided
|
||||
if (input.customerEmail) {
|
||||
void (async () => {
|
||||
try {
|
||||
const bookingUrl = generateUrl(`/booking/${bookingAccessToken.token}`);
|
||||
const formattedDate = formatDateGerman(input.appointmentDate);
|
||||
const homepageUrl = generateUrl();
|
||||
|
||||
const html = await renderBookingConfirmedHTML({
|
||||
name: input.customerName,
|
||||
date: input.appointmentDate,
|
||||
time: input.appointmentTime,
|
||||
cancellationUrl: bookingUrl
|
||||
});
|
||||
|
||||
await sendEmailWithAGBAndCalendar({
|
||||
to: input.customerEmail,
|
||||
subject: "Dein Termin wurde bestätigt - AGB im Anhang",
|
||||
text: `Hallo ${input.customerName},\n\nwir haben deinen Termin am ${formattedDate} um ${input.appointmentTime} bestätigt.\n\nWichtiger Hinweis: Die Allgemeinen Geschäftsbedingungen (AGB) findest du im Anhang dieser E-Mail. Bitte lies sie vor deinem Termin durch.\n\nTermin-Status ansehen und verwalten: ${bookingUrl}\nFalls du den Termin stornieren möchtest, kannst du das über den obigen Link tun.\n\nRechtliche Informationen: ${generateUrl('/legal')}\nZur Website: ${homepageUrl}\n\nBis bald!\nStargirlnails Kiel`,
|
||||
html,
|
||||
bcc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined,
|
||||
}, {
|
||||
date: input.appointmentDate,
|
||||
time: input.appointmentTime,
|
||||
durationMinutes: treatment.duration,
|
||||
customerName: input.customerName,
|
||||
treatmentName: treatment.name
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Email send failed for manual booking:", e);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
// Optionally return the token in the RPC response for UI to copy/share (admin usage only)
|
||||
return {
|
||||
...booking,
|
||||
bookingAccessToken: bookingAccessToken.token
|
||||
};
|
||||
});
|
||||
|
||||
const list = os.handler(async () => {
|
||||
return kv.getAllItems();
|
||||
@@ -495,10 +673,189 @@ const live = {
|
||||
|
||||
export const router = {
|
||||
create,
|
||||
createManual,
|
||||
updateStatus,
|
||||
remove,
|
||||
list,
|
||||
get,
|
||||
getByDate,
|
||||
live,
|
||||
// Admin proposes a reschedule for a confirmed booking
|
||||
proposeReschedule: os
|
||||
.input(z.object({
|
||||
sessionId: z.string(),
|
||||
bookingId: z.string(),
|
||||
proposedDate: z.string(),
|
||||
proposedTime: z.string(),
|
||||
}))
|
||||
.handler(async ({ input }) => {
|
||||
await assertOwner(input.sessionId);
|
||||
const booking = await kv.getItem(input.bookingId);
|
||||
if (!booking) throw new Error("Booking not found");
|
||||
if (booking.status !== "confirmed") throw new Error("Nur bestätigte Termine können umgebucht werden.");
|
||||
|
||||
const treatment = await treatmentsKV.getItem(booking.treatmentId);
|
||||
if (!treatment) throw new Error("Behandlung nicht gefunden.");
|
||||
|
||||
// Validate grid and not in past
|
||||
const appointmentMinutes = parseTime(input.proposedTime);
|
||||
if (appointmentMinutes % 15 !== 0) {
|
||||
throw new Error("Termine müssen auf 15-Minuten-Raster ausgerichtet sein (z.B. 09:00, 09:15, 09:30, 09:45).");
|
||||
}
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
if (input.proposedDate < today) {
|
||||
throw new Error("Buchungen für vergangene Termine sind nicht möglich.");
|
||||
}
|
||||
if (input.proposedDate === today) {
|
||||
const now = new Date();
|
||||
const currentTime = `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`;
|
||||
if (input.proposedTime <= currentTime) {
|
||||
throw new Error("Buchungen für vergangene Uhrzeiten sind nicht möglich.");
|
||||
}
|
||||
}
|
||||
|
||||
await validateBookingAgainstRules(input.proposedDate, input.proposedTime, booking.bookedDurationMinutes || treatment.duration);
|
||||
await checkBookingConflicts(input.proposedDate, input.proposedTime, booking.bookedDurationMinutes || treatment.duration, booking.id);
|
||||
|
||||
// Invalidate and create new reschedule token via cancellation router
|
||||
const res = await queryClient.cancellation.createRescheduleToken({
|
||||
bookingId: booking.id,
|
||||
proposedDate: input.proposedDate,
|
||||
proposedTime: input.proposedTime,
|
||||
});
|
||||
const acceptUrl = generateUrl(`/booking/${res.token}?action=accept`);
|
||||
const declineUrl = generateUrl(`/booking/${res.token}?action=decline`);
|
||||
|
||||
// Send proposal email to customer
|
||||
if (booking.customerEmail) {
|
||||
const html = await renderBookingRescheduleProposalHTML({
|
||||
name: booking.customerName,
|
||||
originalDate: booking.appointmentDate,
|
||||
originalTime: booking.appointmentTime,
|
||||
proposedDate: input.proposedDate,
|
||||
proposedTime: input.proposedTime,
|
||||
treatmentName: (await treatmentsKV.getItem(booking.treatmentId))?.name || "Behandlung",
|
||||
acceptUrl,
|
||||
declineUrl,
|
||||
expiresAt: res.expiresAt,
|
||||
});
|
||||
await sendEmail({
|
||||
to: booking.customerEmail,
|
||||
subject: "Vorschlag zur Terminänderung",
|
||||
text: `Hallo ${booking.customerName}, wir schlagen vor, deinen Termin von ${formatDateGerman(booking.appointmentDate)} ${booking.appointmentTime} auf ${formatDateGerman(input.proposedDate)} ${input.proposedTime} zu verschieben. Akzeptieren: ${acceptUrl} | Ablehnen: ${declineUrl}`,
|
||||
html,
|
||||
bcc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined,
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
return { success: true, token: res.token };
|
||||
}),
|
||||
|
||||
// Customer accepts reschedule via token
|
||||
acceptReschedule: os
|
||||
.input(z.object({ token: z.string() }))
|
||||
.handler(async ({ input }) => {
|
||||
const proposal = await queryClient.cancellation.getRescheduleProposal({ token: input.token });
|
||||
const booking = await kv.getItem(proposal.booking.id);
|
||||
if (!booking) throw new Error("Booking not found");
|
||||
if (booking.status !== "confirmed") throw new Error("Buchung ist nicht mehr in bestätigtem Zustand.");
|
||||
|
||||
const treatment = await treatmentsKV.getItem(booking.treatmentId);
|
||||
const duration = booking.bookedDurationMinutes || treatment?.duration || 60;
|
||||
|
||||
// Re-validate slot to ensure still available
|
||||
await validateBookingAgainstRules(proposal.proposed.date, proposal.proposed.time, duration);
|
||||
await checkBookingConflicts(proposal.proposed.date, proposal.proposed.time, duration, booking.id);
|
||||
|
||||
const updated = { ...booking, appointmentDate: proposal.proposed.date, appointmentTime: proposal.proposed.time } as typeof booking;
|
||||
await kv.setItem(updated.id, updated);
|
||||
|
||||
// Remove token
|
||||
await queryClient.cancellation.removeRescheduleToken({ token: input.token });
|
||||
|
||||
// Send confirmation to customer (no BCC to avoid duplicate admin emails)
|
||||
if (updated.customerEmail) {
|
||||
const html = await renderBookingConfirmedHTML({
|
||||
name: updated.customerName,
|
||||
date: updated.appointmentDate,
|
||||
time: updated.appointmentTime,
|
||||
cancellationUrl: generateUrl(`/booking/${(await queryClient.cancellation.createToken({ bookingId: updated.id })).token}`),
|
||||
});
|
||||
await sendEmailWithAGBAndCalendar({
|
||||
to: updated.customerEmail,
|
||||
subject: "Terminänderung bestätigt",
|
||||
text: `Hallo ${updated.customerName}, dein neuer Termin ist am ${formatDateGerman(updated.appointmentDate)} um ${updated.appointmentTime}.`,
|
||||
html,
|
||||
}, {
|
||||
date: updated.appointmentDate,
|
||||
time: updated.appointmentTime,
|
||||
durationMinutes: duration,
|
||||
customerName: updated.customerName,
|
||||
treatmentName: (await treatmentsKV.getItem(updated.treatmentId))?.name || "Behandlung",
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
if (process.env.ADMIN_EMAIL) {
|
||||
const adminHtml = await renderAdminRescheduleAcceptedHTML({
|
||||
customerName: updated.customerName,
|
||||
originalDate: proposal.original.date,
|
||||
originalTime: proposal.original.time,
|
||||
newDate: updated.appointmentDate,
|
||||
newTime: updated.appointmentTime,
|
||||
treatmentName: (await treatmentsKV.getItem(updated.treatmentId))?.name || "Behandlung",
|
||||
});
|
||||
await sendEmail({
|
||||
to: process.env.ADMIN_EMAIL,
|
||||
subject: `Reschedule akzeptiert - ${updated.customerName}`,
|
||||
text: `Reschedule akzeptiert: ${updated.customerName} von ${formatDateGerman(proposal.original.date)} ${proposal.original.time} auf ${formatDateGerman(updated.appointmentDate)} ${updated.appointmentTime}.`,
|
||||
html: adminHtml,
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
return { success: true, message: `Termin auf ${formatDateGerman(updated.appointmentDate)} um ${updated.appointmentTime} aktualisiert.` };
|
||||
}),
|
||||
|
||||
// Customer declines reschedule via token
|
||||
declineReschedule: os
|
||||
.input(z.object({ token: z.string() }))
|
||||
.handler(async ({ input }) => {
|
||||
const proposal = await queryClient.cancellation.getRescheduleProposal({ token: input.token });
|
||||
const booking = await kv.getItem(proposal.booking.id);
|
||||
if (!booking) throw new Error("Booking not found");
|
||||
|
||||
// Remove token
|
||||
await queryClient.cancellation.removeRescheduleToken({ token: input.token });
|
||||
|
||||
// Notify customer that original stays
|
||||
if (booking.customerEmail) {
|
||||
await sendEmail({
|
||||
to: booking.customerEmail,
|
||||
subject: "Terminänderung abgelehnt",
|
||||
text: `Du hast den Vorschlag zur Terminänderung abgelehnt. Dein ursprünglicher Termin am ${formatDateGerman(booking.appointmentDate)} um ${booking.appointmentTime} bleibt bestehen.`,
|
||||
html: await renderBookingConfirmedHTML({ name: booking.customerName, date: booking.appointmentDate, time: booking.appointmentTime, cancellationUrl: generateUrl(`/booking/${(await queryClient.cancellation.createToken({ bookingId: booking.id })).token}`) }),
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
// Notify admin
|
||||
if (process.env.ADMIN_EMAIL) {
|
||||
const html = await renderAdminRescheduleDeclinedHTML({
|
||||
customerName: booking.customerName,
|
||||
originalDate: proposal.original.date,
|
||||
originalTime: proposal.original.time,
|
||||
proposedDate: proposal.proposed.date!,
|
||||
proposedTime: proposal.proposed.time!,
|
||||
treatmentName: (await treatmentsKV.getItem(booking.treatmentId))?.name || "Behandlung",
|
||||
customerEmail: booking.customerEmail,
|
||||
customerPhone: booking.customerPhone,
|
||||
});
|
||||
await sendEmail({
|
||||
to: process.env.ADMIN_EMAIL,
|
||||
subject: `Reschedule abgelehnt - ${booking.customerName}`,
|
||||
text: `Abgelehnt: ${booking.customerName}. Ursprünglich: ${formatDateGerman(proposal.original.date)} ${proposal.original.time}. Vorschlag: ${formatDateGerman(proposal.proposed.date!)} ${proposal.proposed.time!}.`,
|
||||
html,
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
return { success: true, message: "Du hast den Vorschlag abgelehnt. Dein ursprünglicher Termin bleibt bestehen." };
|
||||
}),
|
||||
};
|
@@ -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 };
|
||||
}),
|
||||
};
|
||||
|
Reference in New Issue
Block a user