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 { 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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue