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>
This commit is contained in:
1 parent
6e18a70a5d
commit
35d98da977
15 files changed
+2086
-107
No files matched your search
+110
-11
@@ -1,4 +1,4 @@
|
||||
import { Router } from 'express'
|
||||
import express, { Router } from 'express'
|
||||
import { config } from '../config.js'
|
||||
import { getSessionCustomerId, requireSession } from '../middleware/session.js'
|
||||
import {
|
||||
@@ -9,6 +9,16 @@ import {
|
||||
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()
|
||||
@@ -19,10 +29,44 @@ export function sanitizeMessage(doc) {
|
||||
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),
|
||||
@@ -53,41 +97,94 @@ router.get('/messages', requireSession, async (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
/** Nachricht des Kunden an die Administratoren. */
|
||||
/** 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 text = String(req.body?.text || '').trim()
|
||||
if (!text || text.length > 2000) {
|
||||
return res.status(400).json({ error: 'Nachricht muss zwischen 1 und 2000 Zeichen lang sein.' })
|
||||
}
|
||||
|
||||
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 || '',
|
||||
text,
|
||||
...prepared.fields,
|
||||
readByAdmin: false,
|
||||
readByCustomer: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
ID.unique()
|
||||
)
|
||||
await prepared.claim.commit(doc.$id)
|
||||
|
||||
// Admins benachrichtigen (gedrosselt, Fehler nicht durchreichen)
|
||||
// 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, text))
|
||||
.then((customer) => sendChatNotification(customer, mailText))
|
||||
.catch(() => {})
|
||||
|
||||
return res.status(201).json({ message: sanitizeMessage(doc) })
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: err.message || 'Nachricht konnte nicht gesendet werden' })
|
||||
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' })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -120,4 +217,6 @@ router.get('/unread-count', requireSession, async (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
router.use(uploadErrors)
|
||||
|
||||
export default router
|
||||
@@ -16,7 +16,16 @@ import {
|
||||
import { listAppointments, saveAppointment } from '../services/appointments.js'
|
||||
import { getCustomerActivity } from '../services/customerActivity.js'
|
||||
import { listInvoicesForClient } from '../services/invoiceNinja.js'
|
||||
import { sanitizeMessage } from './chat.js'
|
||||
import { prepareMessage, rawUpload, sanitizeMessage, uploadErrors } from './chat.js'
|
||||
import {
|
||||
attachmentSummary,
|
||||
deletePendingAttachment,
|
||||
messageAttachments,
|
||||
openAttachment,
|
||||
sendAttachment,
|
||||
storeChatUpload,
|
||||
uploadRateLimited,
|
||||
} from '../services/chatAttachments.js'
|
||||
import { offersAdminRouter } from './offers.js'
|
||||
import { takeoverAdminRouter } from './takeoverCredits.js'
|
||||
|
||||
@@ -306,7 +315,7 @@ router.get('/chats', async (_req, res) => {
|
||||
const entry = byCustomer.get(msg.customerId) || {
|
||||
customerId: msg.customerId,
|
||||
customerName: nameById[msg.customerId] || msg.customerId,
|
||||
lastText: msg.text || '',
|
||||
lastText: msg.text || attachmentSummary(messageAttachments(msg)),
|
||||
lastSender: msg.sender || '',
|
||||
lastAt: msg.createdAt || '',
|
||||
unreadFromCustomer: 0,
|
||||
@@ -355,17 +364,14 @@ router.get('/chats/:customerId/messages', async (req, res) => {
|
||||
})
|
||||
|
||||
router.post('/chats/:customerId/messages', async (req, res) => {
|
||||
const text = String(req.body?.text || '').trim()
|
||||
if (!text || text.length > 2000) {
|
||||
return res.status(400).json({ error: 'Nachricht muss zwischen 1 und 2000 Zeichen lang sein.' })
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -373,16 +379,65 @@ router.post('/chats/:customerId/messages', async (req, res) => {
|
||||
customerId: customer.$id,
|
||||
sender: 'admin',
|
||||
senderName: session.adminName || session.name || 'Webklar-Team',
|
||||
text,
|
||||
...prepared.fields,
|
||||
readByAdmin: true,
|
||||
readByCustomer: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
ID.unique()
|
||||
)
|
||||
await prepared.claim.commit(doc.$id)
|
||||
return res.status(201).json({ message: sanitizeMessage(doc) })
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: err.message || 'Nachricht konnte nicht gesendet werden' })
|
||||
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' })
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
Reference in new issue
Block a user