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:
123
server/routes/chat.js
Normal file
123
server/routes/chat.js
Normal file
@@ -0,0 +1,123 @@
|
||||
import { Router } from 'express'
|
||||
import { config } from '../config.js'
|
||||
import { getSessionCustomerId, requireSession } from '../middleware/session.js'
|
||||
import {
|
||||
createDocument,
|
||||
getDocument,
|
||||
listDocuments,
|
||||
updateDocument,
|
||||
Query,
|
||||
ID,
|
||||
} from '../services/appwriteAdmin.js'
|
||||
import { sendChatNotification } from '../services/mailer.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
export function sanitizeMessage(doc) {
|
||||
return {
|
||||
id: doc.$id,
|
||||
sender: doc.sender || '',
|
||||
senderName: doc.senderName || '',
|
||||
text: doc.text || '',
|
||||
createdAt: doc.createdAt || doc.$createdAt || '',
|
||||
}
|
||||
}
|
||||
|
||||
async function countUnreadForCustomer(customerId) {
|
||||
const docs = await listDocuments(config.collections.portalMessages, [
|
||||
Query.equal('customerId', customerId),
|
||||
Query.equal('readByCustomer', false),
|
||||
Query.equal('sender', 'admin'),
|
||||
Query.limit(100),
|
||||
]).catch(() => [])
|
||||
return docs.length
|
||||
}
|
||||
|
||||
/** Nachrichten des eingeloggten Kunden (optional nur neue seit ?since=ISO). */
|
||||
router.get('/messages', requireSession, async (req, res) => {
|
||||
const customerId = getSessionCustomerId(req)
|
||||
try {
|
||||
const queries = [
|
||||
Query.equal('customerId', 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)
|
||||
const unread = await countUnreadForCustomer(customerId)
|
||||
return res.json({ messages: docs.map(sanitizeMessage), unread })
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: err.message || 'Nachrichten konnten nicht geladen werden' })
|
||||
}
|
||||
})
|
||||
|
||||
/** Nachricht des Kunden an die Administratoren. */
|
||||
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)
|
||||
try {
|
||||
const doc = await createDocument(
|
||||
config.collections.portalMessages,
|
||||
{
|
||||
customerId,
|
||||
sender: 'customer',
|
||||
senderName: req.session.name || '',
|
||||
text,
|
||||
readByAdmin: false,
|
||||
readByCustomer: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
ID.unique()
|
||||
)
|
||||
|
||||
// Admins benachrichtigen (gedrosselt, Fehler nicht durchreichen)
|
||||
getDocument(config.collections.customers, customerId)
|
||||
.then((customer) => sendChatNotification(customer, text))
|
||||
.catch(() => {})
|
||||
|
||||
return res.status(201).json({ message: sanitizeMessage(doc) })
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: err.message || 'Nachricht konnte nicht gesendet werden' })
|
||||
}
|
||||
})
|
||||
|
||||
/** Admin-Nachrichten als gelesen markieren. */
|
||||
router.post('/read', requireSession, async (req, res) => {
|
||||
const customerId = getSessionCustomerId(req)
|
||||
try {
|
||||
const docs = await listDocuments(config.collections.portalMessages, [
|
||||
Query.equal('customerId', customerId),
|
||||
Query.equal('readByCustomer', false),
|
||||
Query.equal('sender', 'admin'),
|
||||
Query.limit(100),
|
||||
])
|
||||
for (const doc of docs) {
|
||||
await updateDocument(config.collections.portalMessages, doc.$id, { readByCustomer: true })
|
||||
}
|
||||
return res.json({ updated: docs.length })
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: err.message || 'Fehler beim Markieren' })
|
||||
}
|
||||
})
|
||||
|
||||
/** Billiger Badge-Poll. */
|
||||
router.get('/unread-count', requireSession, async (req, res) => {
|
||||
try {
|
||||
const unread = await countUnreadForCustomer(getSessionCustomerId(req))
|
||||
return res.json({ unread })
|
||||
} catch {
|
||||
return res.json({ unread: 0 })
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -3,7 +3,7 @@ import { config } from '../config.js'
|
||||
import { getSessionCustomerId, requireSession } from '../middleware/session.js'
|
||||
import { getDocument } from '../services/appwriteAdmin.js'
|
||||
import { getCustomerActivity } from '../services/customerActivity.js'
|
||||
import { listInvoicesForClient } from '../services/invoiceNinja.js'
|
||||
import { fetchInvoicePdf, getInvoiceRaw, listInvoicesForClient } from '../services/invoiceNinja.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
@@ -27,6 +27,40 @@ router.get('/invoices', requireSession, async (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
/** Rechnungs-PDF des eingeloggten Kunden (Download ueber Server-Proxy). */
|
||||
router.get('/invoices/:invoiceId/pdf', requireSession, async (req, res) => {
|
||||
try {
|
||||
const customer = await getDocument(
|
||||
config.collections.customers,
|
||||
getSessionCustomerId(req)
|
||||
).catch(() => null)
|
||||
if (!customer?.invoiceNinjaClientId) {
|
||||
return res.status(404).json({ error: 'Rechnung nicht gefunden' })
|
||||
}
|
||||
|
||||
const invoice = await getInvoiceRaw(req.params.invoiceId).catch(() => null)
|
||||
// Ownership-Pruefung: Rechnung muss zum InvoiceNinja-Client des Kunden gehoeren
|
||||
if (!invoice || invoice.client_id !== customer.invoiceNinjaClientId) {
|
||||
return res.status(404).json({ error: 'Rechnung nicht gefunden' })
|
||||
}
|
||||
|
||||
const invitationKey = invoice.invitations?.[0]?.key
|
||||
if (!invitationKey) {
|
||||
return res.status(404).json({ error: 'Kein PDF verfuegbar' })
|
||||
}
|
||||
|
||||
const pdf = await fetchInvoicePdf(invitationKey)
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/pdf',
|
||||
'Content-Disposition': `attachment; filename="Rechnung-${invoice.number || invoice.id}.pdf"`,
|
||||
'Cache-Control': 'no-store',
|
||||
})
|
||||
return res.end(pdf)
|
||||
} catch (err) {
|
||||
return res.status(err.status || 500).json({ error: err.message || 'PDF konnte nicht geladen werden' })
|
||||
}
|
||||
})
|
||||
|
||||
/** Ticket-/Arbeitshistorie des eingeloggten Kunden ("was wurde gemacht"). */
|
||||
router.get('/activity', requireSession, async (req, res) => {
|
||||
try {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,10 +1,25 @@
|
||||
import { Router } from 'express'
|
||||
import { config } from '../config.js'
|
||||
import { listDocuments, Query } from '../services/appwriteAdmin.js'
|
||||
import { getDocument, listDocuments, Query } from '../services/appwriteAdmin.js'
|
||||
import { getSessionCustomerId, requireSession } from '../middleware/session.js'
|
||||
import { getProjectGitInsights, getProjectFileOverview } from '../services/projectInsights.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
/**
|
||||
* Laedt das Projekt und stellt sicher, dass es dem eingeloggten Kunden gehoert.
|
||||
* Fremde/unbekannte IDs antworten einheitlich mit 404 (kein Enumeration-Leak).
|
||||
*/
|
||||
async function loadOwnProject(req, res, next) {
|
||||
const customerId = getSessionCustomerId(req)
|
||||
const project = await getDocument(config.collections.websiteProjects, req.params.id).catch(() => null)
|
||||
if (!project || !customerId || project.customerId !== customerId) {
|
||||
return res.status(404).json({ error: 'Projekt nicht gefunden' })
|
||||
}
|
||||
req.project = project
|
||||
return next()
|
||||
}
|
||||
|
||||
router.get('/', requireSession, async (req, res) => {
|
||||
const customerId = getSessionCustomerId(req)
|
||||
if (!customerId) {
|
||||
@@ -36,4 +51,64 @@ router.get('/', requireSession, async (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
/** Letzte Git-Pushes + Projektgroesse. */
|
||||
router.get('/:id/git', requireSession, loadOwnProject, async (req, res) => {
|
||||
try {
|
||||
const { repo, commits } = await getProjectGitInsights(req.project)
|
||||
return res.json({ repo, commits })
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: err.message || 'Git-Daten konnten nicht geladen werden' })
|
||||
}
|
||||
})
|
||||
|
||||
/** Datei-Uebersicht (Top-Level, Groessensummen). */
|
||||
router.get('/:id/files', requireSession, loadOwnProject, async (req, res) => {
|
||||
try {
|
||||
const summary = await getProjectFileOverview(req.project)
|
||||
if (!summary) return res.json({ totalFiles: 0, totalSizeBytes: 0, truncated: false, topLevel: [] })
|
||||
return res.json(summary)
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: err.message || 'Datei-Uebersicht konnte nicht geladen werden' })
|
||||
}
|
||||
})
|
||||
|
||||
/** Ticket-Arbeiten am Projekt (inkl. Git-Push-Worksheets, "Zeit offen" markiert). */
|
||||
router.get('/:id/work', requireSession, loadOwnProject, async (req, res) => {
|
||||
try {
|
||||
const project = req.project
|
||||
if (!project.ticketId) {
|
||||
return res.json({ ticket: null, worksheets: [] })
|
||||
}
|
||||
|
||||
const ticket = await getDocument(config.collections.workorders, project.ticketId).catch(() => null)
|
||||
const worksheets = await listDocuments(config.collections.worksheets, [
|
||||
Query.equal('workorderId', project.ticketId),
|
||||
Query.orderDesc('$createdAt'),
|
||||
Query.limit(50),
|
||||
]).catch(() => [])
|
||||
|
||||
const sanitized = worksheets
|
||||
// GIT-Eintraege einschliessen, sonstige reine Kommentare ausblenden
|
||||
.filter((ws) => ws.serviceType === 'GIT' || !ws.isComment)
|
||||
.map((ws) => ({
|
||||
wsid: ws.wsid || '',
|
||||
date: ws.endDate || ws.startDate || '',
|
||||
time: ws.endTime || ws.startTime || '',
|
||||
employeeName: ws.employeeName || ws.employeeShort || '',
|
||||
serviceType: ws.serviceType || '',
|
||||
details: ws.details || '',
|
||||
totalTime: Number(ws.totalTime || 0),
|
||||
isGit: ws.serviceType === 'GIT',
|
||||
pending: ws.serviceType === 'GIT' && !ws.startTime,
|
||||
}))
|
||||
|
||||
return res.json({
|
||||
ticket: ticket ? { woid: ticket.woid || '', topic: ticket.topic || '', status: ticket.status || '' } : null,
|
||||
worksheets: sanitized,
|
||||
})
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: err.message || 'Arbeiten konnten nicht geladen werden' })
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
buildPreviewUrl,
|
||||
} from '../../services/previewDeploy.js'
|
||||
import { createGitPushWorksheet } from '../../services/gitWorksheet.js'
|
||||
import { ensureProjectTicket } from '../../services/ticketAdmin.js'
|
||||
import { invalidateProjectCache } from '../../services/projectInsights.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
@@ -93,17 +95,29 @@ router.post('/gitea', async (req, res) => {
|
||||
}
|
||||
|
||||
const project = await upsertWebsiteProjectByRepo(repoFullName, projectPayload)
|
||||
invalidateProjectCache(repoFullName)
|
||||
|
||||
let gitWorksheet = null
|
||||
if (project.ticketId && commitSha && commitSha !== '0000000000000000000000000000000000000000') {
|
||||
gitWorksheet = await createGitPushWorksheet({
|
||||
ticketId: project.ticketId,
|
||||
repoFullName,
|
||||
branch,
|
||||
commits,
|
||||
pusher,
|
||||
commitSha,
|
||||
})
|
||||
if (commitSha && commitSha !== '0000000000000000000000000000000000000000') {
|
||||
// Projekt ohne Ticket: automatisch ein Webpage-Ticket anlegen (nur mit Kunde)
|
||||
let ticketId = project.ticketId
|
||||
if (!ticketId) {
|
||||
try {
|
||||
ticketId = await ensureProjectTicket(project, { repoFullName })
|
||||
} catch (err) {
|
||||
console.error('[webhook] Auto-Ticket fehlgeschlagen:', err.message)
|
||||
}
|
||||
}
|
||||
if (ticketId) {
|
||||
gitWorksheet = await createGitPushWorksheet({
|
||||
ticketId,
|
||||
repoFullName,
|
||||
branch,
|
||||
commits,
|
||||
pusher,
|
||||
commitSha,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return res.json({
|
||||
|
||||
Reference in New Issue
Block a user