From 00ea4baf658ed0b24cb5371cf55230f0b388efe9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 19:00:33 +0000 Subject: [PATCH] Chat: Kunde bekommt Mail, wenn das Team schreibt (K17) Neue Vorlage K17 im Stil von K16 mit Nachricht, Absender und Link auf den Portal-Chat. Hoechstens eine Mail je Kunde im Zeitfenster CHAT_NOTIFY_THROTTLE_MIN (15 Min.), ohne BCC ans Team, nicht ohne E-Mail-Adresse oder Portalzugang. Das Admin-Panel zeigt unter dem Kundennamen, ob der Hinweis rausging. Co-Authored-By: Claude Opus 5.5 --- public/admin.js | 18 ++++++- server/mail-templates/K17 Neue Nachricht.html | 50 +++++++++++++++++ server/routes/portalAdmin.js | 11 +++- server/services/mailer.js | 47 ++++++++++++++-- test/chatNotice.test.js | 54 +++++++++++++++++++ test/mailTemplates.test.js | 3 +- 6 files changed, 176 insertions(+), 7 deletions(-) create mode 100644 server/mail-templates/K17 Neue Nachricht.html create mode 100644 test/chatNotice.test.js diff --git a/public/admin.js b/public/admin.js index a98c51f..97c079d 100644 --- a/public/admin.js +++ b/public/admin.js @@ -467,6 +467,18 @@ async function pollAdminThread() { } } +// Ergebnis des Mail-Hinweises an den Kunden (K17) unter dem Namen anzeigen +function showAdminChatNotice(notice) { + const el = document.getElementById('admin-chat-notice') + if (!el || !notice) return + const time = new Date().toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }) + el.textContent = notice.sent + ? `E-Mail-Hinweis an ${notice.to} gesendet (${time})` + : notice.reason === 'throttled' + ? 'Kein neuer E-Mail-Hinweis: Der Kunde wurde in den letzten 15 Minuten schon benachrichtigt.' + : `Kein E-Mail-Hinweis: ${notice.reason}` +} + async function openChatThread(customerId, customerName) { adminChat.activeCustomerId = customerId adminChat.activeCustomerName = customerName || 'Kunde' @@ -476,7 +488,8 @@ async function openChatThread(customerId, customerName) { const thread = document.getElementById('admin-chat-thread') thread.innerHTML = ` -
${escapeHtml(adminChat.activeCustomerName)}
+
${escapeHtml(adminChat.activeCustomerName)} +
` ChatComposer.trackScroll(document.getElementById('admin-chat-messages')) @@ -489,10 +502,11 @@ async function openChatThread(customerId, customerName) { placeholder: `Antwort an ${adminChat.activeCustomerName}…`, placeholderShort: 'Antwort…', send: async ({ text, attachmentIds }) => { - const { message } = await api(`${adminChatUrl(customerId)}/messages`, { + const { message, notice } = await api(`${adminChatUrl(customerId)}/messages`, { method: 'POST', body: JSON.stringify({ text, attachmentIds }), }) + if (adminChat.activeCustomerId === customerId) showAdminChatNotice(notice) return message }, onSent: (message) => { diff --git a/server/mail-templates/K17 Neue Nachricht.html b/server/mail-templates/K17 Neue Nachricht.html new file mode 100644 index 0000000..edc5b2a --- /dev/null +++ b/server/mail-templates/K17 Neue Nachricht.html @@ -0,0 +1,50 @@ + + + + + + + + +Neue Nachricht von WEBklar + + + + +{{absender}} hat Dir im Kundenportal geschrieben. ͏ ͏ ͏ + + + + + diff --git a/server/routes/portalAdmin.js b/server/routes/portalAdmin.js index 9432fe7..be8519a 100644 --- a/server/routes/portalAdmin.js +++ b/server/routes/portalAdmin.js @@ -17,7 +17,9 @@ import { listAppointments, saveAppointment } from '../services/appointments.js' import { getCustomerActivity } from '../services/customerActivity.js' import { listInvoicesForClient } from '../services/invoiceNinja.js' import { prepareMessage, rawUpload, sanitizeMessage, uploadErrors } from './chat.js' +import { sendCustomerChatNotice } from '../services/mailer.js' import { + attachmentLines, attachmentSummary, deletePendingAttachment, messageAttachments, @@ -393,7 +395,14 @@ router.post('/chats/:customerId/messages', async (req, res) => { ID.unique() ) await prepared.claim.commit(doc.$id) - return res.status(201).json({ message: sanitizeMessage(doc) }) + + // Kunden per Mail hinweisen (K17, gedrosselt); Fehler lassen die Nachricht bestehen + const lines = attachmentLines(prepared.claim.attachments) + const notice = await sendCustomerChatNotice(customer, { + sender: doc.senderName, + text: [prepared.text, lines.length ? `Anhänge:\n${lines.join('\n')}` : ''].filter(Boolean).join('\n\n'), + }) + return res.status(201).json({ message: sanitizeMessage(doc), notice }) } catch (err) { await prepared?.claim.release() return res.status(err.status || 500).json({ error: err.message || 'Nachricht konnte nicht gesendet werden' }) diff --git a/server/services/mailer.js b/server/services/mailer.js index e037280..86bfb24 100644 --- a/server/services/mailer.js +++ b/server/services/mailer.js @@ -78,6 +78,46 @@ export async function sendChatNotification(customer, text) { } } +// Gleiches Zeitfenster in Gegenrichtung: das Team schreibt, der Kunde bekommt K17 +const lastCustomerNoticeByCustomer = new Map() // customerId -> timestamp + +/** + * Benachrichtigt den Kunden per E-Mail ueber eine neue Chatnachricht vom Team (K17), ohne BCC. + * Wirft nie; das Ergebnis zeigt das Admin-Panel unter der Nachricht an. + * @returns {Promise<{ sent: boolean, reason?: string, to?: string }>} + */ +export async function sendCustomerChatNotice(customer, { sender, text }) { + const to = String(customer.email || '').trim() + if (!to) return { sent: false, reason: 'keine E-Mail-Adresse' } + if (!customer.portalAccessEnabled) return { sent: false, reason: 'kein Portalzugang' } + const now = Date.now() + const last = lastCustomerNoticeByCustomer.get(customer.$id) || 0 + if (now - last < config.chatNotify.throttleMin * 60 * 1000) return { sent: false, reason: 'throttled', to } + try { + lastCustomerNoticeByCustomer.set(customer.$id, now) + await sendCustomerMail(to, { + template: 'K17', + bccTeam: false, + values: { + kunde_name: customer.name || to, + absender: sender || 'Dein WEBklar-Team', + nachricht: text, + portal_url: `${config.publicBaseUrl}/dashboard.html#chat`, + }, + }) + return { sent: true, to } + } catch (err) { + lastCustomerNoticeByCustomer.delete(customer.$id) + console.error(`[mailer] Chat-Hinweis an Kunde ${customer.$id} fehlgeschlagen:`, err.message) + return { sent: false, reason: err.message, to } + } +} + +/** Nur fuer Tests: Zeitfenster der Kunden-Hinweise zuruecksetzen. */ +export function resetChatNoticeThrottleForTests() { + lastCustomerNoticeByCustomer.clear() +} + // --------------------------------------------------------------------------- // Wartungs-Abo: Vertrags-/Kuendigungs-/Widerrufsbestaetigungen // --------------------------------------------------------------------------- @@ -112,9 +152,10 @@ export async function sendAdminNotice(message) { * Vertragsunterlagen als PDF. * Ergebnis: nodemailer-Info (messageId, Antwort des Mailservers, abgelehnte Empfaenger). * @param {string} to - * @param {{ template: string, values?: object, blocks?: object, attachments?: object[] }} message + * `bccTeam: false` laesst die Kopie ans Team weg (K17: das Team hat die Nachricht selbst geschrieben). + * @param {{ template: string, values?: object, blocks?: object, attachments?: object[], bccTeam?: boolean }} message */ -export async function sendCustomerMail(to, { attachments = [], ...message }) { +export async function sendCustomerMail(to, { attachments = [], bccTeam = true, ...message }) { const mailer = getTransporter() if (!mailer) throw new Error('SMTP nicht konfiguriert') const mail = render(message) @@ -122,7 +163,7 @@ export async function sendCustomerMail(to, { attachments = [], ...message }) { from: `"${CUSTOMER_SENDER}" <${config.smtp.from}>`, replyTo: config.smtp.replyTo || undefined, to, - bcc: config.chatNotify.emails.join(', ') || undefined, + bcc: (bccTeam && config.chatNotify.emails.join(', ')) || undefined, subject: mail.subject, text: mail.text, html: mail.html, diff --git a/test/chatNotice.test.js b/test/chatNotice.test.js new file mode 100644 index 0000000..68475f1 --- /dev/null +++ b/test/chatNotice.test.js @@ -0,0 +1,54 @@ +import { test, before, beforeEach } from 'node:test' +import assert from 'node:assert/strict' +import { mailSink } from './support/mailSink.js' + +// K17: Hinweis an den Kunden, wenn das Team im Portal-Chat schreibt +let sendCustomerChatNotice, resetChatNoticeThrottleForTests, setMailTransportForTests +let sink + +const customer = (extra = {}) => ({ $id: 'c1', name: 'Emma Schatz', email: 'emma@example.com', portalAccessEnabled: true, ...extra }) + +before(async () => { + Object.assign(process.env, { APPWRITE_ENDPOINT: 'http://127.0.0.1:9/v1', SMTP_HOST: '', CHAT_NOTIFY_EMAILS: 'team@webklar.invalid', PORTAL_PUBLIC_URL: 'https://project.webklar.com' }) + ;({ sendCustomerChatNotice, resetChatNoticeThrottleForTests, setMailTransportForTests } = await import('../server/services/mailer.js')) +}) + +beforeEach(() => { + sink = mailSink() + setMailTransportForTests(sink) + resetChatNoticeThrottleForTests() +}) + +test('Kunde bekommt K17 mit Nachricht und Portal-Link, ohne BCC ans Team', async () => { + const result = await sendCustomerChatNotice(customer(), { sender: 'Kenso Grimm', text: 'Hallo \nZeile 2' }) + assert.deepEqual(result, { sent: true, to: 'emma@example.com' }) + assert.equal(sink.sent.length, 1) + const mail = sink.sent[0] + assert.deepEqual(mail.to.map((t) => t.address), ['emma@example.com']) + assert.ok(!mail.bcc, "keine Kopie ans Team") + assert.equal(mail.subject, 'Neue Nachricht von WEBklar') + assert.match(mail.html, /Hallo Emma Schatz,
Kenso Grimm hat Dir/) + assert.match(mail.html, /Hallo <Emma>
Zeile 2/) + assert.match(mail.html, /href="https:\/\/project\.webklar\.com\/dashboard\.html#chat"/) + assert.match(mail.text, /Hallo \nZeile 2/) +}) + +test('höchstens eine Mail je Kunde im Zeitfenster, andere Kunden unabhängig', async () => { + assert.equal((await sendCustomerChatNotice(customer(), { sender: 'A', text: 'eins' })).sent, true) + assert.deepEqual(await sendCustomerChatNotice(customer(), { sender: 'A', text: 'zwei' }), { sent: false, reason: 'throttled', to: 'emma@example.com' }) + assert.equal((await sendCustomerChatNotice(customer({ $id: 'c2', email: 'b@example.com' }), { sender: 'A', text: 'x' })).sent, true) + assert.equal(sink.sent.length, 2) +}) + +test('ohne E-Mail oder Portalzugang keine Mail', async () => { + assert.deepEqual(await sendCustomerChatNotice(customer({ email: '' }), { text: 'x' }), { sent: false, reason: 'keine E-Mail-Adresse' }) + assert.deepEqual(await sendCustomerChatNotice(customer({ portalAccessEnabled: false }), { text: 'x' }), { sent: false, reason: 'kein Portalzugang' }) + assert.equal(sink.sent.length, 0) +}) + +test('Versandfehler: Nachricht bleibt, nächster Versuch ist nicht gesperrt', async () => { + setMailTransportForTests({ sendMail: async () => { throw new Error('SMTP weg') } }) + assert.deepEqual(await sendCustomerChatNotice(customer(), { text: 'x' }), { sent: false, reason: 'SMTP weg', to: 'emma@example.com' }) + setMailTransportForTests(sink) + assert.equal((await sendCustomerChatNotice(customer(), { text: 'y' })).sent, true) +}) diff --git a/test/mailTemplates.test.js b/test/mailTemplates.test.js index 1188243..7529cd8 100644 --- a/test/mailTemplates.test.js +++ b/test/mailTemplates.test.js @@ -6,7 +6,7 @@ import { contractAttachmentFor, termsDocFor } from '../server/services/products. const DIR = new URL('../server/mail-templates/', import.meta.url) // K12, K14 und T12 (Wechsel ins Wartungs-Abo) aus Task 9, K13 und T13 (Zusatzspeicher) aus Task 8 (Fix 1); -// Endfassung der Texte in Task 12; T14 „Löschfristen“ aus Task 21 (F39); T15 Push-Zeitmail; K15, K16, T16 Funktionen (News-Pinnwand); T17 Repo zuordnen +// Endfassung der Texte in Task 12; T14 „Löschfristen“ aus Task 21 (F39); T15 Push-Zeitmail; K15, K16, T16 Funktionen (News-Pinnwand); T17 Repo zuordnen; K17 Chat-Hinweis an Kunden const EXPECTED = [ ...Array.from({ length: 11 }, (_, i) => `K${String(i + 1).padStart(2, '0')}`), 'K12', @@ -14,6 +14,7 @@ const EXPECTED = [ 'K14', 'K15', 'K16', + 'K17', ...Array.from({ length: 11 }, (_, i) => `T${String(i + 1).padStart(2, '0')}`), 'T12', 'T13',