Fix ICS calendar issues and improve email template code quality

- Fix ATTENDEE mailto to use customerEmail instead of customerName
- Add icsEscape helper for proper RFC 5545 text escaping
- Calculate ICS duration from treatments array instead of separate param
- Add renderTreatmentList helper to reduce code duplication in email templates
This commit is contained in:
2025-10-08 18:45:34 +02:00
parent 9583148e02
commit ccba9d443b
3 changed files with 160 additions and 51 deletions

View File

@@ -363,12 +363,18 @@ const create = os
name: input.customerName,
date: input.appointmentDate,
time: input.appointmentTime,
statusUrl: bookingUrl
statusUrl: bookingUrl,
treatments: input.treatments
});
const treatmentsText = input.treatments.map(t => `- ${t.name} (${t.duration} Min, ${t.price.toFixed(2)} €)`).join('\n');
const totalDuration = input.treatments.reduce((sum, t) => sum + t.duration, 0);
const totalPrice = input.treatments.reduce((sum, t) => sum + t.price, 0);
await sendEmail({
to: input.customerEmail,
subject: "Deine Terminanfrage ist eingegangen",
text: `Hallo ${input.customerName},\n\nwir haben deine Anfrage für ${formattedDate} um ${input.appointmentTime} erhalten. Wir bestätigen deinen Termin in Kürze. Du erhältst eine weitere E-Mail, sobald der Termin bestätigt ist.\n\nTermin-Status ansehen: ${bookingUrl}\n\nRechtliche Informationen: ${generateUrl('/legal')}\nZur Website: ${homepageUrl}\n\nLiebe Grüße\nStargirlnails Kiel`,
text: `Hallo ${input.customerName},\n\nwir haben deine Anfrage für ${formattedDate} um ${input.appointmentTime} erhalten.\n\nBehandlungen:\n${treatmentsText}\n\nGesamt: ${totalDuration} Min, ${totalPrice.toFixed(2)}\n\nWir bestätigen deinen Termin in Kürze. Du erhältst eine weitere E-Mail, sobald der Termin bestätigt ist.\n\nTermin-Status ansehen: ${bookingUrl}\n\nRechtliche Informationen: ${generateUrl('/legal')}\nZur Website: ${homepageUrl}\n\nLiebe Grüße\nStargirlnails Kiel`,
html,
}).catch(() => {});
})();
@@ -377,25 +383,26 @@ const create = os
void (async () => {
if (!process.env.ADMIN_EMAIL) return;
// Build treatment list string
const treatmentsList = input.treatments.map(t => `${t.name} (${t.duration} Min, ${t.price.toFixed(2)} €)`).join(', ');
const adminHtml = await renderAdminBookingNotificationHTML({
name: input.customerName,
date: input.appointmentDate,
time: input.appointmentTime,
treatment: treatmentsList,
treatments: input.treatments,
phone: input.customerPhone || "Nicht angegeben",
notes: input.notes,
hasInspirationPhoto: !!input.inspirationPhoto
});
const homepageUrl = generateUrl();
const treatmentsText = input.treatments.map(t => ` - ${t.name} (${t.duration} Min, ${t.price.toFixed(2)} €)`).join('\n');
const totalDuration = input.treatments.reduce((sum, t) => sum + t.duration, 0);
const totalPrice = input.treatments.reduce((sum, t) => sum + t.price, 0);
const adminText = `Neue Buchungsanfrage eingegangen:\n\n` +
`Name: ${input.customerName}\n` +
`Telefon: ${input.customerPhone || "Nicht angegeben"}\n` +
`Behandlungen: ${treatmentsList}\n` +
`Behandlungen:\n${treatmentsText}\n` +
`Gesamt: ${totalDuration} Min, ${totalPrice.toFixed(2)}\n` +
`Datum: ${formatDateGerman(input.appointmentDate)}\n` +
`Uhrzeit: ${input.appointmentTime}\n` +
`${input.notes ? `Notizen: ${input.notes}\n` : ''}` +
@@ -472,41 +479,48 @@ const updateStatus = os
date: booking.appointmentDate,
time: booking.appointmentTime,
cancellationUrl: bookingUrl, // Now points to booking status page
reviewUrl: generateUrl(`/review/${bookingAccessToken.token}`)
reviewUrl: generateUrl(`/review/${bookingAccessToken.token}`),
treatments: booking.treatments
});
// Get treatment information for ICS file
const treatmentName = booking.treatments && booking.treatments.length > 0
? booking.treatments.map(t => t.name).join(', ')
: "Behandlung";
const treatmentDuration = booking.treatments && booking.treatments.length > 0
? booking.treatments.reduce((sum, t) => sum + t.duration, 0)
: (booking.bookedDurationMinutes || 60);
const treatmentsText = booking.treatments.map(t => `- ${t.name} (${t.duration} Min, ${t.price.toFixed(2)} €)`).join('\n');
const totalDuration = booking.treatments.reduce((sum, t) => sum + t.duration, 0);
const totalPrice = booking.treatments.reduce((sum, t) => sum + t.price, 0);
if (booking.customerEmail) {
await sendEmailWithAGBAndCalendar({
to: booking.customerEmail,
subject: "Dein Termin wurde bestätigt - AGB im Anhang",
text: `Hallo ${booking.customerName},\n\nwir haben deinen Termin am ${formattedDate} um ${booking.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`,
text: `Hallo ${booking.customerName},\n\nwir haben deinen Termin am ${formattedDate} um ${booking.appointmentTime} bestätigt.\n\nBehandlungen:\n${treatmentsText}\n\nGesamt: ${totalDuration} Min, ${totalPrice.toFixed(2)}\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: booking.appointmentDate,
time: booking.appointmentTime,
durationMinutes: treatmentDuration,
customerName: booking.customerName,
treatmentName: treatmentName
customerEmail: booking.customerEmail,
treatments: booking.treatments
});
}
} 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 });
const html = await renderBookingCancelledHTML({
name: booking.customerName,
date: booking.appointmentDate,
time: booking.appointmentTime,
treatments: booking.treatments
});
const treatmentsText = booking.treatments.map(t => `- ${t.name} (${t.duration} Min, ${t.price.toFixed(2)} €)`).join('\n');
const totalDuration = booking.treatments.reduce((sum, t) => sum + t.duration, 0);
const totalPrice = booking.treatments.reduce((sum, t) => sum + t.price, 0);
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`,
text: `Hallo ${booking.customerName},\n\nleider wurde dein Termin am ${formattedDate} um ${booking.appointmentTime} abgesagt.\n\nBehandlungen:\n${treatmentsText}\n\nGesamt: ${totalDuration} Min, ${totalPrice.toFixed(2)}\n\nBitte 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,
});
@@ -550,11 +564,21 @@ const remove = os
try {
const formattedDate = formatDateGerman(booking.appointmentDate);
const homepageUrl = generateUrl();
const html = await renderBookingCancelledHTML({ name: booking.customerName, date: booking.appointmentDate, time: booking.appointmentTime });
const html = await renderBookingCancelledHTML({
name: booking.customerName,
date: booking.appointmentDate,
time: booking.appointmentTime,
treatments: booking.treatments
});
const treatmentsText = booking.treatments.map(t => `- ${t.name} (${t.duration} Min, ${t.price.toFixed(2)} €)`).join('\n');
const totalDuration = booking.treatments.reduce((sum, t) => sum + t.duration, 0);
const totalPrice = booking.treatments.reduce((sum, t) => sum + t.price, 0);
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`,
text: `Hallo ${booking.customerName},\n\nleider wurde dein Termin am ${formattedDate} um ${booking.appointmentTime} abgesagt.\n\nBehandlungen:\n${treatmentsText}\n\nGesamt: ${totalDuration} Min, ${totalPrice.toFixed(2)}\n\nBitte 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,
});
@@ -665,20 +689,24 @@ const createManual = os
date: input.appointmentDate,
time: input.appointmentTime,
cancellationUrl: bookingUrl,
reviewUrl: generateUrl(`/review/${bookingAccessToken.token}`)
reviewUrl: generateUrl(`/review/${bookingAccessToken.token}`),
treatments: input.treatments
});
const treatmentsText = input.treatments.map(t => `- ${t.name} (${t.duration} Min, ${t.price.toFixed(2)} €)`).join('\n');
const totalPrice = input.treatments.reduce((sum, t) => sum + t.price, 0);
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`,
text: `Hallo ${input.customerName},\n\nwir haben deinen Termin am ${formattedDate} um ${input.appointmentTime} bestätigt.\n\nBehandlungen:\n${treatmentsText}\n\nGesamt: ${totalDuration} Min, ${totalPrice.toFixed(2)}\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,
}, {
date: input.appointmentDate,
time: input.appointmentTime,
durationMinutes: totalDuration,
customerName: input.customerName,
treatmentName: input.treatments.map(t => t.name).join(', ')
customerEmail: input.customerEmail,
treatments: input.treatments
});
} catch (e) {
console.error("Email send failed for manual booking:", e);
@@ -843,21 +871,23 @@ export const router = {
time: updated.appointmentTime,
cancellationUrl: generateUrl(`/booking/${bookingAccessToken.token}`),
reviewUrl: generateUrl(`/review/${bookingAccessToken.token}`),
treatments: updated.treatments,
});
const treatmentName = updated.treatments && updated.treatments.length > 0
? updated.treatments.map(t => t.name).join(', ')
: "Behandlung";
const treatmentsText = updated.treatments.map(t => `- ${t.name} (${t.duration} Min, ${t.price.toFixed(2)} €)`).join('\n');
const totalPrice = updated.treatments.reduce((sum, t) => sum + t.price, 0);
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}.`,
text: `Hallo ${updated.customerName}, dein neuer Termin ist am ${formatDateGerman(updated.appointmentDate)} um ${updated.appointmentTime}.\n\nBehandlungen:\n${treatmentsText}\n\nGesamt: ${duration} Min, ${totalPrice.toFixed(2)}`,
html,
}, {
date: updated.appointmentDate,
time: updated.appointmentTime,
durationMinutes: duration,
customerName: updated.customerName,
treatmentName: treatmentName,
customerEmail: updated.customerEmail,
treatments: updated.treatments,
}).catch(() => {});
}
@@ -898,11 +928,23 @@ export const router = {
// Notify customer that original stays
if (booking.customerEmail) {
const bookingAccessToken = await queryClient.cancellation.createToken({ bookingId: booking.id });
const treatmentsText = booking.treatments.map(t => `- ${t.name} (${t.duration} Min, ${t.price.toFixed(2)} €)`).join('\n');
const totalDuration = booking.treatments.reduce((sum, t) => sum + t.duration, 0);
const totalPrice = booking.treatments.reduce((sum, t) => sum + t.price, 0);
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/${bookingAccessToken.token}`), reviewUrl: generateUrl(`/review/${bookingAccessToken.token}`) }),
text: `Du hast den Vorschlag zur Terminänderung abgelehnt. Dein ursprünglicher Termin am ${formatDateGerman(booking.appointmentDate)} um ${booking.appointmentTime} bleibt bestehen.\n\nBehandlungen:\n${treatmentsText}\n\nGesamt: ${totalDuration} Min, ${totalPrice.toFixed(2)}`,
html: await renderBookingConfirmedHTML({
name: booking.customerName,
date: booking.appointmentDate,
time: booking.appointmentTime,
cancellationUrl: generateUrl(`/booking/${bookingAccessToken.token}`),
reviewUrl: generateUrl(`/review/${bookingAccessToken.token}`),
treatments: booking.treatments
}),
}).catch(() => {});
}