Feature: Admin kann Nachrichten an Kunden senden

- Neues Email-Template für Kundennachrichten
- RPC-Funktion sendCustomerMessage für zukünftige Termine
- UI: Nachricht-Button und Modal in Admin-Buchungen
- Email mit BCC an Admin für Monitoring
- HTML-Escaping für sichere Nachrichtenanzeige
- Detailliertes Logging für Debugging
This commit is contained in:
2025-10-08 11:13:59 +02:00
parent ca20516080
commit cceb4d4e60
4 changed files with 299 additions and 43 deletions

View File

@@ -347,3 +347,43 @@ export async function renderAdminRescheduleExpiredHTML(params: {
return renderBrandedEmail("Abgelaufene Terminänderungsvorschläge", inner);
}
export async function renderCustomerMessageHTML(params: {
customerName: string;
message: string;
appointmentDate?: string;
appointmentTime?: string;
treatmentName?: string;
}) {
const { customerName, message, appointmentDate, appointmentTime, treatmentName } = params;
const formattedDate = appointmentDate ? formatDateGerman(appointmentDate) : null;
const domain = process.env.DOMAIN || 'localhost:5173';
const protocol = domain.includes('localhost') ? 'http' : 'https';
const legalUrl = `${protocol}://${domain}/legal`;
const ownerName = process.env.OWNER_NAME || 'Stargirlnails Kiel';
const inner = `
<p>Hallo ${customerName},</p>
${(appointmentDate && appointmentTime && treatmentName) ? `
<div style="background-color: #f8fafc; border-left: 4px solid #db2777; padding: 16px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0; font-weight: 600; color: #db2777;">📅 Zu deinem Termin:</p>
<ul style="margin: 8px 0 0 0; color: #475569; list-style: none; padding: 0;">
<li><strong>Behandlung:</strong> ${treatmentName}</li>
<li><strong>Datum:</strong> ${formattedDate}</li>
<li><strong>Uhrzeit:</strong> ${appointmentTime}</li>
</ul>
</div>
` : ''}
<div style="background-color: #fef9f5; border-left: 4px solid #f59e0b; padding: 16px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0; font-weight: 600; color: #f59e0b;">💬 Nachricht von ${ownerName}:</p>
<div style="margin: 12px 0 0 0; color: #475569; white-space: pre-wrap; line-height: 1.6;">${message.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')}</div>
</div>
<p>Bei Fragen oder Anliegen kannst du einfach auf diese E-Mail antworten wir helfen dir gerne weiter!</p>
<div style="background-color: #f8fafc; border-left: 4px solid #3b82f6; padding: 16px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0; font-weight: 600; color: #3b82f6;">📋 Rechtliche Informationen:</p>
<p style="margin: 8px 0 12px 0; color: #475569;">Weitere Informationen findest du in unserem <a href="${legalUrl}" style="color: #3b82f6; text-decoration: underline;">Impressum und Datenschutz</a>.</p>
</div>
<p>Liebe Grüße,<br/>${ownerName}</p>
`;
return renderBrandedEmail("Nachricht zu deinem Termin", inner);
}

View File

@@ -6,6 +6,7 @@ type SendEmailParams = {
from?: string;
cc?: string | string[];
bcc?: string | string[];
replyTo?: string | string[];
attachments?: Array<{
filename: string;
content: string; // base64 encoded
@@ -130,22 +131,27 @@ export async function sendEmail(params: SendEmailParams): Promise<{ success: boo
return { success: false };
}
const payload = {
from: params.from || DEFAULT_FROM,
to: Array.isArray(params.to) ? params.to : [params.to],
subject: params.subject,
text: params.text,
html: params.html,
cc: params.cc ? (Array.isArray(params.cc) ? params.cc : [params.cc]) : undefined,
bcc: params.bcc ? (Array.isArray(params.bcc) ? params.bcc : [params.bcc]) : undefined,
reply_to: params.replyTo ? (Array.isArray(params.replyTo) ? params.replyTo : [params.replyTo]) : undefined,
attachments: params.attachments,
};
console.log(`Sending email via Resend: to=${JSON.stringify(payload.to)}, subject="${params.subject}"`);
const response = await fetch("https://api.resend.com/emails", {
method: "POST",
headers: {
"Authorization": `Bearer ${RESEND_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
from: params.from || DEFAULT_FROM,
to: Array.isArray(params.to) ? params.to : [params.to],
subject: params.subject,
text: params.text,
html: params.html,
cc: params.cc ? (Array.isArray(params.cc) ? params.cc : [params.cc]) : undefined,
bcc: params.bcc ? (Array.isArray(params.bcc) ? params.bcc : [params.bcc]) : undefined,
attachments: params.attachments,
}),
body: JSON.stringify(payload),
});
if (!response.ok) {
@@ -153,6 +159,9 @@ export async function sendEmail(params: SendEmailParams): Promise<{ success: boo
console.error("Resend send error:", response.status, body);
return { success: false };
}
const responseData = await response.json().catch(() => ({}));
console.log("Resend email sent successfully:", responseData);
return { success: true };
}

View File

@@ -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, renderBookingRescheduleProposalHTML, renderAdminRescheduleAcceptedHTML, renderAdminRescheduleDeclinedHTML } from "../lib/email-templates.js";
import { renderBookingPendingHTML, renderBookingConfirmedHTML, renderBookingCancelledHTML, renderAdminBookingNotificationHTML, renderBookingRescheduleProposalHTML, renderAdminRescheduleAcceptedHTML, renderAdminRescheduleDeclinedHTML, renderCustomerMessageHTML } from "../lib/email-templates.js";
import { router as rootRouter } from "./index.js";
import { createORPCClient } from "@orpc/client";
import { RPCLink } from "@orpc/client/fetch";
@@ -911,4 +911,74 @@ export const router = {
}
};
}),
// Admin sendet Nachricht an Kunden
sendCustomerMessage: os
.input(z.object({
sessionId: z.string(),
bookingId: z.string(),
message: z.string().min(1, "Nachricht darf nicht leer sein").max(5000, "Nachricht ist zu lang (max. 5000 Zeichen)"),
}))
.handler(async ({ input }) => {
await assertOwner(input.sessionId);
const booking = await kv.getItem(input.bookingId);
if (!booking) throw new Error("Buchung nicht gefunden");
// Check if booking has customer email
if (!booking.customerEmail) {
throw new Error("Diese Buchung hat keine E-Mail-Adresse. Bitte kontaktiere den Kunden telefonisch.");
}
// Check if booking is in the future
const today = new Date().toISOString().split("T")[0];
const bookingDate = booking.appointmentDate;
if (bookingDate < today) {
throw new Error("Nachrichten können nur für zukünftige Termine gesendet werden.");
}
// Get treatment name for context
const treatment = await treatmentsKV.getItem(booking.treatmentId);
const treatmentName = treatment?.name || "Behandlung";
// Prepare email with Reply-To header
const ownerName = process.env.OWNER_NAME || "Stargirlnails Kiel";
const emailFrom = process.env.EMAIL_FROM || "Stargirlnails <no-reply@stargirlnails.de>";
const replyToEmail = process.env.ADMIN_EMAIL;
const formattedDate = formatDateGerman(bookingDate);
const html = await renderCustomerMessageHTML({
customerName: booking.customerName,
message: input.message,
appointmentDate: bookingDate,
appointmentTime: booking.appointmentTime,
treatmentName: treatmentName,
});
const textContent = `Hallo ${booking.customerName},\n\nZu deinem Termin:\nBehandlung: ${treatmentName}\nDatum: ${formattedDate}\nUhrzeit: ${booking.appointmentTime}\n\nNachricht von ${ownerName}:\n${input.message}\n\nBei Fragen oder Anliegen kannst du einfach auf diese E-Mail antworten wir helfen dir gerne weiter!\n\nLiebe Grüße,\n${ownerName}`;
// Send email with BCC to admin for monitoring
// Note: Not using explicit 'from' or 'replyTo' to match behavior of other system emails
console.log(`Sending customer message to ${booking.customerEmail} for booking ${input.bookingId}`);
console.log(`Email config: from=${emailFrom}, replyTo=${replyToEmail}, bcc=${replyToEmail}`);
const emailResult = await sendEmail({
to: booking.customerEmail,
subject: `Nachricht zu deinem Termin am ${formattedDate}`,
text: textContent,
html: html,
bcc: replyToEmail ? [replyToEmail] : undefined,
});
if (!emailResult.success) {
console.error(`Failed to send customer message to ${booking.customerEmail}`);
throw new Error("E-Mail konnte nicht versendet werden. Bitte überprüfe die E-Mail-Konfiguration oder versuche es später erneut.");
}
console.log(`Successfully sent customer message to ${booking.customerEmail}`);
return {
success: true,
message: `Nachricht wurde erfolgreich an ${booking.customerEmail} gesendet.`
};
}),
};