Chat ueber den Team-Eingang: gemeinsamer Router fuer Admin-Panel und Ticketsystem, signierte Anhang-Links

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-01 23:29:05 +00:00
1 parent 5f5df7ba1a
commit 48dd19b75a
5 files changed
+346 -186

No files matched your search

+208
View File
@@ -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' })
}
})
+3 -186
View File
@@ -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) => {
+26
View File
@@ -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: <img>, <audio> und Downloads im
* Ticketsystem koennen keinen Authorization-Header tragen. Ohne sig geht es weiter zur JWT-Pruefung.
* Eingehaengt in routes/team/index.js VOR requireStaffJwt.
*/
const router = Router()
router.get('/chats/:customerId/attachments/:id', async (req, res, next) => {
if (req.query.sig === undefined) return next()
const { customerId, id } = req.params
if (!verifyFileLink({ kind: 'chat', customerId, id, exp: req.query.exp, sig: String(req.query.sig) })) {
return res.status(403).json({ error: 'Link abgelaufen. Bitte die Unterhaltung neu laden.' })
}
try {
const found = await openAttachment({ customerId, 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' })
}
})
export default router
+5
View File
@@ -1,12 +1,17 @@
import { Router } from 'express'
import { requireStaffJwt } from '../../middleware/staffJwt.js'
import { chatAdminRouter } from '../chatAdmin.js'
import customersRoutes from './customers.js'
import filesRoutes from './files.js'
import previewRoutes from './preview.js'
// Team-Eingang fuer das Ticketsystem: alle Mitarbeiter, Appwrite-JWT (Spec 2026-10-01 Admin-Panel ins Ticketsystem)
const router = Router()
// Anhaenge mit signiertem Link brauchen kein JWT (siehe files.js)
router.use(filesRoutes)
router.use(requireStaffJwt)
router.use('/customers', customersRoutes)
router.use('/preview-link', previewRoutes)
router.use(chatAdminRouter)
export default router
+104
View File
@@ -0,0 +1,104 @@
import { test, after } from 'node:test'
import assert from 'node:assert/strict'
import os from 'node:os'
import path from 'node:path'
import { mkdtempSync } from 'node:fs'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { useTestEnv } from './support/testEnv.js'
import { mailSink } from './support/mailSink.js'
// Chat ueber den Team-Eingang (/api/team, JWT) mit signierten Anhang-Links; Admin-Panel (/api/portal-admin) unveraendert
process.env.CHAT_UPLOAD_DIR = mkdtempSync(path.join(os.tmpdir(), 'team-chat-'))
const aw = await startAppwriteMock()
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { startTestApp, rawGet, post } = await import('./support/testApp.js')
const { seedFixtures, adminCookie } = await import('./support/fixtures.js')
const { setMailTransportForTests } = await import('../server/services/mailer.js')
const { default: teamRoutes } = await import('../server/routes/team/index.js')
const { default: portalAdminRoutes } = await import('../server/routes/portalAdmin.js')
const app = await startTestApp({ '/api/team': teamRoutes, '/api/portal-admin': portalAdminRoutes })
const sink = mailSink()
setMailTransportForTests(sink)
after(() => Promise.all([app.close(), aw.close(), st.close()]))
seedFixtures(aw)
aw.seed('employees', { $id: 'e_staff2', userId: 'u_staff2', displayName: 'Andrej', email: 'andrej@webklar.com' })
aw.seed('portalMessages', { $id: 'm1', customerId: 'c1', sender: 'customer', senderName: 'Kunde Eins', text: 'Frage 1', readByAdmin: false, readByCustomer: true, createdAt: '2026-10-01T08:00:00.000Z' })
aw.seed('portalMessages', { $id: 'm2', customerId: 'c1', sender: 'customer', senderName: 'Kunde Eins', text: 'Frage 2', readByAdmin: false, readByCustomer: true, createdAt: '2026-10-01T08:01:00.000Z' })
aw.seed('portalMessages', { $id: 'm3', customerId: 'c2', sender: 'admin', senderName: 'Kenso', text: 'Hallo c2', readByAdmin: true, readByCustomer: false, createdAt: '2026-10-01T09:00:00.000Z' })
const staff = { headers: { authorization: 'Bearer jwt-staff' } }
const upload = (customerId, body, headers = {}) =>
fetch(`${app.url}/api/team/chats/${customerId}/attachments`, {
method: 'POST',
headers: { authorization: 'Bearer jwt-staff', 'content-type': 'application/octet-stream', 'x-file-name': 'notiz.txt', ...headers },
body,
}).then(async (res) => ({ status: res.status, body: await res.json() }))
const URL_RE = /^https:\/\/project\.webklar\.com\/api\/team\/chats\/c1\/attachments\/([^?]+)\?exp=(\d+)&sig=([0-9a-f]{64})$/
test('Liste und ungelesene Zahl ueber den Team-Eingang', async () => {
const list = await rawGet('/api/team/chats', staff)
assert.equal(list.status, 200)
const c1 = list.body.chats.find((c) => c.customerId === 'c1')
assert.equal(c1.unreadFromCustomer, 2)
assert.equal(c1.customerName, 'Kunde Eins')
assert.equal((await rawGet('/api/team/chats/unread-count', staff)).body.unread, 2)
})
test('Senden als Mitarbeiter: Absendername aus dem JWT, K17-Mail an den Kunden', async () => {
const res = await post('/api/team/chats/c1/messages', { text: 'Hallo' }, staff)
assert.equal(res.status, 201)
assert.equal(res.body.message.senderName, 'Andrej')
assert.equal(res.body.message.sender, 'admin')
assert.equal(res.body.notice.sent, true)
assert.equal(sink.sent.at(-1).to[0].address, 'k@example.invalid')
})
let fileUrl = null
test('Anhang hochladen, mit Nachricht senden -> signierte url', async () => {
const up = await upload('c1', Buffer.from('hallo welt'))
assert.equal(up.status, 201)
const sent = await post('/api/team/chats/c1/messages', { text: '', attachmentIds: [up.body.attachment.id] }, staff)
assert.equal(sent.status, 201)
fileUrl = sent.body.message.attachments[0].url
assert.match(fileUrl, URL_RE)
assert.equal(URL_RE.exec(fileUrl)[1], up.body.attachment.id)
const listed = await rawGet('/api/team/chats/c1/messages', staff)
assert.match(listed.body.messages.at(-1).attachments[0].url, URL_RE)
})
test('signierter Link: ohne JWT 200, manipuliert/fremder Kunde/abgelaufen 403, ohne sig 401, JWT ohne sig 200', async (t) => {
const u = new URL(fileUrl)
const rel = u.pathname + u.search
const ok = await fetch(app.url + rel)
assert.equal(ok.status, 200)
assert.equal(await ok.text(), 'hallo welt')
const tampered = rel.replace(/sig=([0-9a-f])/, (m, c) => `sig=${c === 'a' ? 'b' : 'a'}`)
assert.equal((await rawGet(tampered)).status, 403)
assert.equal((await rawGet(rel.replace('/chats/c1/', '/chats/c2/'))).status, 403)
assert.equal((await rawGet(u.pathname)).status, 401)
assert.equal((await rawGet(u.pathname, staff)).status, 200)
const now = Date.now()
t.mock.method(Date, 'now', () => now + 16 * 60 * 1000)
const expired = await rawGet(rel)
assert.equal(expired.status, 403)
assert.equal(expired.body.error, 'Link abgelaufen. Bitte die Unterhaltung neu laden.')
})
test('Lesen markiert Kundennachrichten', async () => {
const res = await post('/api/team/chats/c1/read', {}, staff)
assert.equal(res.body.updated, 2)
assert.equal((await rawGet('/api/team/chats/unread-count', staff)).body.unread, 0)
})
test('Admin-Panel unveraendert: /api/portal-admin/chats mit Admin-Cookie 200, ohne 401', async () => {
assert.equal((await rawGet('/api/portal-admin/chats', { cookie: adminCookie() })).status, 200)
assert.equal((await rawGet('/api/portal-admin/chats')).status, 401)
})
test('Team-Chat ohne JWT -> 401', async () => {
assert.equal((await rawGet('/api/team/chats')).status, 401)
assert.equal((await post('/api/team/chats/c1/messages', { text: 'x' })).status, 401)
})