From c0289317bff31d801dc4e2966aeccdf90215b5f2 Mon Sep 17 00:00:00 2001 From: Kundencenter Date: Mon, 28 Sep 2026 22:50:42 +0200 Subject: [PATCH] feat(tickets): E-Mail-Benachrichtigung beim Antworten sichtbar/steuerbar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/api/src/modules/tickets/index.ts | 9 +++++---- apps/web/src/app/(app)/tickets/[id]/page.tsx | 8 +++++--- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/apps/api/src/modules/tickets/index.ts b/apps/api/src/modules/tickets/index.ts index e843c55..de69672 100644 --- a/apps/api/src/modules/tickets/index.ts +++ b/apps/api/src/modules/tickets/index.ts @@ -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 -------------- diff --git a/apps/web/src/app/(app)/tickets/[id]/page.tsx b/apps/web/src/app/(app)/tickets/[id]/page.tsx index c8879dd..7bae736 100644 --- a/apps/web/src/app/(app)/tickets/[id]/page.tsx +++ b/apps/web/src/app/(app)/tickets/[id]/page.tsx @@ -32,13 +32,14 @@ export default function TicketDetail() { useEffect(() => { if (write) api('GET', '/admin/users').then((l) => setStaffUsers(l.filter((u) => !!u.staffRole))).catch(() => undefined); }, [write]); async function send(e: FormEvent) { - 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() {