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);