Files
Webklar-Kundenbereich/server/routes/chat.js
T
ClaudeandClaude Opus 5.5 35d98da977 Chat: Bilder, Kamerafoto, Dateien und Sprachnachrichten
- Kundenbereich und Admin-Chat: Bueroklammer-Menue (Bild, Kamera, Datei),
  Mikrofon fuer Sprachnachrichten, Vorschau vor dem Senden mit
  Upload-Fortschritt, Einfuegen (Strg+V) und Hineinziehen von Dateien
- Kamera: am Handy/Tablet die Kamera-App, am Rechner die Kamera im Browser
  mit Vorschau, Kamerawechsel und "Nochmal"
- Sprachnachricht: Aufnahme mit Zeit und Pegel, vorher anhoerbar oder
  direkt senden, hoechstens 5 Minuten
- Verlauf: Bilder (einzeln bzw. Raster, Grossansicht), Player fuer
  Sprachnachrichten, Datei-Kacheln mit Download
- Server: jede Datei einzeln roh hochgeladen (max. 25 MB, 10 je Nachricht,
  max. 3 gleichzeitig), Typ aus dem Inhalt, nur erlaubte Endungen, keine
  Programme; Ablage unter upload-data/chat/<Kunde>/ ausserhalb von public/,
  Auslieferung nur mit Login; Bilder, Audio und PDF inline, alles andere
  als Download (nosniff, CSP sandbox)
- Nicht gesendete Anhaenge werden nach 24 Stunden geloescht
- Benachrichtigungs-Mail nennt nur Art und Namen der Anhaenge
- Appwrite: neues Attribut portalMessages.attachments
  (scripts/setup-chat-collection.mjs, vor dem Deploy ausfuehren)
- Datenschutzerklaerung Abschnitt 6 ergaenzt (Stand 26.09.2026), PDF neu

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 14:01:17 +00:00

223 lines
7.9 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 || '',
}
}
/** Chat-Anhang als rohe Bytes (Content-Type application/octet-stream), Metadaten in X-Headern. */
export const rawUpload = express.raw({ type: 'application/octet-stream', limit: config.chat.maxFileBytes })
/** 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