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:
parent
1cd1a8b03a
commit
3371655ca3
4 changed files with 57 additions and 3 deletions
|
|
@ -6,15 +6,32 @@ import { audit } from '../../core/audit.js';
|
||||||
import { clientIp, requireAuth, requirePermission } from '../../core/auth.js';
|
import { clientIp, requireAuth, requirePermission } from '../../core/auth.js';
|
||||||
import { badRequest, conflict, forbidden, notFound } from '../../core/errors.js';
|
import { badRequest, conflict, forbidden, notFound } from '../../core/errors.js';
|
||||||
import { canInOrg } from '../../core/policy.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 { enqueue } from '../../core/jobs.js';
|
||||||
import { calculatePrice } from '@kc/platform/pricing';
|
import { calculatePrice } from '@kc/platform/pricing';
|
||||||
import { addMonths, consumerTerms } from '@kc/platform/contractterms';
|
import { addMonths, consumerTerms } from '@kc/platform/contractterms';
|
||||||
import { getConnector, loadInstance, syncInstance } from '@kc/connectors';
|
import { getConnector, loadInstance, syncInstance } from '@kc/connectors';
|
||||||
import { ConnectorError, type ImportableCustomer } from '@kc/connector-sdk';
|
import { ConnectorError, type ImportableCustomer } from '@kc/connector-sdk';
|
||||||
import { AppError } from '../../core/errors.js';
|
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';
|
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 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 opt = (n: number) => z.string().trim().max(n).optional().transform((v) => (v ? v : null));
|
||||||
const billing = z.object({
|
const billing = z.object({
|
||||||
|
|
@ -88,6 +105,26 @@ export const customersModule: KcModule = {
|
||||||
return loadOrg(id);
|
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) => {
|
app.patch('/admin/customers/:id', async (req) => {
|
||||||
const a = requirePermission(req, 'customers.write');
|
const a = requirePermission(req, 'customers.write');
|
||||||
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
|
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,12 @@ export default function Kunde() {
|
||||||
async function setStatus(status: string) {
|
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) }); }
|
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 (<>
|
return (<>
|
||||||
<p><Link href="/admin/kunden">← Alle Kunden</Link></p>
|
<p><Link href="/admin/kunden">← Alle Kunden</Link></p>
|
||||||
<div className="row between"><h1>{o.name}</h1><Status value={o.status} /></div>
|
<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" /> :
|
{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>
|
<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>
|
{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>
|
{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>
|
{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>
|
{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>
|
||||||
|
|
|
||||||
8
migrations/028_welcome_mail.sql
Normal file
8
migrations/028_welcome_mail.sql
Normal 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');
|
||||||
|
|
@ -65,7 +65,8 @@ const LINK_LABEL: Record<string, string> = { inviteLink: 'Zugang einrichten', re
|
||||||
export function renderTemplateHtml(tpl: string, vars: Record<string, string>): string {
|
export function renderTemplateHtml(tpl: string, vars: Record<string, string>): string {
|
||||||
const linkKey = Object.keys(vars).find((k) => /Link$/.test(k) && vars[k]);
|
const linkKey = Object.keys(vars).find((k) => /Link$/.test(k) && vars[k]);
|
||||||
const bodyVars = linkKey ? { ...vars, [linkKey]: '' } : vars;
|
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 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>` : '';
|
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;">
|
return `<!doctype html><html><body style="margin:0;padding:32px 16px;background:#f4f6fa;font-family:-apple-system,Segoe UI,Roboto,sans-serif;">
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue