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>
This commit is contained in:
2026-07-11 18:01:24 +00:00
parent 177ee6a13d
commit d4bd510dcd
23 changed files with 1510 additions and 100 deletions

View File

@@ -6,13 +6,16 @@ import {
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()
@@ -222,6 +225,120 @@ 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 || '',
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 {