import express, { Router } from 'express' import { config } from '../config.js' import { getSessionCustomerId, requireSession } from '../middleware/session.js' import { createDocument, getDocument, listDocuments, updateDocument, Query, ID, } from '../services/appwriteAdmin.js' import { attachmentLines, claimAttachments, deletePendingAttachment, messageAttachments, openAttachment, sendAttachment, storeChatUpload, uploadRateLimited, } from '../services/chatAttachments.js' import { sendChatNotification } from '../services/mailer.js' const router = Router() export function sanitizeMessage(doc) { return { id: doc.$id, sender: doc.sender || '', senderName: doc.senderName || '', text: doc.text || '', attachments: messageAttachments(doc), createdAt: doc.createdAt || doc.$createdAt || '', } } const parseRaw = express.raw({ type: 'application/octet-stream', limit: config.chat.maxFileBytes }) // Jeder Upload liegt bis zu 25 MB im Speicher: nur config.chat.maxParallelUploads gleichzeitig einlesen, // weitere warten (ihr Body bleibt so lange ungelesen im Netzwerkpuffer) let activeUploads = 0 const waitingUploads = [] function startWaitingUploads() { while (activeUploads < config.chat.maxParallelUploads && waitingUploads.length) waitingUploads.shift()() } export function uploadSlot(_req, res, next) { let state = 'waiting' // 'close' kommt nach der Antwort oder wenn der Browser abbricht - auch waehrend des Wartens res.once('close', () => { if (state === 'active') activeUploads -= 1 state = 'closed' startWaitingUploads() }) waitingUploads.push(() => { if (state !== 'waiting') return // abgebrochen, bevor er dran war state = 'active' activeUploads += 1 next() }) startWaitingUploads() } /** Chat-Anhang als rohe Bytes (Content-Type application/octet-stream), Metadaten in X-Headern. */ export const rawUpload = [uploadSlot, parseRaw] /** Fehler beim Einlesen eines Uploads verstaendlich beantworten (auch fuer den Admin-Router). */ export function uploadErrors(err, _req, res, next) { if (err?.type === 'entity.too.large') { return res.status(413).json({ error: `Die Datei ist größer als ${config.chat.maxFileBytes / 1024 / 1024} MB.` }) } if (err?.type === 'request.aborted') return res.status(400).end() return next(err) } /** Text und Anhaenge einer neuen Nachricht pruefen und die Anhaenge reservieren. */ export async function prepareMessage(body, { customerId, uploadedBy }) { const text = String(body?.text || '').trim() const attachmentIds = Array.isArray(body?.attachmentIds) ? body.attachmentIds : [] if (text.length > 2000) { const err = new Error('Die Nachricht darf höchstens 2000 Zeichen lang sein.') err.status = 400 throw err } if (!text && !attachmentIds.length) { const err = new Error('Bitte eine Nachricht schreiben oder einen Anhang hinzufügen.') err.status = 400 throw err } const claim = await claimAttachments({ customerId, ids: attachmentIds, uploadedBy }) // Nur mit Anhang setzen: reine Textnachrichten brauchen das Attribut nicht const fields = { text } if (claim.attachments.length) fields.attachments = JSON.stringify(claim.attachments) return { text, claim, fields } } async function countUnreadForCustomer(customerId) { const docs = await listDocuments(config.collections.portalMessages, [ Query.equal('customerId', customerId), Query.equal('readByCustomer', false), Query.equal('sender', 'admin'), Query.limit(100), ]).catch(() => []) return docs.length } /** Nachrichten des eingeloggten Kunden (optional nur neue seit ?since=ISO). */ router.get('/messages', requireSession, async (req, res) => { const customerId = getSessionCustomerId(req) try { const queries = [ Query.equal('customerId', 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) const unread = await countUnreadForCustomer(customerId) return res.json({ messages: docs.map(sanitizeMessage), unread }) } catch (err) { return res.status(500).json({ error: err.message || 'Nachrichten konnten nicht geladen werden' }) } }) /** Nachricht des Kunden an die Administratoren (Text und/oder vorher hochgeladene Anhaenge). */ router.post('/messages', requireSession, async (req, res) => { if (req.session.viewAs) { return res.status(403).json({ error: 'Im Ansichtsmodus kann nicht als Kunde geschrieben werden.' }) } const customerId = getSessionCustomerId(req) let prepared = null try { prepared = await prepareMessage(req.body, { customerId, uploadedBy: 'customer' }) const doc = await createDocument( config.collections.portalMessages, { customerId, sender: 'customer', senderName: req.session.name || '', ...prepared.fields, readByAdmin: false, readByCustomer: true, createdAt: new Date().toISOString(), }, ID.unique() ) await prepared.claim.commit(doc.$id) // Admins benachrichtigen (gedrosselt, Fehler nicht durchreichen). Anhaenge nur mit Art und Name. const lines = attachmentLines(prepared.claim.attachments) const mailText = [prepared.text, lines.length ? `Anhänge:\n${lines.join('\n')}` : ''].filter(Boolean).join('\n\n') getDocument(config.collections.customers, customerId) .then((customer) => sendChatNotification(customer, mailText)) .catch(() => {}) return res.status(201).json({ message: sanitizeMessage(doc) }) } catch (err) { await prepared?.claim.release() return res.status(err.status || 500).json({ error: err.message || 'Nachricht konnte nicht gesendet werden' }) } }) /** Anhang hochladen (eine Datei je Anfrage); wird erst mit dem Absenden der Nachricht sichtbar. */ router.post( '/attachments', requireSession, (req, res, next) => { if (req.session.viewAs) { return res.status(403).json({ error: 'Im Ansichtsmodus kann nicht als Kunde gesendet werden.' }) } if (uploadRateLimited(`customer:${getSessionCustomerId(req)}`)) { return res.status(429).json({ error: 'Zu viele Anhänge in kurzer Zeit. Bitte später erneut versuchen.' }) } return next() }, rawUpload, async (req, res) => { try { const attachment = await storeChatUpload({ customerId: getSessionCustomerId(req), uploadedBy: 'customer', buffer: req.body, headers: req.headers, }) return res.status(201).json({ attachment }) } catch (err) { if ((err.status || 500) >= 500) console.error('[chat] Upload fehlgeschlagen:', err.message) return res.status(err.status || 500).json({ error: err.status ? err.message : 'Datei konnte nicht gespeichert werden' }) } } ) router.get('/attachments/:id', requireSession, async (req, res) => { try { const found = await openAttachment({ customerId: getSessionCustomerId(req), id: req.params.id, viewer: 'customer' }) return sendAttachment(req, res, found) } catch (err) { return res.status(err.status || 500).json({ error: err.status ? err.message : 'Datei nicht verfügbar' }) } }) /** Noch nicht gesendeten Anhang wieder entfernen (Kunde hat ihn aus der Nachricht genommen). */ router.delete('/attachments/:id', requireSession, async (req, res) => { if (req.session.viewAs) { return res.status(403).json({ error: 'Im Ansichtsmodus nicht möglich.' }) } try { await deletePendingAttachment({ customerId: getSessionCustomerId(req), id: req.params.id, uploadedBy: 'customer' }) return res.status(204).end() } catch (err) { return res.status(err.status || 500).json({ error: err.status ? err.message : 'Anhang konnte nicht entfernt werden' }) } }) /** Admin-Nachrichten als gelesen markieren. */ router.post('/read', requireSession, async (req, res) => { const customerId = getSessionCustomerId(req) try { const docs = await listDocuments(config.collections.portalMessages, [ Query.equal('customerId', customerId), Query.equal('readByCustomer', false), Query.equal('sender', 'admin'), Query.limit(100), ]) for (const doc of docs) { await updateDocument(config.collections.portalMessages, doc.$id, { readByCustomer: true }) } return res.json({ updated: docs.length }) } catch (err) { return res.status(500).json({ error: err.message || 'Fehler beim Markieren' }) } }) /** Billiger Badge-Poll. */ router.get('/unread-count', requireSession, async (req, res) => { try { const unread = await countUnreadForCustomer(getSessionCustomerId(req)) return res.json({ unread }) } catch { return res.json({ unread: 0 }) } }) router.use(uploadErrors) export default router