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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user