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:
1 parent
5f5df7ba1a
commit
48dd19b75a
5 files changed
+346
-186
No files matched your search
@@ -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' })
|
||||
}
|
||||
})
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in new issue
Block a user