Mails K15/K16 je Funktion (News-Wortlaut eingefroren)

K15 (Bestellung eingegangen) und K16 (Funktion bereit) tragen den Wortlaut der
Funktion als Platzhalter (funktion, funktion_gen/akk/nom/pron/dein, preis_hinweis,
danach, bereit, knopf, portal_url) statt fest News-Pinnwand. siteFeatures baut die
Werte mit bestellMail/bereitMail aus dem Register-Eintrag (mails.*, setupEstimate,
returnHash) und dem Katalogpreis je feature.product. K15 geht ohne Anhaenge raus.
Die News-Mails bleiben Zeichen fuer Zeichen gleich (Test featureMails friert
Betreff und Text ein).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-04 12:56:45 +00:00
1 parent 9dd0244537
commit e200efb7e5
5 files changed
+421 -37

No files matched your search

@@ -3,18 +3,21 @@
<head>
<meta charset="utf-8">
<!--
K15 · Bestellung Funktion (News-Pinnwand)
Betreff: Deine Bestellung: News-Pinnwand
K15 · Bestellung Funktion (je Funktion: News-Pinnwand, Terminplaner)
Betreff: Deine Bestellung: {{funktion}}
Absender: „WEBklar“ <Portal-Postfach>
Empfänger: Kunde (E-Mail des Kundenkontos) · BCC intern
Platzhalter: {{kunde_name}}, {{projekt}}, {{preis}}, {{zeit}}, {{rechnung_url}}
Platzhalter: {{kunde_name}}, {{projekt}}, {{funktion}}, {{funktion_gen}}, {{funktion_akk}}, {{funktion_nom}}, {{funktion_pron}}, {{preis}}, {{preis_hinweis}}, {{zeit}}, {{danach}}, {{portal_url}}, {{rechnung_url}}
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): rechnung
funktion = Name der Funktion; funktion_gen/_akk/_nom/_pron = ihre Formen im Satz (Genitiv, Akkusativ, Nominativ mit Artikel, Pronomen im Akkusativ), aus dem Register (mails.formen).
preis = Preis der Einrichtung aus dem Katalog der Funktion; preis_hinweis = Zusatz in Klammern dahinter (mails.preisHinweis).
danach = Satz, was der Kunde nach der Freischaltung tun kann (mails.danach). portal_url = Adresse der Funktion im Kundenportal.
rechnung = Link zur Stripe-Rechnung (nur wenn Stripe die Rechnung schon geliefert hat).
-->
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
<title>Deine Bestellung: News-Pinnwand</title>
<title>Deine Bestellung: {{funktion}}</title>
<!--[if mso]><style>table,td{font-family:Arial,Helvetica,sans-serif!important;}</style><![endif]-->
<style>
a{color:#111111;}
@@ -23,7 +26,7 @@ a{color:#111111;}
</style>
</head>
<body style="margin:0;padding:0;background:#f1f0ec;">
<span style="display:none;max-height:0;overflow:hidden;opacity:0;mso-hide:all;font-size:1px;line-height:1px;color:#f1f0ec;">Deine Bestellung der News-Pinnwand ist bei uns eingegangen.&#8199;&#847;&#8199;&#847;&#8199;&#847;</span>
<span style="display:none;max-height:0;overflow:hidden;opacity:0;mso-hide:all;font-size:1px;line-height:1px;color:#f1f0ec;">Deine Bestellung {{funktion_gen}} ist bei uns eingegangen.&#8199;&#847;&#8199;&#847;&#8199;&#847;</span>
<table role="presentation" class="bg" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f1f0ec;">
<tr><td align="center" style="padding:32px 12px;">
<table role="presentation" class="w" width="600" cellpadding="0" cellspacing="0" border="0" style="width:600px;max-width:600px;">
@@ -34,14 +37,14 @@ a{color:#111111;}
<tr><td class="card" style="background:#ffffff;border-radius:16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr><td class="px" style="padding:44px 40px 8px 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td style="background:#e4f2e6;border-radius:100px;padding:6px 12px;font-family:Arial,Helvetica,sans-serif;font-size:12px;font-weight:bold;color:#1d5b2c;">&#10003;&nbsp; Bestellung eingegangen</td></tr></table></td></tr>
<tr><td class="px ink" style="padding:16px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:28px;line-height:34px;mso-line-height-rule:exactly;font-weight:bold;letter-spacing:-0.5px;color:#111111;">Deine Bestellung: News-Pinnwand</td></tr>
<tr><td class="px mut" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Hallo {{kunde_name}},<br>vielen Dank für Deine Bestellung und Deine Zahlung. Wir richten die News-Pinnwand jetzt für Dein Projekt ein.</td></tr>
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;mso-line-height-rule:exactly;"><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Funktion</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>News-Pinnwand</strong> – Einrichtung</td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Projekt</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{projekt}}</td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Einmalig bezahlt</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>{{preis}}</strong> <span class="mut" style="color:#6b6964;">(brutto, einmalig; die Nutzung ist kostenlos, es läuft kein Abo)</span></td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;color:#6b6964;">Einrichtung</td><td class="val ink rowline" valign="top" style="padding:12px 0;color:#111111;">{{zeit}}</td></tr></table></td></tr>
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Wie geht es weiter?</div><div class="mut" style="color:#4a4843;">Sobald die News-Pinnwand auf Deiner Website eingebaut ist, schalten wir sie im Kundenportal frei und Du bekommst eine weitere E-Mail. Danach kannst Du Beiträge im Portal schreiben.</div></td></tr></table></td></tr>
<tr><td class="px ink" style="padding:16px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:28px;line-height:34px;mso-line-height-rule:exactly;font-weight:bold;letter-spacing:-0.5px;color:#111111;">Deine Bestellung: {{funktion}}</td></tr>
<tr><td class="px mut" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Hallo {{kunde_name}},<br>vielen Dank für Deine Bestellung und Deine Zahlung. Wir richten {{funktion_akk}} jetzt für Dein Projekt ein.</td></tr>
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;mso-line-height-rule:exactly;"><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Funktion</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>{{funktion}}</strong> – Einrichtung</td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Projekt</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{projekt}}</td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Einmalig bezahlt</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>{{preis}}</strong> <span class="mut" style="color:#6b6964;">({{preis_hinweis}})</span></td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;color:#6b6964;">Einrichtung</td><td class="val ink rowline" valign="top" style="padding:12px 0;color:#111111;">{{zeit}}</td></tr></table></td></tr>
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Wie geht es weiter?</div><div class="mut" style="color:#4a4843;">Sobald {{funktion_nom}} auf Deiner Website eingebaut ist, schalten wir {{funktion_pron}} im Kundenportal frei und Du bekommst eine weitere E-Mail. {{danach}}</div></td></tr></table></td></tr>
<!-- WENN rechnung -->
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td class="btn" bgcolor="#111111" style="background:#111111;border-radius:10px;"><a href="{{rechnung_url}}" style="display:block;padding:14px 26px;font-family:Arial,Helvetica,sans-serif;font-size:15px;font-weight:bold;color:#ffffff;text-decoration:none;">Rechnung ansehen &rarr;</a></td></tr></table></td></tr>
<!-- ENDE rechnung -->
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td class="btn" bgcolor="#111111" style="background:#111111;border-radius:10px;"><a href="https://project.webklar.com/dashboard.html#news" style="display:block;padding:14px 26px;font-family:Arial,Helvetica,sans-serif;font-size:15px;font-weight:bold;color:#ffffff;text-decoration:none;">Zum Kundenportal &rarr;</a></td></tr></table></td></tr>
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td class="btn" bgcolor="#111111" style="background:#111111;border-radius:10px;"><a href="{{portal_url}}" style="display:block;padding:14px 26px;font-family:Arial,Helvetica,sans-serif;font-size:15px;font-weight:bold;color:#ffffff;text-decoration:none;">Zum Kundenportal &rarr;</a></td></tr></table></td></tr>
<tr><td class="px ink" style="padding:28px 40px 44px 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:24px;color:#111111;">Viele Grüße<br><strong>Dein WEBklar-Team</strong></td></tr>
</table>
</td></tr>
+12 -10
View File
@@ -3,16 +3,18 @@
<head>
<meta charset="utf-8">
<!--
K16 · Funktion bereit (News-Pinnwand)
Betreff: Deine News-Pinnwand ist bereit
K16 · Funktion bereit (je Funktion: News-Pinnwand, Terminplaner)
Betreff: {{funktion_dein}} ist bereit
Absender: „WEBklar“ <Portal-Postfach>
Empfänger: Kunde (E-Mail des Kundenkontos) · BCC intern
Platzhalter: {{kunde_name}}, {{projekt}}, {{portal_url}}
Platzhalter: {{kunde_name}}, {{projekt}}, {{funktion}}, {{funktion_dein}}, {{funktion_nom}}, {{bereit}}, {{knopf}}, {{portal_url}}
funktion = Name der Funktion; funktion_dein/_nom = ihre Formen im Satz („Deine News-Pinnwand“, „die News-Pinnwand“), aus dem Register (mails.formen).
bereit = Satz, was der Kunde jetzt tun kann (mails.bereit). knopf = Beschriftung des Knopfs (mails.knopf). portal_url = Adresse der Funktion im Kundenportal.
-->
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
<title>Deine News-Pinnwand ist bereit</title>
<title>{{funktion_dein}} ist bereit</title>
<!--[if mso]><style>table,td{font-family:Arial,Helvetica,sans-serif!important;}</style><![endif]-->
<style>
a{color:#111111;}
@@ -21,7 +23,7 @@ a{color:#111111;}
</style>
</head>
<body style="margin:0;padding:0;background:#f1f0ec;">
<span style="display:none;max-height:0;overflow:hidden;opacity:0;mso-hide:all;font-size:1px;line-height:1px;color:#f1f0ec;">Deine News-Pinnwand ist freigeschaltet.&#8199;&#847;&#8199;&#847;&#8199;&#847;</span>
<span style="display:none;max-height:0;overflow:hidden;opacity:0;mso-hide:all;font-size:1px;line-height:1px;color:#f1f0ec;">{{funktion_dein}} ist freigeschaltet.&#8199;&#847;&#8199;&#847;&#8199;&#847;</span>
<table role="presentation" class="bg" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f1f0ec;">
<tr><td align="center" style="padding:32px 12px;">
<table role="presentation" class="w" width="600" cellpadding="0" cellspacing="0" border="0" style="width:600px;max-width:600px;">
@@ -31,11 +33,11 @@ a{color:#111111;}
</tr></table></td></tr>
<tr><td class="card" style="background:#ffffff;border-radius:16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr><td class="px" style="padding:44px 40px 8px 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td style="background:#e4f2e6;border-radius:100px;padding:6px 12px;font-family:Arial,Helvetica,sans-serif;font-size:12px;font-weight:bold;color:#1d5b2c;">&#10003;&nbsp; News-Pinnwand</td></tr></table></td></tr>
<tr><td class="px ink" style="padding:16px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:28px;line-height:34px;mso-line-height-rule:exactly;font-weight:bold;letter-spacing:-0.5px;color:#111111;">Deine News-Pinnwand ist bereit</td></tr>
<tr><td class="px mut" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Hallo {{kunde_name}},<br>die News-Pinnwand ist auf Deiner Website eingebaut und freigeschaltet. Du kannst ab sofort Beiträge schreiben, sie erscheinen direkt bei Dir auf der Seite.</td></tr>
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;mso-line-height-rule:exactly;"><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Funktion</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>News-Pinnwand</strong></td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;color:#6b6964;">Projekt</td><td class="val ink rowline" valign="top" style="padding:12px 0;color:#111111;">{{projekt}}</td></tr></table></td></tr>
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td class="btn" bgcolor="#111111" style="background:#111111;border-radius:10px;"><a href="{{portal_url}}" style="display:block;padding:14px 26px;font-family:Arial,Helvetica,sans-serif;font-size:15px;font-weight:bold;color:#ffffff;text-decoration:none;">Beitrag schreiben &rarr;</a></td></tr></table></td></tr>
<tr><td class="px" style="padding:44px 40px 8px 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td style="background:#e4f2e6;border-radius:100px;padding:6px 12px;font-family:Arial,Helvetica,sans-serif;font-size:12px;font-weight:bold;color:#1d5b2c;">&#10003;&nbsp; {{funktion}}</td></tr></table></td></tr>
<tr><td class="px ink" style="padding:16px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:28px;line-height:34px;mso-line-height-rule:exactly;font-weight:bold;letter-spacing:-0.5px;color:#111111;">{{funktion_dein}} ist bereit</td></tr>
<tr><td class="px mut" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Hallo {{kunde_name}},<br>{{funktion_nom}} ist auf Deiner Website eingebaut und freigeschaltet. {{bereit}}</td></tr>
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;mso-line-height-rule:exactly;"><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Funktion</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>{{funktion}}</strong></td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;color:#6b6964;">Projekt</td><td class="val ink rowline" valign="top" style="padding:12px 0;color:#111111;">{{projekt}}</td></tr></table></td></tr>
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td class="btn" bgcolor="#111111" style="background:#111111;border-radius:10px;"><a href="{{portal_url}}" style="display:block;padding:14px 26px;font-family:Arial,Helvetica,sans-serif;font-size:15px;font-weight:bold;color:#ffffff;text-decoration:none;">{{knopf}} &rarr;</a></td></tr></table></td></tr>
<tr><td class="px ink" style="padding:28px 40px 44px 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:24px;color:#111111;">Viele Grüße<br><strong>Dein WEBklar-Team</strong></td></tr>
</table>
</td></tr>
+66 -16
View File
@@ -469,7 +469,60 @@ export async function findActiveBySiteKey(siteKey, featureKey = 'news') {
// ---------------------------------------------------------------------------
const eur = (cents) => `${(cents / 100).toFixed(2).replace('.', ',')} €`
const portalUrl = () => `${config.publicBaseUrl}/dashboard.html#news`
/** Adresse der Funktion im Kundenportal (Anker aus dem Register: returnHash). */
const portalUrl = (feature) => `${config.publicBaseUrl}/dashboard.html#${feature.returnHash}`
/** Preis der Einrichtung aus dem Katalog der Funktion (feature.product: newsSetup | termineSetup); config.* bei jedem Aufruf gelesen. */
function einrichtungsPreis(feature) {
const amountCents = config[feature.product]?.amountCents
if (!Number.isInteger(amountCents)) throw new Error(`Katalogpreis ${feature.product} fehlt`)
return eur(amountCents)
}
/**
* K15 (Bestellung eingegangen) einer Funktion: Vorlage, Platzhalterwerte und Bloecke ausschliesslich aus dem Register-Eintrag
* (mails.*, setupEstimate, returnHash) und dem Katalogpreis. Die Werte decken genau die Platzhalter der Vorlage ab.
* Ohne Anhaenge: die Rechtsunterlagen der Termine-Bestellung haengt F8 direkt in onFeaturePaid an.
*/
export function bestellMail(feature, { kundeName, projekt, invoiceUrl }) {
const { funktion, formen, preisHinweis, danach } = feature.mails
return {
template: 'K15',
values: {
kunde_name: kundeName,
projekt,
funktion,
funktion_gen: formen.gen,
funktion_akk: formen.akk,
funktion_nom: formen.nom,
funktion_pron: formen.pron,
preis: einrichtungsPreis(feature),
preis_hinweis: preisHinweis,
zeit: feature.setupEstimate,
danach,
portal_url: portalUrl(feature),
rechnung_url: invoiceUrl || '',
},
blocks: { rechnung: Boolean(invoiceUrl) },
}
}
/** K16 (Funktion bereit) einer Funktion, Werte wie bei bestellMail aus dem Register-Eintrag. */
export function bereitMail(feature, { kundeName, projekt }) {
const { funktion, formen, bereit, knopf } = feature.mails
return {
template: 'K16',
values: {
kunde_name: kundeName,
projekt,
funktion,
funktion_dein: formen.dein,
funktion_nom: formen.nom,
bereit,
knopf,
portal_url: portalUrl(feature),
},
}
}
/** Ereignisse, die handleFeatureCheckoutEvent versteht (Funktions-Checkout: mode 'payment' mit featureKey). */
export const FEATURE_EVENT_TYPES = [
@@ -595,17 +648,12 @@ async function onFeaturePaid(session) {
}
if (customer?.email) {
try {
await sendCustomerMail(customer.email, {
template: 'K15',
values: {
kunde_name: customer.name || '',
projekt: await projectName(doc.projectId),
preis: eur(config.newsSetup.amountCents),
zeit: config.funktionen.setupEstimate,
rechnung_url: invoiceUrl,
},
blocks: { rechnung: Boolean(invoiceUrl) },
})
const feature = featureConfig(doc.featureKey)
if (!feature) throw new Error(`Funktion ${doc.featureKey} nicht im Register`)
await sendCustomerMail(
customer.email,
bestellMail(feature, { kundeName: customer.name || '', projekt: await projectName(doc.projectId), invoiceUrl })
)
} catch (err) {
console.error('[funktionen] K15 fehlgeschlagen:', err.message)
}
@@ -647,10 +695,12 @@ export async function runActivation({ now = new Date(), customerId = null } = {}
try {
const customer = await customerOf(doc.customerId)
if (!customer?.email) continue
await sendCustomerMail(customer.email, {
template: 'K16',
values: { kunde_name: customer.name || '', projekt: await projectName(doc.projectId), portal_url: portalUrl() },
})
const feature = featureConfig(doc.featureKey)
if (!feature) throw new Error(`Funktion ${doc.featureKey} nicht im Register`)
await sendCustomerMail(
customer.email,
bereitMail(feature, { kundeName: customer.name || '', projekt: await projectName(doc.projectId) })
)
} catch (err) {
console.error(`[funktionen] K16 an Kunde ${doc.customerId} fehlgeschlagen:`, err.message)
}
+302
View File
@@ -0,0 +1,302 @@
import { test, after } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { useTestEnv } from './support/testEnv.js'
import { schemaKeys } from './support/schema.js'
// Mails K15 (Bestellung eingegangen) und K16 (Funktion bereit) je Funktion (Task B9a). Der News-Wortlaut ist als Literal
// eingefroren (vor dem Umbau der Vorlagen aus den damaligen Mails uebernommen): "News-Mails bleiben woertlich".
// B9b ergaenzt T16 und T19 in dieser Datei. websiteProjects, workorders und customers legt das Setup-Skript nicht an: ohne Schema.
const aw = await startAppwriteMock({ schema: { portalFeatures: schemaKeys('portalFeatures') } })
const st = await startFakeStripe()
after(() => Promise.all([aw.close(), st.close()]))
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { config } = await import('../server/config.js')
const reg = await import('../server/services/featureRegistry.js')
const sf = await import('../server/services/siteFeatures.js')
const { loadTemplate, renderMail } = await import('../server/services/mailTemplates.js')
const { setMailTransportForTests } = await import('../server/services/mailer.js')
const { mailSink } = await import('./support/mailSink.js')
const mails = mailSink()
setMailTransportForTests(mails)
after(() => setMailTransportForTests(null))
// Vorlagenprobleme (unbekannte oder fehlende Platzhalter) meldet der Mailer nur als Warnung: hier mitschreiben
const warnungen = []
const origWarn = console.warn
console.warn = (...args) => {
if (String(args[0]).startsWith('[mailer] Vorlage')) warnungen.push(args.join(' '))
else origWarn(...args)
}
after(() => {
console.warn = origWarn
})
aw.seed('customers', { $id: 'c1', name: 'Kunde Eins', location: 'Berlin', email: 'kunde1@example.test' })
st.invoices.set('in_1', { id: 'in_1', object: 'invoice', hosted_invoice_url: 'https://invoice.stripe.test/i/1' })
const BASE = 'https://project.webklar.com'
const FOOTER = [
'Viele Grüße',
'Dein WEBklar-Team',
'',
`Kundenportal (${BASE}/dashboard.html)`,
'',
'WEBklar GbR · Am Schwimmbad 10 · 67722 Winnweiler',
'Telefon 0176 23726355 · support@webklar.com',
'Vertreten durch die Gesellschafter Kenso Grimm und Justin Klein',
'',
]
let zaehler = 0
/** Projekt (Kunde c1) anlegen und die Funktion bestellen: liefert { projectId, sessionId }. */
async function bestelle(key, projectName) {
zaehler += 1
const projectId = `bm${zaehler}`
const sessionId = `cs_bm${zaehler}`
aw.seed('websiteProjects', { $id: projectId, customerId: 'c1', projectName })
await sf.markOrdered({ customerId: 'c1', projectId, key, sessionId, consent: {} })
return { projectId, sessionId }
}
const paidEvent = (sessionId, projectId, key, extra = {}) => ({
id: `evt_${sessionId}`,
object: 'event',
type: 'checkout.session.completed',
data: {
object: { id: sessionId, object: 'checkout.session', mode: 'payment', payment_status: 'paid', metadata: { customerId: 'c1', projectId, featureKey: key }, ...extra },
},
})
/** Bestellung bezahlen (Stripe-Ereignis) und die K15-Mail an den Kunden liefern. */
async function bezahle(key, projectName, extra = {}) {
const { projectId, sessionId } = await bestelle(key, projectName)
const vor = mails.sent.length
await sf.handleFeatureCheckoutEvent(paidEvent(sessionId, projectId, key, extra))
const k15 = mails.sent.slice(vor).filter((m) => m.subject.startsWith('Deine Bestellung'))
assert.equal(k15.length, 1, `genau eine K15-Mail: ${mails.sent.slice(vor).map((m) => m.subject)}`)
return { projectId, sessionId, mail: k15[0] }
}
// ------------------------------------------------------------------------- News bleibt woertlich ---
test('News-Mails bleiben wörtlich: K15 mit Rechnung', async () => {
const { mail } = await bezahle('news', 'Bäckerei', { invoice: 'in_1' })
assert.equal(mail.subject, 'Deine Bestellung: News-Pinnwand')
assert.equal(
mail.text,
[
'WEBklar',
'',
'Bestätigung',
'',
'✓ Bestellung eingegangen',
'',
'Deine Bestellung: News-Pinnwand',
'',
'Hallo Kunde Eins,',
'vielen Dank für Deine Bestellung und Deine Zahlung. Wir richten die News-Pinnwand jetzt für Dein Projekt ein.',
'',
'Funktion: News-Pinnwand – Einrichtung',
'Projekt: Bäckerei',
'Einmalig bezahlt: 30,00 € (brutto, einmalig; die Nutzung ist kostenlos, es läuft kein Abo)',
'Einrichtung: meist innerhalb von 1–2 Wochen',
'',
'Wie geht es weiter?',
'Sobald die News-Pinnwand auf Deiner Website eingebaut ist, schalten wir sie im Kundenportal frei und Du bekommst eine weitere E-Mail. Danach kannst Du Beiträge im Portal schreiben.',
'',
'Rechnung ansehen: https://invoice.stripe.test/i/1',
'',
`Zum Kundenportal: ${BASE}/dashboard.html#news`,
'',
...FOOTER,
].join('\n')
)
assert.deepEqual(mail.to.map((t) => t.address), ['kunde1@example.test'])
})
test('News-Mails bleiben wörtlich: K15 ohne Rechnung', async () => {
const { mail } = await bezahle('news', 'Café')
assert.equal(mail.subject, 'Deine Bestellung: News-Pinnwand')
assert.equal(
mail.text,
[
'WEBklar',
'',
'Bestätigung',
'',
'✓ Bestellung eingegangen',
'',
'Deine Bestellung: News-Pinnwand',
'',
'Hallo Kunde Eins,',
'vielen Dank für Deine Bestellung und Deine Zahlung. Wir richten die News-Pinnwand jetzt für Dein Projekt ein.',
'',
'Funktion: News-Pinnwand – Einrichtung',
'Projekt: Café',
'Einmalig bezahlt: 30,00 € (brutto, einmalig; die Nutzung ist kostenlos, es läuft kein Abo)',
'Einrichtung: meist innerhalb von 1–2 Wochen',
'',
'Wie geht es weiter?',
'Sobald die News-Pinnwand auf Deiner Website eingebaut ist, schalten wir sie im Kundenportal frei und Du bekommst eine weitere E-Mail. Danach kannst Du Beiträge im Portal schreiben.',
'',
`Zum Kundenportal: ${BASE}/dashboard.html#news`,
'',
...FOOTER,
].join('\n')
)
assert.doesNotMatch(mail.text, /Rechnung ansehen/)
})
test('News-Mails bleiben wörtlich: K16', async () => {
const { projectId } = await bezahle('news', 'Bistro')
const doc = await sf.getFeatureDoc('c1', projectId, 'news')
aw.seed('workorders', { ...aw.docs('workorders').get(doc.ticketId), status: 'Closed' })
const vor = mails.sent.length
const done = await sf.runActivation({ customerId: 'c1' })
assert.deepEqual(done.map((d) => d.projectId), [projectId])
const gesendet = mails.sent.slice(vor)
assert.equal(gesendet.length, 1)
const [mail] = gesendet
assert.equal(mail.subject, 'Deine News-Pinnwand ist bereit')
assert.equal(
mail.text,
[
'WEBklar',
'',
'Bereit',
'',
'✓ News-Pinnwand',
'',
'Deine News-Pinnwand ist bereit',
'',
'Hallo Kunde Eins,',
'die News-Pinnwand ist auf Deiner Website eingebaut und freigeschaltet. Du kannst ab sofort Beiträge schreiben, sie erscheinen direkt bei Dir auf der Seite.',
'',
'Funktion: News-Pinnwand',
'Projekt: Bistro',
'',
`Beitrag schreiben: ${BASE}/dashboard.html#news`,
'',
...FOOTER,
].join('\n')
)
assert.deepEqual(mail.to.map((t) => t.address), ['kunde1@example.test'])
})
// ------------------------------------------------------------------------------------- Termine ---
test('Termine-Mails: K15 mit den Werten des Terminplaners', async () => {
const termine = config.features.termine.mails
const { mail } = await bezahle('termine', 'Salon Haarlinie', { invoice: 'in_1' })
assert.equal(mail.subject, 'Deine Bestellung: Terminplaner')
assert.doesNotMatch(mail.text, /News|Beitr/)
assert.doesNotMatch(mail.html, /News|Beitr/)
assert.match(mail.text, /Wir richten den Terminplaner jetzt für Dein Projekt ein\./)
assert.match(mail.text, /Funktion: Terminplaner – Einrichtung/)
assert.match(mail.text, /Projekt: Salon Haarlinie/)
assert.ok(mail.text.includes(`Einmalig bezahlt: 149,00 € (${termine.preisHinweis})`), mail.text)
assert.ok(
mail.text.includes(`Sobald der Terminplaner auf Deiner Website eingebaut ist, schalten wir ihn im Kundenportal frei und Du bekommst eine weitere E-Mail. ${termine.danach}`),
mail.text
)
assert.match(mail.text, /Rechnung ansehen: https:\/\/invoice\.stripe\.test\/i\/1/)
assert.ok(mail.text.includes(`Zum Kundenportal: ${BASE}/dashboard.html#termine`), mail.text)
assert.match(mail.html, new RegExp(`href="${BASE.replace(/\./g, '\\.')}/dashboard\\.html#termine"`))
assert.deepEqual(mail.to.map((t) => t.address), ['kunde1@example.test'])
})
test('Termine-Mails: K16 mit den Werten des Terminplaners', () => {
const termine = config.features.termine.mails
const nachricht = sf.bereitMail(reg.featureConfig('termine'), { kundeName: 'Kunde Eins', projekt: 'Salon Haarlinie' })
assert.equal(nachricht.template, 'K16')
const mail = renderMail(nachricht.template, nachricht.values, nachricht.blocks)
assert.deepEqual(mail.problems, [])
assert.equal(mail.subject, 'Dein Terminplaner ist bereit')
assert.doesNotMatch(mail.text, /News|Beitr/)
assert.doesNotMatch(mail.html, /News|Beitr/)
assert.match(mail.text, /Hallo Kunde Eins,\nder Terminplaner ist auf Deiner Website eingebaut und freigeschaltet\./)
assert.ok(mail.text.includes(termine.bereit), mail.text)
assert.match(mail.text, /Funktion: Terminplaner\nProjekt: Salon Haarlinie/)
assert.ok(mail.text.includes(`Termine öffnen: ${BASE}/dashboard.html#termine`), mail.text)
})
// ----------------------------------------------------------------------------- Platzhalter, Blöcke ---
test('Platzhalter-/Blocklisten exakt: K15 und K16', () => {
const k15 = loadTemplate('K15')
assert.deepEqual(k15.placeholders, [
'kunde_name',
'projekt',
'funktion',
'funktion_gen',
'funktion_akk',
'funktion_nom',
'funktion_pron',
'preis',
'preis_hinweis',
'zeit',
'danach',
'portal_url',
'rechnung_url',
])
assert.deepEqual(k15.blocks, ['rechnung'])
assert.equal(k15.subject, 'Deine Bestellung: {{funktion}}')
const k16 = loadTemplate('K16')
assert.deepEqual(k16.placeholders, ['kunde_name', 'projekt', 'funktion', 'funktion_dein', 'funktion_nom', 'bereit', 'knopf', 'portal_url'])
assert.deepEqual(k16.blocks, [])
assert.equal(k16.subject, '{{funktion_dein}} ist bereit')
})
test('Die Mail-Werte je Funktion decken genau die Platzhalter der Vorlagen ab', () => {
const ctx = { kundeName: 'Kunde Eins', projekt: 'Salon', invoiceUrl: 'https://invoice.stripe.test/i/1' }
for (const key of reg.featureKeys()) {
const feature = reg.featureConfig(key)
const k15 = sf.bestellMail(feature, ctx)
const k16 = sf.bereitMail(feature, ctx)
assert.equal(k15.template, 'K15')
assert.equal(k16.template, 'K16')
assert.deepEqual(Object.keys(k15.values).sort(), [...loadTemplate('K15').placeholders].sort(), `${key}: K15`)
assert.deepEqual(Object.keys(k16.values).sort(), [...loadTemplate('K16').placeholders].sort(), `${key}: K16`)
assert.deepEqual(k15.blocks, { rechnung: true })
assert.deepEqual(sf.bestellMail(feature, { ...ctx, invoiceUrl: '' }).blocks, { rechnung: false })
for (const nachricht of [k15, k16]) {
const mail = renderMail(nachricht.template, nachricht.values, nachricht.blocks)
assert.deepEqual(mail.problems, [], `${key}: ${nachricht.template}`)
}
// Preis aus dem Katalog der Funktion (feature.product), nicht aus News
assert.equal(k15.values.preis, `${(config[feature.product].amountCents / 100).toFixed(2).replace('.', ',')} €`)
assert.equal(k15.values.portal_url, `${config.publicBaseUrl}/dashboard.html#${feature.returnHash}`)
assert.equal(k16.values.portal_url, k15.values.portal_url)
}
})
// ------------------------------------------------------------------------------------ Anhänge ---
test('K15 ohne Anhänge: die Termine-Bestellung ruft keinen Registerhaken', async () => {
const termine = config.features.termine
const aufrufe = []
const alt = {}
const spion = (name) => (...args) => {
aufrufe.push(name)
return name === 'mails.anhaenge' ? [] : true
}
for (const name of ['verfuegbar', 'bestellpruefung', 'unterlagenBereit', 'belehrung', 'freischaltbar']) {
alt[name] = termine[name]
termine[name] = spion(name)
}
termine.mails.anhaenge = spion('mails.anhaenge')
try {
const { mail } = await bezahle('termine', 'Anhang-Test')
assert.ok(!mail.attachments || mail.attachments.length === 0, 'K15 trägt keinen Anhang')
assert.deepEqual(aufrufe, [])
} finally {
for (const name of Object.keys(alt)) {
if (alt[name] === undefined) delete termine[name]
else termine[name] = alt[name]
}
delete termine.mails.anhaenge
}
})
test('keine Vorlagenwarnungen in allen Abläufen dieser Datei', () => {
assert.deepEqual(warnungen, [])
})
+28 -1
View File
@@ -6,7 +6,7 @@ import { contractAttachmentFor, termsDocFor } from '../server/services/products.
const DIR = new URL('../server/mail-templates/', import.meta.url)
// G01–G08 Gästemails des Terminplaners (Task 9, neutrales Layout im Auftrag eines Betriebs), K20 und K21 Hinweise an den Betrieb;
// K12, K14 und T12 (Wechsel ins Wartungs-Abo) aus Task 9, K13 und T13 (Zusatzspeicher) aus Task 8 (Fix 1); Endfassung der Texte in Task 12; T14 „Löschfristen“ aus Task 21 (F39); T15 Push-Zeitmail; K15, K16, T16 Funktionen (News-Pinnwand); T17 Repo zuordnen; K17 Chat-Hinweis an Kunden; K18, K19 Website-Analyse; T18 Bankzugang erneuern (Finanzen); T20 Terminplaner Betriebsmeldung (Terminplaner TP2, Team-Hinweis; T19 „Erstattung fällig“ folgt in Block B)
// K12, K14 und T12 (Wechsel ins Wartungs-Abo) aus Task 9, K13 und T13 (Zusatzspeicher) aus Task 8 (Fix 1); Endfassung der Texte in Task 12; T14 „Löschfristen“ aus Task 21 (F39); T15 Push-Zeitmail; K15, K16, T16 Funktionen (News-Pinnwand, Terminplaner; K15 und K16 gelten je Funktion, Terminplaner TP2 B9a); T17 Repo zuordnen; K17 Chat-Hinweis an Kunden; K18, K19 Website-Analyse; T18 Bankzugang erneuern (Finanzen); T20 Terminplaner Betriebsmeldung (Terminplaner TP2, Team-Hinweis; T19 „Erstattung fällig“ folgt in Block B)
const ids = (prefix, count) => Array.from({ length: count }, (_, i) => `${prefix}${String(i + 1).padStart(2, '0')}`)
const EXPECTED = [...ids('G', 8), ...ids('K', 21), ...ids('T', 18), 'T20']
const GUEST = ids('G', 8)
@@ -70,6 +70,33 @@ test('T20: Betreff [Terminplaner], Platzhalter vorfall/details/zeit, keine Blöc
for (const line of ['Mailkontingent erreicht', 'Zeile 1\nZeile <2>', '04.10.2026, 10:00']) assert.ok(mail.text.includes(line), `Text enthält: ${line}\n---\n${mail.text}`)
})
// Terminplaner TP2 (B9a): K15 und K16 gelten für jede Funktion des Registers; der Wortlaut je Funktion kommt aus den Platzhaltern
test('K15, K16: Platzhalter und Blöcke je Funktion, keine Funktion fest im Text', () => {
const k15 = loadTemplate('K15')
assert.equal(k15.subject, 'Deine Bestellung: {{funktion}}')
assert.deepEqual(k15.placeholders, ['kunde_name', 'projekt', 'funktion', 'funktion_gen', 'funktion_akk', 'funktion_nom', 'funktion_pron', 'preis', 'preis_hinweis', 'zeit', 'danach', 'portal_url', 'rechnung_url'])
assert.deepEqual(k15.blocks, ['rechnung'])
const k16 = loadTemplate('K16')
assert.equal(k16.subject, '{{funktion_dein}} ist bereit')
assert.deepEqual(k16.placeholders, ['kunde_name', 'projekt', 'funktion', 'funktion_dein', 'funktion_nom', 'bereit', 'knopf', 'portal_url'])
assert.deepEqual(k16.blocks, [])
for (const tpl of [k15, k16]) assert.doesNotMatch(tpl.body, /News|Beitr|#news|#termine|Terminplaner/, `${tpl.id}: keine feste Funktion`)
const mail = renderMail('K15', { ...sampleValues(k15), rechnung_url: 'https://rechnung.example/1', portal_url: 'https://portal.example/#x' }, { rechnung: true })
assert.deepEqual(mail.problems, [])
assert.equal(mail.subject, 'Deine Bestellung: Wert-funktion')
for (const part of ['Wir richten Wert-funktion_akk jetzt für Dein Projekt ein.', 'Sobald Wert-funktion_nom auf Deiner Website eingebaut ist, schalten wir Wert-funktion_pron im Kundenportal frei', 'Wert-danach', 'Wert-preis (Wert-preis_hinweis)', 'Rechnung ansehen: https://rechnung.example/1', 'Zum Kundenportal: https://portal.example/#x']) {
assert.ok(mail.text.includes(part), `K15 enthält: ${part}\n---\n${mail.text}`)
}
assert.doesNotMatch(renderMail('K15', sampleValues(k15), { rechnung: false }).text, /Rechnung ansehen/)
const bereit = renderMail('K16', sampleValues(k16), {})
assert.deepEqual(bereit.problems, [])
assert.equal(bereit.subject, 'Wert-funktion_dein ist bereit')
for (const part of ['✓ Wert-funktion', 'Wert-funktion_nom ist auf Deiner Website eingebaut und freigeschaltet. Wert-bereit', 'Wert-knopf: Wert-portal_url']) {
assert.ok(bereit.text.includes(part), `K16 enthält: ${part}\n---\n${bereit.text}`)
}
})
test('Werte werden HTML-sicher eingesetzt, Zeilenumbrüche werden <br>', () => {
const mail = renderMail('T01', { kunde_name: 'Max <b>&"\'', nachricht: 'Zeile 1\nZeile <2>' })
assert.match(mail.html, /Max &lt;b&gt;&amp;&quot;&#39; hat im Kundenportal geschrieben/)