feat(mail): Begrüßungsmail mit Zugang, Produkten, Preisen und Discord

Neue Mailvorlage "welcome" (Migration 028), von Personal gezielt über
einen Button auf der Kundenseite ausgelöst ("Begrüßungsmail senden"),
statt automatisch bei der Kundenanlage – sinnvoll ist sie erst, sobald
Produkte zugeordnet sind.

Enthält: Begrüßung, Kundennummer, eine Klartext-Aufstellung der
aktuellen aktiven Produkte mit Preisen (Privatkunden brutto,
Geschäftskunden netto+brutto, wie im Rest der Oberfläche), eine kurze
Anleitung (Ressourcen/Tickets/Rechnungen) und einen Hinweis zur
Discord-Verknüpfung unter Mein Konto. Zugangslink ist je nach Status
entweder ein frischer Einladungslink (noch nicht aktiviert) oder der
normale Login-Link (bereits aktiv).

Außerdem: renderTemplateHtml verlinkt jetzt auch weitere nackte URLs
im Text automatisch (nicht nur die Haupt-Link-Variable), damit z. B.
der Discord-Hinweis in der HTML-Ansicht klickbar ist.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Kundencenter 2026-09-28 23:50:53 +02:00
parent 1cd1a8b03a
commit 3371655ca3
4 changed files with 57 additions and 3 deletions

View file

@ -6,15 +6,32 @@ import { audit } from '../../core/audit.js';
import { clientIp, requireAuth, requirePermission } from '../../core/auth.js';
import { badRequest, conflict, forbidden, notFound } from '../../core/errors.js';
import { canInOrg } from '../../core/policy.js';
import { createInvitedUser, inviteLink, mailInvite } from '../../core/accounts.js';
import { createInvitedUser, createToken, inviteLink, mailInvite } from '../../core/accounts.js';
import { enqueue } from '../../core/jobs.js';
import { calculatePrice } from '@kc/platform/pricing';
import { addMonths, consumerTerms } from '@kc/platform/contractterms';
import { getConnector, loadInstance, syncInstance } from '@kc/connectors';
import { ConnectorError, type ImportableCustomer } from '@kc/connector-sdk';
import { AppError } from '../../core/errors.js';
import { config } from '../../core/config.js';
import { sendTemplateMail } from '../../core/mail.js';
import type { KcModule } from '../../core/module.js';
const deMoney = (cents: number) => (cents / 100).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' });
const INTERVAL_LABEL: Record<string, string> = { once: 'einmalig', monthly: 'monatlich', yearly: 'jährlich' };
/** Klartext-Aufstellung der aktiven Produkte für die Begrüßungsmail (Privatkunden brutto, Geschäftskunden netto+brutto, wie in der Oberfläche). */
function formatProducts(contracts: { price_snapshot_json: unknown }[], priv: boolean): string {
if (!contracts.length) return '– aktuell keine aktiven Produkte –';
return contracts.map((c) => {
const p = typeof c.price_snapshot_json === 'string' ? JSON.parse(c.price_snapshot_json) : c.price_snapshot_json;
const recurring = p.recurring?.net > 0 || (p.interval && p.interval !== 'once');
const amt = recurring
? (priv ? `${deMoney(p.recurring.gross)} ${INTERVAL_LABEL[p.interval] ?? ''} inkl. USt.` : `${deMoney(p.recurring.net)} netto ${INTERVAL_LABEL[p.interval] ?? ''} (${deMoney(p.recurring.gross)} brutto)`)
: (priv ? `${deMoney(p.setup.gross)} einmalig inkl. USt.` : `${deMoney(p.setup.net)} netto einmalig (${deMoney(p.setup.gross)} brutto)`);
return `• ${p.name ?? 'Produkt'}: ${amt}`;
}).join('\n');
}
const email = z.string().email().max(254).transform((s) => s.toLowerCase());
const opt = (n: number) => z.string().trim().max(n).optional().transform((v) => (v ? v : null));
const billing = z.object({
@ -88,6 +105,26 @@ export const customersModule: KcModule = {
return loadOrg(id);
});
/** Begrüßungsmail: Zugang (neuer Einladungslink oder Login-Link, falls schon aktiv), aktuelle Produkte, kurze Anleitung, Discord-Hinweis. */
app.post('/admin/customers/:id/welcome-mail', async (req) => {
const a = requirePermission(req, 'customers.write');
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const org = await loadOrg(id);
const owner = org.members.find((m) => m.role === 'owner') ?? org.members[0];
if (!owner) throw badRequest('Für diesen Kunden ist kein Ansprechpartner hinterlegt.', 'NO_MEMBER');
const u = await one('SELECT status FROM users WHERE id = ?', [owner.id]);
let loginLink: string;
if (u?.status === 'invited') {
await run("UPDATE user_tokens SET used_at = UTC_TIMESTAMP(3) WHERE user_id = ? AND purpose = 'invite' AND used_at IS NULL", [owner.id]);
loginLink = inviteLink(await createToken(undefined, owner.id, 'invite'));
} else loginLink = `${config.baseUrl}/login`;
const contracts = await query("SELECT price_snapshot_json FROM contracts WHERE org_id = ? AND status = 'active'", [id]) as { price_snapshot_json: unknown }[];
const products = formatProducts(contracts, org.customerType === 'private');
const status = await sendTemplateMail(owner.email, 'welcome', { name: owner.name, customerNumber: org.customerNumber, products, loginLink, discordUrl: `${config.baseUrl}/konto` });
await audit({ actorType: 'user', actorId: a.user.id, orgId: id, action: 'customer.welcome_mail', resourceType: 'organization', resourceId: id, correlationId: req.correlationId, ip: clientIp(req), result: status === 'sent' ? 'success' : 'failure', after: { to: owner.email, status } });
return { status };
});
app.patch('/admin/customers/:id', async (req) => {
const a = requirePermission(req, 'customers.write');
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);

View file

@ -37,6 +37,12 @@ export default function Kunde() {
async function setStatus(status: string) {
try { setO(await api<Org>('PATCH', `/admin/customers/${id}`, { status })); setConfirm(null); setMsg({ k: 'ok', t: 'Status geändert.' }); } catch (x) { setMsg({ k: 'err', t: errMsg(x) }); }
}
const [welcomeBusy, setWelcomeBusy] = useState(false);
async function sendWelcome() {
setWelcomeBusy(true);
try { const r = await api<{ status: string }>('POST', `/admin/customers/${id}/welcome-mail`); setMsg({ k: r.status === 'sent' ? 'ok' : 'warn', t: r.status === 'sent' ? 'Begrüßungsmail gesendet.' : `Nicht gesendet (Status: ${r.status}).` }); }
catch (x) { setMsg({ k: 'err', t: errMsg(x) }); } finally { setWelcomeBusy(false); }
}
return (<>
<p><Link href="/admin/kunden">← Alle Kunden</Link></p>
<div className="row between"><h1>{o.name}</h1><Status value={o.status} /></div>
@ -66,7 +72,9 @@ export default function Kunde() {
{orders === null ? <p className="muted" role="status">Wird geladen …</p> : orders.length === 0 ? <Empty title="Noch keine Bestellungen" /> :
<div className="tablewrap"><table><thead><tr><th>Nr.</th><th>Positionen</th><th>Bestellt am</th><th>Status</th></tr></thead><tbody>
{orders.map((x) => <tr key={x.id}><td className="mono"><Link href="/bestellungen">{x.number}</Link></td><td>{x.items.map((i) => i.snapshot.name).join(', ')}</td><td>{fmt(x.createdAt)}</td><td><OrderStatusBadge value={x.status} /></td></tr>)}</tbody></table></div>}</div>
<div className="card"><h2>Benutzer</h2><div className="tablewrap"><table><thead><tr><th>Name</th><th>E-Mail</th><th>Rolle</th><th>Status</th><th><span className="sr">Aktionen</span></th></tr></thead><tbody>
<div className="card"><div className="row between"><h2>Benutzer</h2>{w && <button className="btn small" disabled={welcomeBusy} onClick={sendWelcome}>{welcomeBusy ? 'Bitte warten …' : 'Begrüßungsmail senden'}</button>}</div>
<p className="muted small">Enthält den Zugangslink, die aktuellen Produkte mit Preisen und eine kurze Anleitung inkl. Discord-Verknüpfung. Geht an den ersten Ansprechpartner.</p>
<div className="tablewrap"><table><thead><tr><th>Name</th><th>E-Mail</th><th>Rolle</th><th>Status</th><th><span className="sr">Aktionen</span></th></tr></thead><tbody>
{o.members.map((m) => <tr key={m.id}><td>{m.name}</td><td>{m.email}</td><td>{ROLE_LABEL[m.role]}</td><td><Status value={m.status} /></td><td>{w && m.status === 'invited' && <button className="btn small" onClick={() => reinvite(m.id, m.email)}>Einladung senden</button>}</td></tr>)}</tbody></table></div></div>
{w && <div className="card"><h2>Kundenart</h2>
{switchTo ? (<div role="alertdialog" aria-labelledby="st"><p id="st"><strong>Zu {switchTo === 'private' ? 'Privatkunde' : 'Geschäftskunde'} wechseln?</strong> {switchTo === 'private' ? 'Firma und USt-IdNr. werden entfernt.' : 'Als Firma wird der bisherige Name übernommen.'} Das kann Auswirkungen auf Rechnungen und Steuer haben. Die Änderung wird protokolliert.</p>

View file

@ -0,0 +1,8 @@
-- Begrüßungsmail: von Personal gezielt ausgelöst (nicht automatisch), da sie den aktuellen Produktstand
-- des Kunden enthält und daher erst sinnvoll ist, sobald Produkte zugeordnet sind.
INSERT INTO mail_templates (`key`, name, trigger_text, category, variables_json, subject, body) VALUES
('welcome', 'Willkommen im Kundencenter', 'Wird von Personal manuell ausgelöst (Kundenseite), sobald der Kunde eingerichtet ist.',
'kundencenter',
JSON_ARRAY('name', 'customerNumber', 'products', 'loginLink', 'discordUrl'),
'Willkommen im Kundencenter, {{name}}!',
'Hallo {{name}},\n\nherzlich willkommen in unserem Kundencenter! Hier haben Sie jederzeit den Überblick über Ihre Produkte, Tickets und Rechnungen.\n\nIhre Kundennummer: {{customerNumber}}\n\nIhre aktuellen Produkte:\n{{products}}\n\nSo geht es los:\n- Über den Link unten gelangen Sie zu Ihrem Zugang.\n- Unter „Ressourcen“ sehen Sie alle Ihre Produkte inkl. Zugangsdaten.\n- Unter „Tickets“ erreichen Sie unseren Support.\n- Unter „Rechnungen“ finden Sie alle Belege zum Download.\n\nTipp: Unter „Mein Konto“ können Sie Ihr Discord-Konto verknüpfen, um Tickets auch direkt in Discord zu sehen und zu beantworten: {{discordUrl}}\n\n{{loginLink}}\n\nBei Fragen sind wir jederzeit über den Support für Sie da.\n');

View file

@ -65,7 +65,8 @@ const LINK_LABEL: Record<string, string> = { inviteLink: 'Zugang einrichten', re
export function renderTemplateHtml(tpl: string, vars: Record<string, string>): string {
const linkKey = Object.keys(vars).find((k) => /Link$/.test(k) && vars[k]);
const bodyVars = linkKey ? { ...vars, [linkKey]: '' } : vars;
const rendered = tpl.replace(/\{\{(\w+)\}\}/g, (m, k: string) => (k in bodyVars ? escapeHtml(bodyVars[k]!) : m));
const rendered = tpl.replace(/\{\{(\w+)\}\}/g, (m, k: string) => (k in bodyVars ? escapeHtml(bodyVars[k]!) : m))
.replace(/https?:\/\/[^\s<]+/g, (url) => `<a href="${url}" style="color:#3b6fd8;">${url}</a>`); // weitere Links im Text bleiben klickbar, auch ohne eigenen Button
const paragraphs = rendered.split(/\n{2,}/).map((p) => p.trim()).filter(Boolean).map((p) => `<p style="margin:0 0 16px;">${p.replace(/\n/g, '<br>')}</p>`).join('');
const button = linkKey ? `<p style="margin:24px 0 0;"><a href="${escapeHtml(vars[linkKey]!)}" style="display:inline-block;background:#3b6fd8;color:#ffffff;text-decoration:none;padding:12px 24px;border-radius:8px;font-weight:600;">${LINK_LABEL[linkKey] ?? 'Öffnen'}</a></p>` : '';
return `<!doctype html><html><body style="margin:0;padding:32px 16px;background:#f4f6fa;font-family:-apple-system,Segoe UI,Roboto,sans-serif;">