diff --git a/server/routes/chatAdmin.js b/server/routes/chatAdmin.js
new file mode 100644
index 0000000..54ea328
--- /dev/null
+++ b/server/routes/chatAdmin.js
@@ -0,0 +1,208 @@
+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 Admin-Panel (/api/portal-admin, Cookie) und im Team-Eingang des
+ * Ticketsystems (/api/team, JWT); beide liefern 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' })
+ }
+})
diff --git a/server/routes/portalAdmin.js b/server/routes/portalAdmin.js
index be8519a..21531c2 100644
--- a/server/routes/portalAdmin.js
+++ b/server/routes/portalAdmin.js
@@ -6,28 +6,15 @@ import {
setPortalSession,
} from '../middleware/session.js'
import {
- createDocument,
getDocument,
listDocuments,
updateDocument,
Query,
- ID,
} from '../services/appwriteAdmin.js'
import { listAppointments, saveAppointment } from '../services/appointments.js'
import { getCustomerActivity } from '../services/customerActivity.js'
import { listInvoicesForClient } from '../services/invoiceNinja.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 { chatAdminRouter, customerNameMap } from './chatAdmin.js'
import { funktionenAdminRouter } from './admin/funktionenAdmin.js'
import { offersAdminRouter } from './offers.js'
import { storageTasksAdminRouter } from './storageTasks.js'
@@ -54,12 +41,6 @@ function isDeployed(project) {
return ['ready', 'deployed'].includes(norm(project.provisioningStatus || project.status))
}
-function customerNameMap(customers) {
- const map = {}
- for (const c of customers) map[c.$id] = c.name || c.code || c.$id
- return map
-}
-
// ---------------------------------------------------------------- Übersicht --
router.get('/overview', async (_req, res) => {
try {
@@ -306,172 +287,8 @@ router.get('/customers/:customerId/invoices', async (req, res) => {
}
})
-// ------------------------------------------------------------------- Chat --
-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(sanitizeMessage) })
- } 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: sanitizeMessage(doc), 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' })
- }
-})
+// Chat (Tab "Chat"): eigener Router, auch im Team-Eingang des Ticketsystems eingehaengt
+router.use(chatAdminRouter)
// ------------------------------------------------------- Als Kunde ansehen --
router.post('/view-as/:customerId', async (req, res) => {
diff --git a/server/routes/team/files.js b/server/routes/team/files.js
new file mode 100644
index 0000000..ac1cfcf
--- /dev/null
+++ b/server/routes/team/files.js
@@ -0,0 +1,26 @@
+import { Router } from 'express'
+import { openAttachment, sendAttachment } from '../../services/chatAttachments.js'
+import { verifyFileLink } from '../../services/signedLinks.js'
+
+/**
+ * Anhaenge mit signiertem Link (?exp=&sig=, 15 min) ohne JWT ausliefern:
,