first commit
Docker-Images bauen und veroeffentlichen / build-and-push (push) Failing after 1m32s

This commit is contained in:
Nick Szaunig committed 2026-09-27 06:06:56 +02:00
commit 59bb5a38a3
132 files changed
+19435

No files matched your search

+165
View File
@@ -0,0 +1,165 @@
/**
* 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 { 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 } 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" },
),
);
}