From 3371655ca33bf294c16ef6aa41e0ade25a27b367 Mon Sep 17 00:00:00 2001 From: Kundencenter Date: Mon, 28 Sep 2026 23:50:53 +0200 Subject: [PATCH] =?UTF-8?q?feat(mail):=20Begr=C3=BC=C3=9Fungsmail=20mit=20?= =?UTF-8?q?Zugang,=20Produkten,=20Preisen=20und=20Discord?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/api/src/modules/customers/index.ts | 39 ++++++++++++++++++- .../src/app/(app)/admin/kunden/[id]/page.tsx | 10 ++++- migrations/028_welcome_mail.sql | 8 ++++ packages/platform/src/mail.ts | 3 +- 4 files changed, 57 insertions(+), 3 deletions(-) create mode 100644 migrations/028_welcome_mail.sql diff --git a/apps/api/src/modules/customers/index.ts b/apps/api/src/modules/customers/index.ts index bf9ae1f..e4db92e 100644 --- a/apps/api/src/modules/customers/index.ts +++ b/apps/api/src/modules/customers/index.ts @@ -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 = { 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); diff --git a/apps/web/src/app/(app)/admin/kunden/[id]/page.tsx b/apps/web/src/app/(app)/admin/kunden/[id]/page.tsx index 3c97ff9..1100ffd 100644 --- a/apps/web/src/app/(app)/admin/kunden/[id]/page.tsx +++ b/apps/web/src/app/(app)/admin/kunden/[id]/page.tsx @@ -37,6 +37,12 @@ export default function Kunde() { async function setStatus(status: string) { try { setO(await api('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 (<>

← Alle Kunden

{o.name}

@@ -66,7 +72,9 @@ export default function Kunde() { {orders === null ?

Wird geladen …

: orders.length === 0 ? :
{orders.map((x) => )}
Nr.PositionenBestellt amStatus
{x.number}{x.items.map((i) => i.snapshot.name).join(', ')}{fmt(x.createdAt)}
} -

Benutzer

+

Benutzer

{w && }
+

Enthält den Zugangslink, die aktuellen Produkte mit Preisen und eine kurze Anleitung inkl. Discord-Verknüpfung. Geht an den ersten Ansprechpartner.

+
NameE-MailRolleStatusAktionen
{o.members.map((m) => )}
NameE-MailRolleStatusAktionen
{m.name}{m.email}{ROLE_LABEL[m.role]}{w && m.status === 'invited' && }
{w &&

Kundenart

{switchTo ? (

Zu {switchTo === 'private' ? 'Privatkunde' : 'Geschäftskunde'} wechseln? {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.

diff --git a/migrations/028_welcome_mail.sql b/migrations/028_welcome_mail.sql new file mode 100644 index 0000000..8ca3737 --- /dev/null +++ b/migrations/028_welcome_mail.sql @@ -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'); diff --git a/packages/platform/src/mail.ts b/packages/platform/src/mail.ts index 4975155..583da36 100644 --- a/packages/platform/src/mail.ts +++ b/packages/platform/src/mail.ts @@ -65,7 +65,8 @@ const LINK_LABEL: Record = { inviteLink: 'Zugang einrichten', re export function renderTemplateHtml(tpl: string, vars: Record): 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) => `${url}`); // 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.replace(/\n/g, '
')}

`).join(''); const button = linkKey ? `

${LINK_LABEL[linkKey] ?? 'Öffnen'}

` : ''; return `