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 <noreply@anthropic.com>
This commit is contained in:
1 parent
37489aa866
commit
00ea4baf65
6 files changed
+176
-7
No files matched your search
+16
-2
@@ -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 = `
|
||||
<div style="margin-bottom:8px"><strong>${escapeHtml(adminChat.activeCustomerName)}</strong></div>
|
||||
<div style="margin-bottom:8px"><strong>${escapeHtml(adminChat.activeCustomerName)}</strong>
|
||||
<div class="muted" id="admin-chat-notice" style="font-size:12px"></div></div>
|
||||
<div class="chat-messages" id="admin-chat-messages"></div>
|
||||
<div id="admin-chat-composer"></div>`
|
||||
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) => {
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de" xmlns="http://www.w3.org/1999/xhtml">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<!--
|
||||
K17 · Neue Nachricht vom Team (Chat im Kundenportal)
|
||||
Betreff: Neue Nachricht von WEBklar
|
||||
Absender: „WEBklar“ <Portal-Postfach>
|
||||
Empfänger: Kunde (E-Mail des Kundenkontos) · ohne BCC
|
||||
Platzhalter: {{kunde_name}}, {{absender}}, {{nachricht}}, {{portal_url}}
|
||||
Höchstens eine Mail je Kunde alle 15 Minuten (CHAT_NOTIFY_THROTTLE_MIN).
|
||||
-->
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<meta name="supported-color-schemes" content="light dark">
|
||||
<title>Neue Nachricht von WEBklar</title>
|
||||
<!--[if mso]><style>table,td{font-family:Arial,Helvetica,sans-serif!important;}</style><![endif]-->
|
||||
<style>
|
||||
a{color:#111111;}
|
||||
@media (max-width:620px){.w{width:100%!important;}.px{padding-left:24px!important;padding-right:24px!important;}.lbl{display:block!important;width:100%!important;padding-bottom:2px!important;border-bottom:0!important;}.val{display:block!important;width:100%!important;padding-top:0!important;}}
|
||||
@media (prefers-color-scheme:dark){.bg{background:#161615!important;}.card{background:#1f1f1e!important;}.ink{color:#f2f1ee!important;}.mut{color:#a8a6a0!important;}.box{background:#2a2a28!important;border-color:#3a3a37!important;}.btn{background:#f2f1ee!important;}.btn a{color:#161615!important;}.rowline{border-color:#3a3a37!important;}}
|
||||
</style>
|
||||
</head>
|
||||
<body style="margin:0;padding:0;background:#f1f0ec;">
|
||||
<span style="display:none;max-height:0;overflow:hidden;opacity:0;mso-hide:all;font-size:1px;line-height:1px;color:#f1f0ec;">{{absender}} hat Dir im Kundenportal geschrieben. ͏ ͏ ͏</span>
|
||||
<table role="presentation" class="bg" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f1f0ec;">
|
||||
<tr><td align="center" style="padding:32px 12px;">
|
||||
<table role="presentation" class="w" width="600" cellpadding="0" cellspacing="0" border="0" style="width:600px;max-width:600px;">
|
||||
<tr><td class="px" style="padding:0 40px 20px 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr>
|
||||
<td><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td valign="middle" style="padding-right:10px;"><img src="https://project.webklar.com/webklar-logo.png" width="40" height="40" alt="WEBklar" style="display:block;width:40px;height:40px;border:0;outline:none;text-decoration:none;border-radius:10px;"></td><td valign="middle" class="ink" style="font-family:Arial,Helvetica,sans-serif;font-size:26px;font-weight:bold;letter-spacing:-1px;color:#111111;">WEBklar</td></tr></table></td>
|
||||
<td align="right" class="mut" style="font-family:Arial,Helvetica,sans-serif;font-size:12px;color:#6b6964;">Kundenportal</td>
|
||||
</tr></table></td></tr>
|
||||
<tr><td class="card" style="background:#ffffff;border-radius:16px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr><td class="px" style="padding:44px 40px 8px 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td style="background:#efe6fb;border-radius:100px;padding:6px 12px;font-family:Arial,Helvetica,sans-serif;font-size:12px;font-weight:bold;color:#5a2a91;">Neue Nachricht</td></tr></table></td></tr>
|
||||
<tr><td class="px ink" style="padding:16px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:28px;line-height:34px;mso-line-height-rule:exactly;font-weight:bold;letter-spacing:-0.5px;color:#111111;">Du hast eine neue Nachricht</td></tr>
|
||||
<tr><td class="px mut" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Hallo {{kunde_name}},<br>{{absender}} hat Dir im WEBklar Kundenportal geschrieben:</td></tr>
|
||||
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;color:#111111;">{{nachricht}}</td></tr></table></td></tr>
|
||||
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td class="btn" bgcolor="#111111" style="background:#111111;border-radius:10px;"><a href="{{portal_url}}" style="display:block;padding:14px 26px;font-family:Arial,Helvetica,sans-serif;font-size:15px;font-weight:bold;color:#ffffff;text-decoration:none;">Im Kundenportal antworten →</a></td></tr></table></td></tr>
|
||||
<tr><td class="px mut" style="padding:10px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:13px;line-height:20px;mso-line-height-rule:exactly;color:#6b6964;">Bitte antworte im Chat des Kundenportals, dort sieht das ganze Team Deine Nachricht.</td></tr>
|
||||
<tr><td class="px ink" style="padding:28px 40px 44px 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:24px;color:#111111;">Viele Grüße<br><strong>Dein WEBklar-Team</strong></td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
<tr><td class="px" align="center" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;"><a class="mut" href="https://project.webklar.com/dashboard.html" style="color:#4a4843;text-decoration:underline;">Kundenportal</a></td></tr>
|
||||
<tr><td class="px mut" align="center" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;">WEBklar GbR · Am Schwimmbad 10 · 67722 Winnweiler<br>Telefon <a class="mut" href="tel:+4917623726355" style="color:#6b6964;text-decoration:none;">0176 23726355</a> · <a class="mut" href="mailto:support@webklar.com" style="color:#6b6964;text-decoration:none;">support@webklar.com</a><br>Vertreten durch die Gesellschafter Kenso Grimm und Justin Klein</td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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' })
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 <Emma>\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,<br>Kenso Grimm hat Dir/)
|
||||
assert.match(mail.html, /Hallo <Emma><br>Zeile 2/)
|
||||
assert.match(mail.html, /href="https:\/\/project\.webklar\.com\/dashboard\.html#chat"/)
|
||||
assert.match(mail.text, /Hallo <Emma>\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)
|
||||
})
|
||||
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user