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 Team-Eingang des Ticketsystems (/api/team, JWT); requireStaffJwt * liefert 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' }) } })