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:
@@ -46,6 +46,24 @@ export const config = {
|
||||
workorders: process.env.APPWRITE_COLLECTION_WORKORDERS || 'workorders',
|
||||
worksheets: process.env.APPWRITE_COLLECTION_WORKSHEETS || 'worksheets',
|
||||
employees: process.env.APPWRITE_COLLECTION_EMPLOYEES || 'employees',
|
||||
portalMessages: process.env.APPWRITE_COLLECTION_PORTAL_MESSAGES || 'portalMessages',
|
||||
},
|
||||
|
||||
smtp: {
|
||||
host: process.env.SMTP_HOST || '',
|
||||
port: Number(process.env.SMTP_PORT) || 587,
|
||||
secure: (process.env.SMTP_SECURE || 'false') === 'true',
|
||||
user: process.env.SMTP_USERNAME || '',
|
||||
pass: process.env.SMTP_PASSWORD || '',
|
||||
from: process.env.SMTP_FROM || process.env.SMTP_USERNAME || '',
|
||||
},
|
||||
|
||||
chatNotify: {
|
||||
emails: (process.env.CHAT_NOTIFY_EMAILS || 'kenso@webklar.com,andrej@webklar.com')
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
throttleMin: Number(process.env.CHAT_NOTIFY_THROTTLE_MIN) || 15,
|
||||
},
|
||||
|
||||
gitea: {
|
||||
|
||||
@@ -10,6 +10,7 @@ import projectsRoutes from './routes/projects.js'
|
||||
import featuresRoutes from './routes/features.js'
|
||||
import customerDataRoutes from './routes/customerData.js'
|
||||
import portalAdminRoutes from './routes/portalAdmin.js'
|
||||
import chatRoutes from './routes/chat.js'
|
||||
import giteaWebhookRoutes from './routes/webhook/gitea.js'
|
||||
import adminGiteaRoutes from './routes/admin/gitea.js'
|
||||
import adminWebsiteProjectsRoutes from './routes/admin/websiteProjects.js'
|
||||
@@ -46,6 +47,7 @@ app.use('/api/auth', authRoutes)
|
||||
app.use('/api/projects', projectsRoutes)
|
||||
app.use('/api/features', featuresRoutes)
|
||||
app.use('/api', customerDataRoutes)
|
||||
app.use('/api/chat', chatRoutes)
|
||||
app.use('/api/portal-admin', portalAdminRoutes)
|
||||
app.use('/api/admin/gitea', adminGiteaRoutes)
|
||||
app.use('/api/admin/website-projects', adminWebsiteProjectsRoutes)
|
||||
|
||||
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({
|
||||
|
||||
@@ -75,6 +75,7 @@ export const Query = {
|
||||
values: Array.isArray(value) ? value : [value],
|
||||
}),
|
||||
limit: (n) => ({ method: 'limit', values: [n] }),
|
||||
greaterThan: (attribute, value) => ({ method: 'greaterThan', attribute, values: [value] }),
|
||||
orderDesc: (attribute) => ({ method: 'orderDesc', attribute }),
|
||||
orderAsc: (attribute) => ({ method: 'orderAsc', attribute }),
|
||||
}
|
||||
|
||||
@@ -31,7 +31,8 @@ export async function getCustomerActivity(customerId, { ticketLimit = 25, exclud
|
||||
|
||||
const sheetsByWorkorder = {}
|
||||
for (const ws of worksheets) {
|
||||
if (ws.isComment) continue
|
||||
// Git-Push-Eintraege dem Kunden sofort zeigen, sonstige reine Kommentare nicht
|
||||
if (ws.isComment && ws.serviceType !== 'GIT') continue
|
||||
const list = (sheetsByWorkorder[ws.workorderId] ||= [])
|
||||
list.push({
|
||||
wsid: ws.wsid || '',
|
||||
@@ -41,6 +42,8 @@ export async function getCustomerActivity(customerId, { ticketLimit = 25, exclud
|
||||
totalTime: ws.totalTime || '',
|
||||
date: ws.endDate || ws.startDate || '',
|
||||
details: ws.details || '',
|
||||
isGit: ws.serviceType === 'GIT',
|
||||
pending: ws.serviceType === 'GIT' && !ws.startTime,
|
||||
createdAt: ws.createdAt || ws.$createdAt || '',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -8,13 +8,15 @@ import {
|
||||
} from './appwriteAdmin.js'
|
||||
|
||||
const WSID_START = 100000
|
||||
// worksheets.details ist auf 4000 Zeichen begrenzt
|
||||
const DETAILS_MAX_LENGTH = 3900
|
||||
|
||||
function formatToday() {
|
||||
// dd.mm.yyyy - gleiches Format wie CreateWorksheetModal der Ticketsystem-SPA
|
||||
const d = new Date()
|
||||
const y = d.getFullYear()
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(d.getDate()).padStart(2, '0')
|
||||
return `${y}${m}${day}`
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0')
|
||||
return `${day}.${m}.${d.getFullYear()}`
|
||||
}
|
||||
|
||||
function formatTimeNow() {
|
||||
@@ -49,10 +51,16 @@ function formatCommitLines(commits, repoFullName) {
|
||||
return commits
|
||||
.map((commit) => {
|
||||
const sha = (commit.id || commit.sha || '').slice(0, 7)
|
||||
const message = (commit.message || '').split('\n')[0]
|
||||
// Commit-Titel + Beschreibung (Body) uebernehmen
|
||||
const [title, ...bodyLines] = String(commit.message || '').trim().split('\n')
|
||||
const author = commit.author?.name || commit.committer?.name || 'Unbekannt'
|
||||
const url = commit.url || `${config.gitea.baseUrl}/${repoFullName}/commit/${commit.id || commit.sha}`
|
||||
return `- [${sha}](${url}): ${message} (${author})`
|
||||
const body = bodyLines
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => ` ${line}`)
|
||||
.join('\n')
|
||||
return `- [${sha}](${url}): ${title} (${author})${body ? `\n${body}` : ''}`
|
||||
})
|
||||
.join('\n')
|
||||
}
|
||||
@@ -85,13 +93,18 @@ export async function createGitPushWorksheet({
|
||||
const wsid = await generateNextWsid()
|
||||
const pusherName = pusher?.full_name || pusher?.username || pusher?.login || 'Gitea'
|
||||
const latestCommit = commits[commits.length - 1] || commits[0]
|
||||
const details = [
|
||||
let details = [
|
||||
`Git Push auf \`${repoFullName}\` (Branch: ${branch})`,
|
||||
`Commit: ${commitSha?.slice(0, 7) || latestCommit?.id?.slice(0, 7) || '?'}`,
|
||||
'',
|
||||
formatCommitLines(commits, repoFullName),
|
||||
].join('\n')
|
||||
if (details.length > DETAILS_MAX_LENGTH) {
|
||||
details = `${details.slice(0, DETAILS_MAX_LENGTH)}\n… (gekuerzt)`
|
||||
}
|
||||
|
||||
// Endzeit = Push-Zeitpunkt. Startzeit bleibt leer und wird im Ticketsystem
|
||||
// nachgetragen ("Zeit nachtragen") - erst dann zaehlt die Arbeitszeit.
|
||||
const today = formatToday()
|
||||
const worksheet = await createDocument(
|
||||
config.collections.worksheets,
|
||||
@@ -109,7 +122,7 @@ export async function createGitPushWorksheet({
|
||||
newResponseLevel: workorder.response || '',
|
||||
totalTime: 0,
|
||||
startDate: today,
|
||||
startTime: formatTimeNow(),
|
||||
startTime: '',
|
||||
endDate: today,
|
||||
endTime: formatTimeNow(),
|
||||
details,
|
||||
|
||||
@@ -300,6 +300,94 @@ export async function createProjectFromTemplate({
|
||||
}
|
||||
}
|
||||
|
||||
/** Repo-Basisdaten (Groesse in KB, Default-Branch). */
|
||||
export async function getRepoInfo(repoFullName) {
|
||||
const [owner, repo] = String(repoFullName || '').split('/')
|
||||
if (!owner || !repo) return null
|
||||
const data = await giteaFetch(`/repos/${owner}/${repo}`)
|
||||
return {
|
||||
sizeKb: Number(data.size || 0),
|
||||
defaultBranch: data.default_branch || 'main',
|
||||
updatedAt: data.updated_at || '',
|
||||
htmlUrl: data.html_url || `${config.gitea.baseUrl}/${repoFullName}`,
|
||||
}
|
||||
}
|
||||
|
||||
/** Letzte Commits eines Repos (volle Message inkl. Body). */
|
||||
export async function listRepoCommits(repoFullName, { branch = 'main', limit = 20 } = {}) {
|
||||
const [owner, repo] = String(repoFullName || '').split('/')
|
||||
if (!owner || !repo) return []
|
||||
// stat/verification/files abschalten - sonst berechnet Gitea Diff-Stats pro Commit
|
||||
const data = await giteaFetch(
|
||||
`/repos/${owner}/${repo}/commits?sha=${encodeURIComponent(branch)}&limit=${limit}&stat=false&verification=false&files=false`
|
||||
)
|
||||
return (Array.isArray(data) ? data : []).map((entry) => ({
|
||||
sha: entry.sha || '',
|
||||
shortSha: (entry.sha || '').slice(0, 7),
|
||||
message: entry.commit?.message || '',
|
||||
authorName:
|
||||
entry.commit?.author?.name || entry.author?.login || entry.committer?.login || 'Unbekannt',
|
||||
date: entry.commit?.author?.date || entry.created || '',
|
||||
url: entry.html_url || `${config.gitea.baseUrl}/${repoFullName}/commit/${entry.sha}`,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Datei-Uebersicht: aggregiert den Git-Baum serverseitig nach Top-Level-Eintraegen
|
||||
* (nie den rohen Baum ans Frontend geben - kann tausende Eintraege haben).
|
||||
*/
|
||||
export async function getRepoTreeSummary(repoFullName, ref = 'main') {
|
||||
const [owner, repo] = String(repoFullName || '').split('/')
|
||||
if (!owner || !repo) return null
|
||||
const data = await giteaFetch(
|
||||
`/repos/${owner}/${repo}/git/trees/${encodeURIComponent(ref)}?recursive=true&per_page=1000`
|
||||
)
|
||||
|
||||
const entries = Array.isArray(data?.tree) ? data.tree : []
|
||||
const topLevelMap = new Map()
|
||||
let totalFiles = 0
|
||||
let totalSizeBytes = 0
|
||||
|
||||
for (const entry of entries) {
|
||||
const isBlob = entry.type === 'blob'
|
||||
const size = isBlob ? Number(entry.size || 0) : 0
|
||||
const path = String(entry.path || '')
|
||||
const slash = path.indexOf('/')
|
||||
const topSegment = slash === -1 ? path : path.slice(0, slash)
|
||||
const isDir = slash !== -1 || entry.type === 'tree'
|
||||
|
||||
if (isBlob) {
|
||||
totalFiles += 1
|
||||
totalSizeBytes += size
|
||||
}
|
||||
|
||||
if (!topSegment) continue
|
||||
const existing = topLevelMap.get(topSegment) || {
|
||||
path: topSegment,
|
||||
type: isDir ? 'dir' : 'file',
|
||||
fileCount: 0,
|
||||
sizeBytes: 0,
|
||||
}
|
||||
if (isDir) existing.type = 'dir'
|
||||
if (isBlob) {
|
||||
existing.fileCount += 1
|
||||
existing.sizeBytes += size
|
||||
}
|
||||
topLevelMap.set(topSegment, existing)
|
||||
}
|
||||
|
||||
const topLevel = [...topLevelMap.values()]
|
||||
.sort((a, b) => b.sizeBytes - a.sizeBytes)
|
||||
.slice(0, 50)
|
||||
|
||||
return {
|
||||
totalFiles,
|
||||
totalSizeBytes,
|
||||
truncated: Boolean(data?.truncated),
|
||||
topLevel,
|
||||
}
|
||||
}
|
||||
|
||||
const README_CANDIDATES = ['README.md', 'readme.md', 'Readme.md', 'README.MD', 'README']
|
||||
|
||||
export async function fetchRepoReadme(repoFullName) {
|
||||
|
||||
@@ -68,6 +68,53 @@ export function sanitizeInvoice(inv) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Rohe Rechnung inkl. Invitations (fuer PDF-Download + Ownership-Pruefung). */
|
||||
export async function getInvoiceRaw(invoiceId) {
|
||||
const token = config.invoiceNinja.apiToken
|
||||
if (!token) {
|
||||
const error = new Error('INVOICE_NINJA_API_TOKEN ist nicht konfiguriert')
|
||||
error.status = 503
|
||||
throw error
|
||||
}
|
||||
const response = await fetch(
|
||||
`${config.invoiceNinja.url}/api/v1/invoices/${encodeURIComponent(invoiceId)}?include=invitations`,
|
||||
{
|
||||
headers: {
|
||||
'X-API-TOKEN': token,
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
}
|
||||
)
|
||||
const data = await response.json().catch(() => ({}))
|
||||
if (!response.ok) {
|
||||
const error = new Error(data.message || 'Rechnung nicht gefunden')
|
||||
error.status = response.status === 401 ? 502 : response.status
|
||||
throw error
|
||||
}
|
||||
return data.data
|
||||
}
|
||||
|
||||
/** PDF einer Rechnung ueber den Client-Portal-Invitation-Link streamen. */
|
||||
export async function fetchInvoicePdf(invitationKey) {
|
||||
const token = config.invoiceNinja.apiToken
|
||||
const response = await fetch(
|
||||
`${config.invoiceNinja.url}/client/invoice/${encodeURIComponent(invitationKey)}/download_pdf`,
|
||||
{
|
||||
headers: {
|
||||
'X-Api-Token': token,
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
}
|
||||
)
|
||||
if (!response.ok) {
|
||||
const error = new Error('PDF-Download fehlgeschlagen')
|
||||
error.status = response.status
|
||||
throw error
|
||||
}
|
||||
return Buffer.from(await response.arrayBuffer())
|
||||
}
|
||||
|
||||
/** Rechnungen eines InvoiceNinja-Clients (fuer Kundenportal + Admin-Ansicht). */
|
||||
export async function listInvoicesForClient(clientId, { includeDrafts = false } = {}) {
|
||||
const token = config.invoiceNinja.apiToken
|
||||
|
||||
56
server/services/mailer.js
Normal file
56
server/services/mailer.js
Normal file
@@ -0,0 +1,56 @@
|
||||
import nodemailer from 'nodemailer'
|
||||
import { config } from '../config.js'
|
||||
|
||||
let transporter = null
|
||||
|
||||
function getTransporter() {
|
||||
if (!config.smtp.host || !config.smtp.user) return null
|
||||
if (!transporter) {
|
||||
transporter = nodemailer.createTransport({
|
||||
host: config.smtp.host,
|
||||
port: config.smtp.port,
|
||||
secure: config.smtp.secure,
|
||||
auth: { user: config.smtp.user, pass: config.smtp.pass },
|
||||
})
|
||||
}
|
||||
return transporter
|
||||
}
|
||||
|
||||
// Throttle: pro Kunde maximal eine Benachrichtigung je Zeitfenster,
|
||||
// damit ein aktiver Chat nicht pro Nachricht eine Mail ausloest.
|
||||
const lastNotifyByCustomer = new Map() // customerId -> timestamp
|
||||
|
||||
/**
|
||||
* Benachrichtigt die Admins per E-Mail ueber eine neue Portal-Chatnachricht.
|
||||
* Fehler werden nur geloggt - der Chat darf am Mailversand nie scheitern.
|
||||
*/
|
||||
export async function sendChatNotification(customer, text) {
|
||||
try {
|
||||
const mailer = getTransporter()
|
||||
if (!mailer || !config.chatNotify.emails.length) return { sent: false, reason: 'nicht konfiguriert' }
|
||||
|
||||
const now = Date.now()
|
||||
const last = lastNotifyByCustomer.get(customer.$id) || 0
|
||||
const throttleMs = config.chatNotify.throttleMin * 60 * 1000
|
||||
if (now - last < throttleMs) return { sent: false, reason: 'throttled' }
|
||||
lastNotifyByCustomer.set(customer.$id, now)
|
||||
|
||||
const name = customer.name || customer.email || 'Kunde'
|
||||
await mailer.sendMail({
|
||||
from: `"Webklar Kundenportal" <${config.smtp.from}>`,
|
||||
to: config.chatNotify.emails.join(', '),
|
||||
subject: `Neue Portalnachricht von ${name}`,
|
||||
text: [
|
||||
`${name} hat im Kundenportal geschrieben:`,
|
||||
'',
|
||||
text,
|
||||
'',
|
||||
'Antworten: https://project.webklar.com/admin.html (Tab Chat)',
|
||||
].join('\n'),
|
||||
})
|
||||
return { sent: true }
|
||||
} catch (err) {
|
||||
console.error('[mailer] Chat-Benachrichtigung fehlgeschlagen:', err.message)
|
||||
return { sent: false, reason: err.message }
|
||||
}
|
||||
}
|
||||
47
server/services/projectInsights.js
Normal file
47
server/services/projectInsights.js
Normal file
@@ -0,0 +1,47 @@
|
||||
import { getRepoInfo, listRepoCommits, getRepoTreeSummary } from './giteaAdmin.js'
|
||||
|
||||
// In-Memory-Cache fuer Gitea-Abfragen (Commits/Groesse/Dateibaum) pro Repo.
|
||||
// Wird vom Gitea-Webhook nach jedem Push invalidiert.
|
||||
const cache = new Map() // key: `${repoFullName}:${kind}` -> { data, expiresAt }
|
||||
const TTL_MS = 5 * 60 * 1000
|
||||
|
||||
async function cached(repoFullName, kind, loader) {
|
||||
const key = `${repoFullName}:${kind}`
|
||||
const hit = cache.get(key)
|
||||
if (hit && hit.expiresAt > Date.now()) return hit.data
|
||||
const data = await loader()
|
||||
cache.set(key, { data, expiresAt: Date.now() + TTL_MS })
|
||||
return data
|
||||
}
|
||||
|
||||
export function invalidateProjectCache(repoFullName) {
|
||||
for (const key of cache.keys()) {
|
||||
if (key.startsWith(`${repoFullName}:`)) cache.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
/** Repo-Info + letzte Commits fuer die Projekt-Detailansicht im Kundenportal. */
|
||||
export async function getProjectGitInsights(project) {
|
||||
const repoFullName = project?.repoFullName
|
||||
if (!repoFullName) return { repo: null, commits: [] }
|
||||
|
||||
return cached(repoFullName, 'git', async () => {
|
||||
const repo = await getRepoInfo(repoFullName)
|
||||
const commits = await listRepoCommits(repoFullName, {
|
||||
branch: repo?.defaultBranch || 'main',
|
||||
limit: 20,
|
||||
})
|
||||
return { repo, commits }
|
||||
})
|
||||
}
|
||||
|
||||
/** Aggregierte Datei-Uebersicht (Top-Level + Groessensummen). */
|
||||
export async function getProjectFileOverview(project) {
|
||||
const repoFullName = project?.repoFullName
|
||||
if (!repoFullName) return null
|
||||
|
||||
return cached(repoFullName, 'files', async () => {
|
||||
const repo = await getRepoInfo(repoFullName)
|
||||
return getRepoTreeSummary(repoFullName, repo?.defaultBranch || 'main')
|
||||
})
|
||||
}
|
||||
65
server/services/ticketAdmin.js
Normal file
65
server/services/ticketAdmin.js
Normal file
@@ -0,0 +1,65 @@
|
||||
import { config } from '../config.js'
|
||||
import {
|
||||
createDocument,
|
||||
getDocument,
|
||||
listDocuments,
|
||||
updateDocument,
|
||||
Query,
|
||||
ID,
|
||||
} from './appwriteAdmin.js'
|
||||
|
||||
const WOID_BASE = 9999
|
||||
|
||||
async function generateNextWoid() {
|
||||
const docs = await listDocuments(config.collections.workorders, [
|
||||
Query.orderDesc('woid'),
|
||||
Query.limit(1),
|
||||
])
|
||||
const highest = docs[0]?.woid ? parseInt(docs[0].woid, 10) : WOID_BASE
|
||||
return String(Math.max(WOID_BASE, highest) + 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stellt sicher, dass ein Website-Projekt ein Ticket hat, damit Git-Pushes
|
||||
* als Worksheets (WSIDs) getrackt werden koennen. Legt bei Bedarf automatisch
|
||||
* ein 'Webpage'-Ticket fuer den Kunden des Projekts an und verknuepft es.
|
||||
* Projekte ohne Kunde bekommen kein automatisches Ticket.
|
||||
*/
|
||||
export async function ensureProjectTicket(project, { repoFullName } = {}) {
|
||||
if (!project) return null
|
||||
if (project.ticketId) return project.ticketId
|
||||
if (!project.customerId) return null
|
||||
|
||||
const customer = await getDocument(config.collections.customers, project.customerId).catch(() => null)
|
||||
if (!customer) return null
|
||||
|
||||
const woid = await generateNextWoid()
|
||||
const now = new Date().toISOString()
|
||||
|
||||
const ticket = await createDocument(
|
||||
config.collections.workorders,
|
||||
{
|
||||
topic: `Website ${project.projectName || repoFullName || project.subdomain || ''}`.trim(),
|
||||
type: 'Webpage',
|
||||
status: 'Open',
|
||||
priority: 1,
|
||||
serviceType: 'Remote',
|
||||
systemType: 'n/a',
|
||||
customerId: project.customerId,
|
||||
customerName: customer.name || '',
|
||||
customerLocation: customer.location || '',
|
||||
woid,
|
||||
details: `Automatisch angelegt fuer das Repository ${repoFullName || project.repoFullName || ''} (erster Git-Push).`,
|
||||
createdAt: now,
|
||||
},
|
||||
ID.unique()
|
||||
)
|
||||
|
||||
await updateDocument(config.collections.websiteProjects, project.$id, {
|
||||
ticketId: ticket.$id,
|
||||
updatedAt: now,
|
||||
})
|
||||
|
||||
console.log(`[ticketAdmin] Webpage-Ticket #${woid} fuer Projekt ${project.$id} angelegt`)
|
||||
return ticket.$id
|
||||
}
|
||||
Reference in New Issue
Block a user