249 lines
8.7 KiB
JavaScript
249 lines
8.7 KiB
JavaScript
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
|