feat(tickets): E-Mail-Benachrichtigung beim Antworten sichtbar/steuerbar

Beim Antworten auf ein Kunden-Ticket verschickte das System schon
automatisch (unsichtbar) eine Benachrichtigung an den Kunden. Jetzt
gibt es dafür eine Checkbox "Kunde per E-Mail benachrichtigen"
(Personal, Standard: an) im Antwortformular, und nach dem Absenden
eine Bestätigung ("Antwort gesendet, Kunde wurde per E-Mail
benachrichtigt."), statt dass der Versand unsichtbar im Hintergrund
passiert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Kundencenter 2026-09-28 22:50:42 +02:00
parent fe18bf0bce
commit c0289317bf
2 changed files with 10 additions and 7 deletions

View file

@ -101,7 +101,7 @@ export const ticketsModule: KcModule = {
app.post('/tickets/:id/messages', async (req) => {
const a = requireAuth(req); const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const b = z.object({ body: z.string().trim().min(1).max(10000), internalNote: z.boolean().default(false) }).parse(req.body);
const b = z.object({ body: z.string().trim().min(1).max(10000), internalNote: z.boolean().default(false), notifyEmail: z.boolean().default(true) }).parse(req.body);
const staff = can(a.principal, 'tickets.write');
const t = await one('SELECT * FROM tickets WHERE id = ?', [id]); if (!t) throw notFound();
if (!staff && !canInOrg(a.principal, t.org_id, 'tickets.create', 'tickets.write')) throw notFound();
@ -115,11 +115,12 @@ export const ticketsModule: KcModule = {
});
await audit({ actorType: 'user', actorId: a.user.id, orgId: t.org_id, action: 'ticket.message', resourceType: 'ticket', resourceId: id, correlationId: req.correlationId, ip: clientIp(req), after: { internalNote: b.internalNote } });
if (!b.internalNote && !staff) await notify('ticket.message', { number: t.number }, `ticket.message:${id}:${Date.now()}`, req.correlationId);
if (!b.internalNote && staff) {
let emailed = false;
if (!b.internalNote && staff && b.notifyEmail) {
const creator = await one('SELECT email, name FROM users WHERE id = ?', [t.created_by]);
if (creator) await mailTo(creator.email, 'ticket_message', { name: creator.name, number: t.number, subject: t.subject }, `mail:ticket.message:${id}:${Date.now()}`, req.correlationId);
if (creator) { await mailTo(creator.email, 'ticket_message', { name: creator.name, number: t.number, subject: t.subject }, `mail:ticket.message:${id}:${Date.now()}`, req.correlationId); emailed = true; }
}
return { ok: true, messageId };
return { ok: true, messageId, emailed };
});
// ---- Dateianhänge (Bilder, PDF) zu einer eigenen Nachricht --------------

View file

@ -32,13 +32,14 @@ export default function TicketDetail() {
useEffect(() => { if (write) api<StaffUser[]>('GET', '/admin/users').then((l) => setStaffUsers(l.filter((u) => !!u.staffRole))).catch(() => undefined); }, [write]);
async function send(e: FormEvent<HTMLFormElement>) {
e.preventDefault(); setErr(''); setBusy(true); const f = e.currentTarget; const fd = new FormData(f);
const body = String(fd.get('body') ?? ''); const internal = fd.get('internal') === 'on';
e.preventDefault(); setErr(''); setOk(''); setBusy(true); const f = e.currentTarget; const fd = new FormData(f);
const body = String(fd.get('body') ?? ''); const internal = fd.get('internal') === 'on'; const notifyEmail = fd.get('notifyEmail') === 'on';
const files = (fd.getAll('files') as File[]).filter((x) => x instanceof File && x.size > 0);
try {
const { messageId } = await api<{ messageId: string }>('POST', `/tickets/${id}/messages`, { body, internalNote: internal });
const { messageId, emailed } = await api<{ messageId: string; emailed: boolean }>('POST', `/tickets/${id}/messages`, { body, internalNote: internal, notifyEmail });
f.reset();
if (files.length > 0) { try { await apiUpload(`/tickets/${id}/messages/${messageId}/attachments`, files); } catch (x) { setErr(`Nachricht gesendet, aber Anhang fehlgeschlagen: ${errMsg(x)}`); } }
if (emailed) setOk('Antwort gesendet, Kunde wurde per E-Mail benachrichtigt.');
void load();
} catch (x) { setErr(errMsg(x)); } finally { setBusy(false); }
}
@ -67,6 +68,7 @@ export default function TicketDetail() {
<Field id="body" label="Antwort"><textarea id="body" name="body" required rows={4} style={{ width: '100%' }} /></Field>
<Field id="files" label="Anhänge (optional)" hint={`Bilder oder PDF, höchstens ${MAX_FILES} Dateien, je 15 MB.`}><input id="files" name="files" type="file" accept={ALLOWED} multiple /></Field>
{staff && <label className="row small" style={{ marginBottom: 12 }}><input type="checkbox" name="internal" style={{ width: 20, minHeight: 20 }} /> Nur intern (für den Kunden nicht sichtbar)</label>}
{staff && <label className="row small" style={{ marginBottom: 12 }}><input type="checkbox" name="notifyEmail" defaultChecked style={{ width: 20, minHeight: 20 }} /> Kunde per E-Mail benachrichtigen (bei internen Notizen ohne Wirkung)</label>}
<div className="row"><button className="btn primary" type="submit" disabled={busy}>Absenden</button>{!staff && canClose && <button className="btn" type="button" onClick={close}>Als erledigt schließen</button>}</div>
</form>}
</>);