Files
Webklar-Kundenbereich/server/routes/portalAdmin.js
Kenso Grimm d4bd510dcd feat: Kundenportal-Ausbau - Tabs, Projekt-Einblicke, Git-Zeiterfassung, Rechnungen, Chat
Kunden-Dashboard:
- Tab-Navigation: Projekte | Was wurde gemacht | Rechnungen | Chat
- Projekt-Details pro Projekt: letzte Git-Commits (Titel+Beschreibung),
  Projektgroesse, Datei-Uebersicht (Top-Level aggregiert), Ticket-Arbeiten
- Rechnungen: gestylte Liste mit Status-Pillen, Ansehen/Zahlen-Link,
  PDF-Download ueber Server-Proxy (IDOR-geschuetzt)
- Chat mit dem Webklar-Team (Polling, Ungelesen-Badge, viewAs blockiert)

Admin-Dashboard:
- Chat-Tab: Konversationsliste + Thread + Antwortfeld, Ungelesen-Badge

Backend:
- giteaAdmin: getRepoInfo, listRepoCommits, getRepoTreeSummary
- projectInsights: 5-min-Cache, Invalidierung per Gitea-Webhook
- /api/projects/:id/git|files|work mit Ownership-Check (404)
- /api/chat/* (Kunde) + /api/portal-admin/chats/* (Admin), portalMessages-Collection
- mailer: E-Mail-Benachrichtigung an Admins bei Kundennachricht (15-min-Throttle)
- gitWorksheet: dd.mm.yyyy, voller Commit-Body, startTime leer (Zeit-Nachtrag),
  Auto-Webpage-Ticket bei Push auf Projekt ohne Ticket (ensureProjectTicket)
- customerActivity: Git-Push-Eintraege fuer Kunden sichtbar

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 18:01:24 +00:00

379 lines
13 KiB
JavaScript

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