From 48dd19b75a2ff8fc175f82d6aa0f870de98f5402 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 23:29:05 +0000 Subject: [PATCH] Chat ueber den Team-Eingang: gemeinsamer Router fuer Admin-Panel und Ticketsystem, signierte Anhang-Links Co-Authored-By: Claude Opus 5.5 --- server/routes/chatAdmin.js | 208 +++++++++++++++++++++++++++++++++++ server/routes/portalAdmin.js | 189 +------------------------------ server/routes/team/files.js | 26 +++++ server/routes/team/index.js | 5 + test/teamChat.test.js | 104 ++++++++++++++++++ 5 files changed, 346 insertions(+), 186 deletions(-) create mode 100644 server/routes/chatAdmin.js create mode 100644 server/routes/team/files.js create mode 100644 test/teamChat.test.js diff --git a/server/routes/chatAdmin.js b/server/routes/chatAdmin.js new file mode 100644 index 0000000..54ea328 --- /dev/null +++ b/server/routes/chatAdmin.js @@ -0,0 +1,208 @@ +import { Router } from 'express' +import { config } from '../config.js' +import { createDocument, getDocument, listDocuments, updateDocument, Query, ID } from '../services/appwriteAdmin.js' +import { prepareMessage, rawUpload, sanitizeMessage, uploadErrors } from './chat.js' +import { sendCustomerChatNotice } from '../services/mailer.js' +import { + attachmentLines, + attachmentSummary, + deletePendingAttachment, + messageAttachments, + openAttachment, + sendAttachment, + storeChatUpload, + uploadRateLimited, +} from '../services/chatAttachments.js' +import { signFileLink } from '../services/signedLinks.js' + +/** + * Chat des Teams mit den Kunden. Eingehaengt im Admin-Panel (/api/portal-admin, Cookie) und im Team-Eingang des + * Ticketsystems (/api/team, JWT); beide liefern req.session mit appwriteUserId und Namen des Mitarbeiters. + */ +export const chatAdminRouter = Router() +const router = chatAdminRouter + +export function customerNameMap(customers) { + const map = {} + for (const c of customers) map[c.$id] = c.name || c.code || c.$id + return map +} + +// Anhaenge bekommen einen signierten Link (15 min) auf den Team-Eingang: das Ticketsystem zeigt Bilder, +// Sprachnachrichten und Downloads ohne Authorization-Header an (routes/team/files.js) +function withSignedUrls(message, customerId, now = Date.now()) { + const base = `${config.publicBaseUrl}/api/team/chats/${encodeURIComponent(customerId)}/attachments` + return { + ...message, + attachments: message.attachments.map((att) => { + const { exp, sig } = signFileLink({ kind: 'chat', customerId, id: att.id, now }) + return { ...att, url: `${base}/${encodeURIComponent(att.id)}?exp=${exp}&sig=${sig}` } + }), + } +} + +router.get('/chats', async (_req, res) => { + try { + const [messages, customers] = await Promise.all([ + listDocuments(config.collections.portalMessages, [ + Query.orderDesc('createdAt'), + Query.limit(500), + ]), + listDocuments(config.collections.customers, [Query.limit(500)]), + ]) + const nameById = customerNameMap(customers) + + const byCustomer = new Map() + for (const msg of messages) { + const entry = byCustomer.get(msg.customerId) || { + customerId: msg.customerId, + customerName: nameById[msg.customerId] || msg.customerId, + lastText: msg.text || attachmentSummary(messageAttachments(msg)), + lastSender: msg.sender || '', + lastAt: msg.createdAt || '', + unreadFromCustomer: 0, + } + if (msg.sender === 'customer' && !msg.readByAdmin) entry.unreadFromCustomer += 1 + byCustomer.set(msg.customerId, entry) + } + + const chats = [...byCustomer.values()].sort((a, b) => + String(b.lastAt).localeCompare(String(a.lastAt)) + ) + return res.json({ chats }) + } catch (err) { + return res.status(500).json({ error: err.message || 'Chats konnten nicht geladen werden' }) + } +}) + +router.get('/chats/unread-count', async (_req, res) => { + try { + const docs = await listDocuments(config.collections.portalMessages, [ + Query.equal('readByAdmin', false), + Query.equal('sender', 'customer'), + Query.limit(100), + ]) + return res.json({ unread: docs.length }) + } catch { + return res.json({ unread: 0 }) + } +}) + +router.get('/chats/:customerId/messages', async (req, res) => { + try { + const queries = [ + Query.equal('customerId', req.params.customerId), + Query.orderAsc('createdAt'), + Query.limit(200), + ] + const since = String(req.query.since || '').trim() + if (since) queries.push(Query.greaterThan('createdAt', since)) + + const docs = await listDocuments(config.collections.portalMessages, queries) + return res.json({ messages: docs.map((doc) => withSignedUrls(sanitizeMessage(doc), req.params.customerId)) }) + } catch (err) { + return res.status(500).json({ error: err.message || 'Nachrichten konnten nicht geladen werden' }) + } +}) + +router.post('/chats/:customerId/messages', async (req, res) => { + let prepared = null + try { + const customer = await getDocument(config.collections.customers, req.params.customerId).catch(() => null) + if (!customer) { + return res.status(404).json({ error: 'Kunde nicht gefunden' }) + } + + prepared = await prepareMessage(req.body, { customerId: customer.$id, uploadedBy: 'admin' }) + const session = req.session + const doc = await createDocument( + config.collections.portalMessages, + { + customerId: customer.$id, + sender: 'admin', + senderName: session.adminName || session.name || 'Webklar-Team', + ...prepared.fields, + readByAdmin: true, + readByCustomer: false, + createdAt: new Date().toISOString(), + }, + ID.unique() + ) + await prepared.claim.commit(doc.$id) + + // 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: withSignedUrls(sanitizeMessage(doc), customer.$id), notice }) + } catch (err) { + await prepared?.claim.release() + return res.status(err.status || 500).json({ error: err.message || 'Nachricht konnte nicht gesendet werden' }) + } +}) + +// Anhaenge des Teams an einen Kunden (Ablage wie beim Kunden, siehe services/chatAttachments.js) +router.post( + '/chats/:customerId/attachments', + async (req, res, next) => { + if (uploadRateLimited(`admin:${req.session.appwriteUserId}`)) { + return res.status(429).json({ error: 'Zu viele Anhänge in kurzer Zeit. Bitte später erneut versuchen.' }) + } + const customer = await getDocument(config.collections.customers, req.params.customerId).catch(() => null) + if (!customer) return res.status(404).json({ error: 'Kunde nicht gefunden' }) + return next() + }, + rawUpload, + async (req, res) => { + try { + const attachment = await storeChatUpload({ + customerId: req.params.customerId, + uploadedBy: 'admin', + buffer: req.body, + headers: req.headers, + }) + return res.status(201).json({ attachment }) + } catch (err) { + if ((err.status || 500) >= 500) console.error('[chat] Upload (Admin) fehlgeschlagen:', err.message) + return res.status(err.status || 500).json({ error: err.status ? err.message : 'Datei konnte nicht gespeichert werden' }) + } + }, + uploadErrors +) + +router.get('/chats/:customerId/attachments/:id', async (req, res) => { + try { + const found = await openAttachment({ customerId: req.params.customerId, id: req.params.id, viewer: 'admin' }) + return sendAttachment(req, res, found) + } catch (err) { + return res.status(err.status || 500).json({ error: err.status ? err.message : 'Datei nicht verfügbar' }) + } +}) + +router.delete('/chats/:customerId/attachments/:id', async (req, res) => { + try { + await deletePendingAttachment({ customerId: req.params.customerId, id: req.params.id, uploadedBy: 'admin' }) + return res.status(204).end() + } catch (err) { + return res.status(err.status || 500).json({ error: err.status ? err.message : 'Anhang konnte nicht entfernt werden' }) + } +}) + +router.post('/chats/:customerId/read', async (req, res) => { + try { + const docs = await listDocuments(config.collections.portalMessages, [ + Query.equal('customerId', req.params.customerId), + Query.equal('readByAdmin', false), + Query.equal('sender', 'customer'), + Query.limit(100), + ]) + for (const doc of docs) { + await updateDocument(config.collections.portalMessages, doc.$id, { readByAdmin: true }) + } + return res.json({ updated: docs.length }) + } catch (err) { + return res.status(500).json({ error: err.message || 'Fehler beim Markieren' }) + } +}) diff --git a/server/routes/portalAdmin.js b/server/routes/portalAdmin.js index be8519a..21531c2 100644 --- a/server/routes/portalAdmin.js +++ b/server/routes/portalAdmin.js @@ -6,28 +6,15 @@ import { setPortalSession, } from '../middleware/session.js' import { - createDocument, getDocument, listDocuments, updateDocument, Query, - ID, } from '../services/appwriteAdmin.js' 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, - openAttachment, - sendAttachment, - storeChatUpload, - uploadRateLimited, -} from '../services/chatAttachments.js' +import { chatAdminRouter, customerNameMap } from './chatAdmin.js' import { funktionenAdminRouter } from './admin/funktionenAdmin.js' import { offersAdminRouter } from './offers.js' import { storageTasksAdminRouter } from './storageTasks.js' @@ -54,12 +41,6 @@ function isDeployed(project) { return ['ready', 'deployed'].includes(norm(project.provisioningStatus || project.status)) } -function customerNameMap(customers) { - const map = {} - for (const c of customers) map[c.$id] = c.name || c.code || c.$id - return map -} - // ---------------------------------------------------------------- Übersicht -- router.get('/overview', async (_req, res) => { try { @@ -306,172 +287,8 @@ router.get('/customers/:customerId/invoices', async (req, res) => { } }) -// ------------------------------------------------------------------- Chat -- -router.get('/chats', async (_req, res) => { - try { - const [messages, customers] = await Promise.all([ - listDocuments(config.collections.portalMessages, [ - Query.orderDesc('createdAt'), - Query.limit(500), - ]), - listDocuments(config.collections.customers, [Query.limit(500)]), - ]) - const nameById = customerNameMap(customers) - - const byCustomer = new Map() - for (const msg of messages) { - const entry = byCustomer.get(msg.customerId) || { - customerId: msg.customerId, - customerName: nameById[msg.customerId] || msg.customerId, - lastText: msg.text || attachmentSummary(messageAttachments(msg)), - lastSender: msg.sender || '', - lastAt: msg.createdAt || '', - unreadFromCustomer: 0, - } - if (msg.sender === 'customer' && !msg.readByAdmin) entry.unreadFromCustomer += 1 - byCustomer.set(msg.customerId, entry) - } - - const chats = [...byCustomer.values()].sort((a, b) => - String(b.lastAt).localeCompare(String(a.lastAt)) - ) - return res.json({ chats }) - } catch (err) { - return res.status(500).json({ error: err.message || 'Chats konnten nicht geladen werden' }) - } -}) - -router.get('/chats/unread-count', async (_req, res) => { - try { - const docs = await listDocuments(config.collections.portalMessages, [ - Query.equal('readByAdmin', false), - Query.equal('sender', 'customer'), - Query.limit(100), - ]) - return res.json({ unread: docs.length }) - } catch { - return res.json({ unread: 0 }) - } -}) - -router.get('/chats/:customerId/messages', async (req, res) => { - try { - const queries = [ - Query.equal('customerId', req.params.customerId), - Query.orderAsc('createdAt'), - Query.limit(200), - ] - const since = String(req.query.since || '').trim() - if (since) queries.push(Query.greaterThan('createdAt', since)) - - const docs = await listDocuments(config.collections.portalMessages, queries) - return res.json({ messages: docs.map(sanitizeMessage) }) - } catch (err) { - return res.status(500).json({ error: err.message || 'Nachrichten konnten nicht geladen werden' }) - } -}) - -router.post('/chats/:customerId/messages', async (req, res) => { - let prepared = null - try { - const customer = await getDocument(config.collections.customers, req.params.customerId).catch(() => null) - if (!customer) { - return res.status(404).json({ error: 'Kunde nicht gefunden' }) - } - - prepared = await prepareMessage(req.body, { customerId: customer.$id, uploadedBy: 'admin' }) - const session = req.session - const doc = await createDocument( - config.collections.portalMessages, - { - customerId: customer.$id, - sender: 'admin', - senderName: session.adminName || session.name || 'Webklar-Team', - ...prepared.fields, - readByAdmin: true, - readByCustomer: false, - createdAt: new Date().toISOString(), - }, - ID.unique() - ) - await prepared.claim.commit(doc.$id) - - // 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' }) - } -}) - -// Anhaenge des Teams an einen Kunden (Ablage wie beim Kunden, siehe services/chatAttachments.js) -router.post( - '/chats/:customerId/attachments', - async (req, res, next) => { - if (uploadRateLimited(`admin:${req.session.appwriteUserId}`)) { - return res.status(429).json({ error: 'Zu viele Anhänge in kurzer Zeit. Bitte später erneut versuchen.' }) - } - const customer = await getDocument(config.collections.customers, req.params.customerId).catch(() => null) - if (!customer) return res.status(404).json({ error: 'Kunde nicht gefunden' }) - return next() - }, - rawUpload, - async (req, res) => { - try { - const attachment = await storeChatUpload({ - customerId: req.params.customerId, - uploadedBy: 'admin', - buffer: req.body, - headers: req.headers, - }) - return res.status(201).json({ attachment }) - } catch (err) { - if ((err.status || 500) >= 500) console.error('[chat] Upload (Admin) fehlgeschlagen:', err.message) - return res.status(err.status || 500).json({ error: err.status ? err.message : 'Datei konnte nicht gespeichert werden' }) - } - }, - uploadErrors -) - -router.get('/chats/:customerId/attachments/:id', async (req, res) => { - try { - const found = await openAttachment({ customerId: req.params.customerId, id: req.params.id, viewer: 'admin' }) - return sendAttachment(req, res, found) - } catch (err) { - return res.status(err.status || 500).json({ error: err.status ? err.message : 'Datei nicht verfügbar' }) - } -}) - -router.delete('/chats/:customerId/attachments/:id', async (req, res) => { - try { - await deletePendingAttachment({ customerId: req.params.customerId, id: req.params.id, uploadedBy: 'admin' }) - return res.status(204).end() - } catch (err) { - return res.status(err.status || 500).json({ error: err.status ? err.message : 'Anhang konnte nicht entfernt werden' }) - } -}) - -router.post('/chats/:customerId/read', async (req, res) => { - try { - const docs = await listDocuments(config.collections.portalMessages, [ - Query.equal('customerId', req.params.customerId), - Query.equal('readByAdmin', false), - Query.equal('sender', 'customer'), - Query.limit(100), - ]) - for (const doc of docs) { - await updateDocument(config.collections.portalMessages, doc.$id, { readByAdmin: true }) - } - return res.json({ updated: docs.length }) - } catch (err) { - return res.status(500).json({ error: err.message || 'Fehler beim Markieren' }) - } -}) +// Chat (Tab "Chat"): eigener Router, auch im Team-Eingang des Ticketsystems eingehaengt +router.use(chatAdminRouter) // ------------------------------------------------------- Als Kunde ansehen -- router.post('/view-as/:customerId', async (req, res) => { diff --git a/server/routes/team/files.js b/server/routes/team/files.js new file mode 100644 index 0000000..ac1cfcf --- /dev/null +++ b/server/routes/team/files.js @@ -0,0 +1,26 @@ +import { Router } from 'express' +import { openAttachment, sendAttachment } from '../../services/chatAttachments.js' +import { verifyFileLink } from '../../services/signedLinks.js' + +/** + * Anhaenge mit signiertem Link (?exp=&sig=, 15 min) ohne JWT ausliefern: ,