import { Router } from 'express' import { config } from '../config.js' import { parsePortalSession, requireAdminSession, setPortalSession, } from '../middleware/session.js' import { createDocument, getDocument, listDocuments, updateDocument, Query, ID, } from '../services/appwriteAdmin.js' import { getCustomerActivity } from '../services/customerActivity.js' import { listInvoicesForClient } from '../services/invoiceNinja.js' import { sanitizeMessage } from './chat.js' const router = Router() router.use(requireAdminSession) function norm(value) { return String(value || '').toLowerCase() } 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 { const [projects, customers, access] = await Promise.all([ listDocuments(config.collections.websiteProjects, [Query.limit(500)]), listDocuments(config.collections.customers, [Query.limit(500)]), listDocuments(config.collections.customerPortalAccess, [Query.limit(500)]), ]) const nameById = customerNameMap(customers) const stageOf = (c) => { const s = norm(c.customerStatus) if (s === 'lead') return 'lead' if (s === 'lost') return 'lost' return 'customer' } const recentLogins = access .filter((a) => a.lastLoginAt) .sort((a, b) => String(b.lastLoginAt).localeCompare(String(a.lastLoginAt))) .slice(0, 5) .map((a) => ({ customerName: nameById[a.customerId] || a.customerId, lastLoginAt: a.lastLoginAt, })) const recentProjects = [...projects] .sort((a, b) => String(b.updatedAt || b.$updatedAt || '').localeCompare(String(a.updatedAt || a.$updatedAt || '')) ) .slice(0, 5) .map((p) => ({ projectName: p.projectName || p.subdomain || p.repoFullName || '', subdomain: p.subdomain || '', status: p.status || '', customerName: p.customerId ? nameById[p.customerId] || '' : '', updatedAt: p.updatedAt || p.$updatedAt || '', })) return res.json({ projects: { total: projects.length, deployed: projects.filter(isDeployed).length, withoutWebsite: projects.filter((p) => !p.subdomain).length, assigned: projects.filter((p) => p.customerId).length, }, customers: { total: customers.length, leads: customers.filter((c) => stageOf(c) === 'lead').length, fixed: customers.filter((c) => stageOf(c) === 'customer').length, portalEnabled: customers.filter((c) => c.portalAccessEnabled).length, }, recentLogins, recentProjects, }) } catch (err) { return res.status(500).json({ error: err.message || 'Übersicht konnte nicht geladen werden' }) } }) // ------------------------------------------------------------- Projektsuche -- router.get('/projects', async (req, res) => { const search = norm(req.query.search) try { const [projects, customers] = await Promise.all([ listDocuments(config.collections.websiteProjects, [Query.limit(500)]), listDocuments(config.collections.customers, [Query.limit(500)]), ]) const nameById = customerNameMap(customers) let items = projects.map((p) => ({ id: p.$id, projectName: p.projectName || '', subdomain: p.subdomain || '', previewUrl: p.previewUrl || '', liveDomain: p.liveDomain || '', status: p.status || '', provisioningStatus: p.provisioningStatus || '', projectType: p.projectType || (p.subdomain ? 'website' : 'repo'), giteaRepoUrl: p.giteaRepoUrl || '', repoFullName: p.repoFullName || '', customerId: p.customerId || '', customerName: p.customerId ? nameById[p.customerId] || p.customerId : '', updatedAt: p.updatedAt || p.$updatedAt || '', })) if (search) { items = items.filter((p) => [p.projectName, p.subdomain, p.repoFullName, p.customerName, p.status].some((v) => norm(v).includes(search) ) ) } items.sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt))) return res.json({ projects: items }) } catch (err) { return res.status(500).json({ error: err.message || 'Projekte konnten nicht geladen werden' }) } }) // -------------------------------------------------------------- Kundenliste -- router.get('/customers', async (req, res) => { const search = norm(req.query.search) try { const [customers, access, projects] = await Promise.all([ listDocuments(config.collections.customers, [Query.orderAsc('name'), Query.limit(500)]), listDocuments(config.collections.customerPortalAccess, [Query.limit(500)]), listDocuments(config.collections.websiteProjects, [Query.limit(500)]), ]) const lastLoginByCustomer = {} for (const a of access) { if (a.customerId && a.lastLoginAt) lastLoginByCustomer[a.customerId] = a.lastLoginAt } const projectCount = {} for (const p of projects) { if (p.customerId) projectCount[p.customerId] = (projectCount[p.customerId] || 0) + 1 } let items = customers.map((c) => ({ id: c.$id, code: c.code || '', name: c.name || '', email: c.email || '', phone: c.phone || '', location: c.location || '', customerStatus: c.customerStatus || '', portalAccessEnabled: Boolean(c.portalAccessEnabled), invoiceNinjaLinked: Boolean(c.invoiceNinjaClientId), lastLoginAt: lastLoginByCustomer[c.$id] || '', projectCount: projectCount[c.$id] || 0, })) if (search) { items = items.filter((c) => [c.code, c.name, c.email, c.location].some((v) => norm(v).includes(search)) ) } return res.json({ customers: items }) } catch (err) { return res.status(500).json({ error: err.message || 'Kunden konnten nicht geladen werden' }) } }) // ------------------------------------------- Kundennummer/Stammdaten pflegen -- router.patch('/customers/:customerId', async (req, res) => { const { code, location, phone } = req.body || {} const data = { updatedAt: new Date().toISOString() } if (code !== undefined) data.code = String(code).trim() if (location !== undefined) data.location = String(location).trim() if (phone !== undefined) data.phone = String(phone).trim() try { const updated = await updateDocument(config.collections.customers, req.params.customerId, data) return res.json({ success: true, customer: { id: updated.$id, code: updated.code || '', name: updated.name || '' }, }) } catch (err) { const status = err.status || 500 return res.status(status).json({ error: err.message || 'Kunde konnte nicht aktualisiert werden' }) } }) // -------------------------------------- Aktivität + Rechnungen eines Kunden -- router.get('/customers/:customerId/activity', async (req, res) => { try { const tickets = await getCustomerActivity(req.params.customerId, { ticketLimit: 50 }) return res.json({ tickets }) } catch (err) { return res.status(500).json({ error: err.message || 'Aktivität konnte nicht geladen werden' }) } }) router.get('/customers/:customerId/invoices', async (req, res) => { try { const customer = await getDocument(config.collections.customers, req.params.customerId) if (!customer.invoiceNinjaClientId) { return res.json({ linked: false, invoices: [] }) } const invoices = await listInvoicesForClient(customer.invoiceNinjaClientId, { includeDrafts: true }) return res.json({ linked: true, invoices }) } catch (err) { const status = err.status || 500 return res.status(status).json({ error: err.message || 'Rechnungen konnten nicht geladen werden' }) } }) // ------------------------------------------------------------------- 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 || '', 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) => { 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.' }) } try { const customer = await getDocument(config.collections.customers, req.params.customerId).catch(() => null) if (!customer) { return res.status(404).json({ error: 'Kunde nicht gefunden' }) } const session = req.session const doc = await createDocument( config.collections.portalMessages, { customerId: customer.$id, sender: 'admin', senderName: session.adminName || session.name || 'Webklar-Team', text, readByAdmin: true, readByCustomer: false, createdAt: new Date().toISOString(), }, ID.unique() ) return res.status(201).json({ message: sanitizeMessage(doc) }) } catch (err) { return res.status(500).json({ error: err.message || 'Nachricht konnte nicht gesendet 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' }) } }) // ------------------------------------------------------- Als Kunde ansehen -- router.post('/view-as/:customerId', async (req, res) => { try { const customer = await getDocument(config.collections.customers, req.params.customerId).catch(() => null) if (!customer) { return res.status(404).json({ error: 'Kunde nicht gefunden' }) } const session = req.session setPortalSession(res, { role: 'admin', viewAs: true, appwriteUserId: session.appwriteUserId, adminName: session.adminName || session.name || 'Admin', adminEmail: session.adminEmail || session.email || '', customerId: customer.$id, name: customer.name || '', email: customer.email || '', }) return res.json({ success: true, customer: { id: customer.$id, name: customer.name || '' } }) } catch (err) { return res.status(500).json({ error: err.message || 'Ansicht konnte nicht gewechselt werden' }) } }) router.post('/exit-view-as', (req, res) => { const session = parsePortalSession(req) || req.session setPortalSession(res, { role: 'admin', appwriteUserId: session.appwriteUserId, name: session.adminName || session.name || 'Admin', email: session.adminEmail || session.email || '', }) return res.json({ success: true }) }) export default router