Docker-Images bauen und veroeffentlichen / build-and-push (push) Successful in 4m31s
Neuer Abschnitt nach den Fragen, im Menü als Erstgespräch. Anfrage geht als E-Mail an die Veranstalter-Adresse (Admin) bzw. ADMIN_NOTIFY_EMAIL, sofort zugestellt, sonst über den Worker wiederholt. Gleicher Schutz wie die Buchung: nur JSON von der eigenen Seite, Rate Limit (5/h je IP), Prüfung auf dem Server, Honeypot gegen Bots, doppeltes Absenden ergibt eine Mail. Keine automatische Mail an die angegebene Adresse (sonst Missbrauch für Spam an Dritte). Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
184 lines
8.8 KiB
TypeScript
184 lines
8.8 KiB
TypeScript
/**
|
||
* Texte der Buchungs-E-Mails. Jede Mail nennt nur den tatsaechlich
|
||
* bestaetigten Status — eine Teilzahlung heisst nie "vollstaendig bezahlt".
|
||
*/
|
||
import type { Booking, Installment } from "@/generated/prisma/client";
|
||
import type { ContactRequest } from "./contact";
|
||
import { escapeHtml, type EmailDraft } from "./email";
|
||
import { appUrl } from "./env";
|
||
import { formatEuro } from "./money";
|
||
import { PLAN_LABEL } from "./payment-plan";
|
||
import { PHASE_LABEL } from "./pricing";
|
||
import { formatDate, formatDateTime } from "./time";
|
||
|
||
type BookingWithInstallments = Booking & { installments: Installment[] };
|
||
|
||
const BRAND = "ARTISTA MODELCAMP 2027";
|
||
|
||
function statusLink(token: string): string {
|
||
return appUrl(`/buchung/status/${token}`);
|
||
}
|
||
|
||
function planLines(booking: BookingWithInstallments): string[] {
|
||
return booking.installments
|
||
.slice()
|
||
.sort((a, b) => a.seq - b.seq)
|
||
.map((i) => {
|
||
const due = i.dueAt ? `fällig am ${formatDate(i.dueAt)}` : "bei Buchung";
|
||
const state =
|
||
i.status === "PAID" ? "bezahlt" : i.status === "CANCELED" ? "storniert" : i.status === "PROCESSING" ? "in Bearbeitung" : "offen";
|
||
return `Rate ${i.seq}: ${formatEuro(i.amountCents)}, ${due}, ${state}`;
|
||
});
|
||
}
|
||
|
||
function wrap(subject: string, paragraphs: string[], link?: { href: string; label: string }): { text: string; html: string } {
|
||
const text = [...paragraphs, ...(link ? [`${link.label}: ${link.href}`] : []), "", `${BRAND}`].join("\n\n");
|
||
const html = `<!doctype html><html lang="de"><body style="font-family:Georgia,serif;color:#1f1d1b;background:#f6f1ea;padding:24px">
|
||
<div style="max-width:560px;margin:0 auto;background:#fffdf9;padding:32px;border:1px solid #e6ddd0">
|
||
<p style="letter-spacing:.2em;font-size:12px;text-transform:uppercase;color:#8a5a3b">${BRAND}</p>
|
||
<h1 style="font-weight:normal;font-size:24px">${escapeHtml(subject)}</h1>
|
||
${paragraphs.map((p) => `<p style="font-family:Arial,sans-serif;font-size:15px;line-height:1.6;white-space:pre-line">${escapeHtml(p)}</p>`).join("\n")}
|
||
${link ? `<p><a href="${escapeHtml(link.href)}" style="display:inline-block;background:#1f1d1b;color:#fffdf9;padding:12px 20px;text-decoration:none;font-family:Arial,sans-serif">${escapeHtml(link.label)}</a></p>` : ""}
|
||
</div></body></html>`;
|
||
return { text, html };
|
||
}
|
||
|
||
function draft(key: string, booking: Booking, subject: string, body: { text: string; html: string }): EmailDraft {
|
||
return { dedupeKey: key, to: booking.email, subject: `${subject} | ${BRAND}`, bookingId: booking.id, ...body };
|
||
}
|
||
|
||
export function bookingConfirmedEmail(booking: BookingWithInstallments, token: string): EmailDraft {
|
||
const fullyPaid = booking.paidCents >= booking.totalCents;
|
||
const subject = fullyPaid ? "Deine Buchung ist bestätigt und bezahlt" : "Deine Buchung ist bestätigt, erste Rate erhalten";
|
||
const lines = [
|
||
`Hallo ${booking.firstName},`,
|
||
`vielen Dank! Deine Buchung ${booking.reference} ist bestätigt.`,
|
||
`Gesamtpreis: ${formatEuro(booking.totalCents)} (${PHASE_LABEL[booking.pricePhase]}${booking.voucherDiscountCents > 0 ? ", inkl. Gutschein" : ""})\nZahlungsweise: ${PLAN_LABEL[booking.planType]}\nBezahlt: ${formatEuro(booking.paidCents)}\nOffen: ${formatEuro(Math.max(0, booking.totalCents - booking.paidCents))}`,
|
||
planLines(booking).join("\n"),
|
||
];
|
||
if (!fullyPaid) {
|
||
lines.push("Die weiteren Raten werden zu den genannten Terminen über die hinterlegte Zahlungsmethode eingezogen. Du erhältst jeweils eine Bestätigung.");
|
||
}
|
||
lines.push("Alle Details und den aktuellen Zahlungsstand findest du jederzeit auf deiner Buchungsseite.");
|
||
return draft(`booking-confirmed:${booking.id}`, booking, subject, wrap(subject, lines, { href: statusLink(token), label: "Buchung ansehen" }));
|
||
}
|
||
|
||
export function paymentProcessingEmail(booking: Booking, token: string): EmailDraft {
|
||
const subject = "Deine Zahlung wird verarbeitet";
|
||
return draft(
|
||
`payment-processing:${booking.id}`,
|
||
booking,
|
||
subject,
|
||
wrap(
|
||
subject,
|
||
[
|
||
`Hallo ${booking.firstName},`,
|
||
`wir haben deine Buchung ${booking.reference} erhalten. Deine Zahlungsart braucht einige Tage, bis die Zahlung bestätigt ist. Dein Platz bleibt so lange reserviert.`,
|
||
"Sobald die Zahlung eingegangen ist, bekommst du eine Bestätigung. Erst dann ist die Buchung verbindlich bestätigt.",
|
||
],
|
||
{ href: statusLink(token), label: "Status ansehen" },
|
||
),
|
||
);
|
||
}
|
||
|
||
export function paymentFailedEmail(booking: Booking): EmailDraft {
|
||
const subject = "Deine Zahlung ist fehlgeschlagen";
|
||
return draft(
|
||
`payment-failed:${booking.id}`,
|
||
booking,
|
||
subject,
|
||
wrap(subject, [
|
||
`Hallo ${booking.firstName},`,
|
||
`leider konnte die Zahlung für deine Buchung ${booking.reference} nicht abgeschlossen werden. Die Reservierung wurde deshalb aufgehoben.`,
|
||
"Du kannst jederzeit eine neue Buchung starten.",
|
||
]),
|
||
);
|
||
}
|
||
|
||
export function installmentPaidEmail(booking: BookingWithInstallments, installment: Installment, token: string): EmailDraft {
|
||
const fullyPaid = booking.paidCents >= booking.totalCents;
|
||
const subject = fullyPaid ? "Vollständig bezahlt. Danke!" : `Rate ${installment.seq} erhalten`;
|
||
return draft(
|
||
`installment-paid:${installment.id}`,
|
||
booking,
|
||
subject,
|
||
wrap(
|
||
subject,
|
||
[
|
||
`Hallo ${booking.firstName},`,
|
||
`wir haben Rate ${installment.seq} über ${formatEuro(installment.amountCents)} für deine Buchung ${booking.reference} erhalten.`,
|
||
fullyPaid
|
||
? "Damit ist dein Platz vollständig bezahlt. Weitere Abbuchungen finden nicht statt."
|
||
: `Bezahlt: ${formatEuro(booking.paidCents)} · Offen: ${formatEuro(booking.totalCents - booking.paidCents)}`,
|
||
planLines(booking).join("\n"),
|
||
],
|
||
{ href: statusLink(token), label: "Buchung ansehen" },
|
||
),
|
||
);
|
||
}
|
||
|
||
export function installmentActionEmail(booking: Booking, installment: Installment, token: string, requiresAction: boolean): EmailDraft {
|
||
const subject = requiresAction ? `Bitte bestätige Rate ${installment.seq}` : `Rate ${installment.seq} konnte nicht eingezogen werden`;
|
||
return draft(
|
||
`installment-action:${installment.id}:${installment.attemptCount}:${requiresAction ? "auth" : "fail"}`,
|
||
booking,
|
||
subject,
|
||
wrap(
|
||
subject,
|
||
[
|
||
`Hallo ${booking.firstName},`,
|
||
requiresAction
|
||
? `deine Bank möchte die Zahlung von Rate ${installment.seq} (${formatEuro(installment.amountCents)}) zusätzlich bestätigen lassen.`
|
||
: `der Einzug von Rate ${installment.seq} (${formatEuro(installment.amountCents)}) ist leider fehlgeschlagen.`,
|
||
"Du kannst die Rate über den folgenden Link selbst begleichen, auch mit einer anderen Zahlungsmethode.",
|
||
],
|
||
{ href: statusLink(token), label: "Rate jetzt bezahlen" },
|
||
),
|
||
);
|
||
}
|
||
|
||
export function adminReviewEmail(to: string, booking: Booking, reason: string): EmailDraft {
|
||
const subject = `Prüfung nötig: Buchung ${booking.reference}`;
|
||
const body = wrap(subject, [
|
||
`Die Buchung ${booking.reference} benötigt eine manuelle Entscheidung.`,
|
||
`Grund: ${reason}`,
|
||
"Details stehen im Adminbereich unter Buchungen.",
|
||
], { href: appUrl(`/admin/buchungen/${booking.id}`), label: "Im Adminbereich öffnen" });
|
||
return { dedupeKey: `admin-review:${booking.id}:${reason}`, to, subject, bookingId: booking.id, ...body };
|
||
}
|
||
|
||
export function reviewCustomerEmail(booking: Booking, token: string): EmailDraft {
|
||
const subject = "Deine Zahlung ist eingegangen, wir melden uns";
|
||
return draft(
|
||
`review-customer:${booking.id}`,
|
||
booking,
|
||
subject,
|
||
wrap(
|
||
subject,
|
||
[
|
||
`Hallo ${booking.firstName},`,
|
||
`deine Zahlung für die Buchung ${booking.reference} ist bei uns eingegangen. Bevor wir die Buchung bestätigen können, müssen wir sie kurz manuell prüfen.`,
|
||
"Wir melden uns umgehend bei dir. Solltest du keinen Platz erhalten, wird dir der gezahlte Betrag vollständig erstattet.",
|
||
],
|
||
{ href: statusLink(token), label: "Status ansehen" },
|
||
),
|
||
);
|
||
}
|
||
|
||
/** Anfrage fuer ein Erstgespraech an den Veranstalter (src/lib/contact.ts). */
|
||
export function contactInquiryEmail(to: string, inquiry: ContactRequest, dedupeKey: string, receivedAt: Date): EmailDraft {
|
||
const subject = `Anfrage Erstgespräch: ${inquiry.name}`;
|
||
const lines = [
|
||
`Über die Webseite kam eine Anfrage für ein Erstgespräch (${formatDateTime(receivedAt)}).`,
|
||
[
|
||
`Name: ${inquiry.name}`,
|
||
`E-Mail: ${inquiry.email}`,
|
||
`Telefon: ${inquiry.phone ?? "–"}`,
|
||
`Erreichbar: ${inquiry.availability ?? "–"}`,
|
||
].join("\n"),
|
||
inquiry.message ? `Nachricht:\n${inquiry.message}` : "Keine Nachricht angegeben.",
|
||
];
|
||
const reply = `mailto:${encodeURIComponent(inquiry.email)}?subject=${encodeURIComponent(`Dein Erstgespräch – ${BRAND}`)}`;
|
||
return { dedupeKey, to, subject: `${subject} | ${BRAND}`, ...wrap(subject, lines, { href: reply, label: "Antworten" }) };
|
||
}
|