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. ͏ ͏ ͏
+
+
+
+
+ | WEBklar |
|
+Kundenportal |
+
|
+
+
+ |
+| Du hast eine neue Nachricht |
+Hallo {{kunde_name}}, {{absender}} hat Dir im WEBklar Kundenportal geschrieben: |
+ |
+ |
+| Bitte antworte im Chat des Kundenportals, dort sieht das ganze Team Deine Nachricht. |
+Viele Grüße Dein WEBklar-Team |
+
+ |
+| Kundenportal |
+WEBklar GbR · Am Schwimmbad 10 · 67722 Winnweiler Telefon 0176 23726355 · support@webklar.com Vertreten durch die Gesellschafter Kenso Grimm und Justin Klein |
+
+ |
+
+
+
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',