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

@@ -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 }),
}

View File

@@ -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 || '',
})
}

View File

@@ -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,

View File

@@ -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) {

View File

@@ -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
View 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 }
}
}

View 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')
})
}

View 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
}