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>
423 lines
13 KiB
JavaScript
423 lines
13 KiB
JavaScript
import { config } from '../config.js'
|
||
import { upsertWebsiteProjectByRepo, listDocuments, Query } from './appwriteAdmin.js'
|
||
import {
|
||
runDeploy,
|
||
buildPreviewUrl,
|
||
} from './previewDeploy.js'
|
||
|
||
function giteaHeaders() {
|
||
if (!config.gitea.apiToken) {
|
||
throw new Error('GITEA_API_TOKEN fehlt in .env')
|
||
}
|
||
return {
|
||
Authorization: `token ${config.gitea.apiToken}`,
|
||
'Content-Type': 'application/json',
|
||
}
|
||
}
|
||
|
||
async function giteaFetch(path, options = {}) {
|
||
const response = await fetch(`${config.gitea.baseUrl}/api/v1${path}`, {
|
||
...options,
|
||
headers: { ...giteaHeaders(), ...options.headers },
|
||
})
|
||
|
||
const text = await response.text()
|
||
let data = null
|
||
if (text) {
|
||
try {
|
||
data = JSON.parse(text)
|
||
} catch {
|
||
data = { message: text }
|
||
}
|
||
}
|
||
|
||
if (!response.ok) {
|
||
let message = data?.message || `Gitea ${response.status}`
|
||
if (response.status === 409 && /same name already exists/i.test(message)) {
|
||
message = 'Ein Gitea-Repository mit diesem Namen existiert bereits. Bitte einen anderen Subdomain-/Repo-Namen w<>hlen.'
|
||
}
|
||
const error = new Error(message)
|
||
error.status = response.status
|
||
throw error
|
||
}
|
||
|
||
return data
|
||
}
|
||
|
||
function slugify(value) {
|
||
return String(value || '')
|
||
.toLowerCase()
|
||
.replace(/[^a-z0-9-]+/g, '-')
|
||
.replace(/^-+|-+$/g, '')
|
||
.slice(0, 100)
|
||
}
|
||
|
||
export async function listAllAccessibleRepos() {
|
||
const repos = []
|
||
const limit = 50
|
||
let page = 1
|
||
|
||
while (true) {
|
||
const batch = await giteaFetch(`/repos/search?limit=${limit}&page=${page}`)
|
||
const data = Array.isArray(batch?.data) ? batch.data : Array.isArray(batch) ? batch : []
|
||
if (data.length === 0) break
|
||
repos.push(...data)
|
||
if (data.length < limit) break
|
||
page += 1
|
||
}
|
||
|
||
return repos
|
||
}
|
||
|
||
async function resolveSubdomainForSync(repo, existingDoc) {
|
||
if (existingDoc?.subdomain) return existingDoc.subdomain
|
||
|
||
const candidate = slugify(repo.name) || slugify(repo.full_name?.split('/').pop())
|
||
if (!candidate) return ''
|
||
|
||
const conflict = await listDocuments(config.collections.websiteProjects, [
|
||
Query.equal('subdomain', candidate),
|
||
Query.limit(1),
|
||
])
|
||
|
||
if (!conflict[0] || conflict[0].repoFullName === repo.full_name) {
|
||
return candidate
|
||
}
|
||
|
||
const ownerSlug = slugify(repo.full_name?.replace('/', '-'))
|
||
if (!ownerSlug) return ''
|
||
|
||
const ownerConflict = await listDocuments(config.collections.websiteProjects, [
|
||
Query.equal('subdomain', ownerSlug),
|
||
Query.limit(1),
|
||
])
|
||
|
||
if (!ownerConflict[0] || ownerConflict[0].repoFullName === repo.full_name) {
|
||
return ownerSlug
|
||
}
|
||
|
||
return ''
|
||
}
|
||
|
||
export async function syncReposToAppwrite() {
|
||
const repos = await listAllAccessibleRepos()
|
||
let created = 0
|
||
let updated = 0
|
||
|
||
for (const repo of repos) {
|
||
const repoFullName = repo.full_name
|
||
if (!repoFullName) continue
|
||
|
||
const existing = await listDocuments(config.collections.websiteProjects, [
|
||
Query.equal('repoFullName', repoFullName),
|
||
Query.limit(1),
|
||
])
|
||
|
||
const branch = repo.default_branch || 'main'
|
||
const subdomain = await resolveSubdomainForSync(repo, existing[0])
|
||
const previewUrl = subdomain ? buildPreviewUrl(subdomain) : ''
|
||
|
||
const payload = {
|
||
projectName: repo.description || repo.name || repoFullName.split('/').pop(),
|
||
giteaRepoUrl: repo.html_url || `${config.gitea.baseUrl}/${repoFullName}`,
|
||
giteaRepoName: repo.name || repoFullName.split('/').pop(),
|
||
repoFullName,
|
||
}
|
||
|
||
if (subdomain) payload.subdomain = subdomain
|
||
if (previewUrl) payload.previewUrl = previewUrl
|
||
|
||
if (!existing[0]) {
|
||
payload.status = 'synced'
|
||
created += 1
|
||
} else {
|
||
updated += 1
|
||
}
|
||
|
||
await upsertWebsiteProjectByRepo(repoFullName, payload)
|
||
}
|
||
|
||
return { synced: repos.length, created, updated }
|
||
}
|
||
|
||
export async function ensureSubdomainAvailable(subdomain) {
|
||
const docs = await listDocuments(config.collections.websiteProjects, [
|
||
Query.equal('subdomain', subdomain),
|
||
Query.limit(1),
|
||
])
|
||
if (docs[0]) {
|
||
const error = new Error(`Subdomain "${subdomain}" ist bereits vergeben`)
|
||
error.status = 409
|
||
throw error
|
||
}
|
||
}
|
||
|
||
export async function generateRepoFromTemplate({ owner, repoName }) {
|
||
const templateOwner = config.gitea.templateOwner
|
||
const templateRepo = config.gitea.templateRepo
|
||
const targetOwner = owner || config.gitea.defaultOwner
|
||
|
||
if (!targetOwner) {
|
||
throw new Error('GITEA_DEFAULT_OWNER fehlt in .env')
|
||
}
|
||
|
||
return giteaFetch(`/repos/${templateOwner}/${templateRepo}/generate`, {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
owner: targetOwner,
|
||
name: repoName,
|
||
private: false,
|
||
description: `Website-Projekt ${repoName}`,
|
||
git_content: true,
|
||
}),
|
||
})
|
||
}
|
||
|
||
async function waitForRepoBranch(owner, repo, preferredBranch = 'main', maxAttempts = 15, delayMs = 400) {
|
||
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
||
try {
|
||
const repoInfo = await giteaFetch(`/repos/${owner}/${repo}`)
|
||
const branches = await giteaFetch(`/repos/${owner}/${repo}/branches?limit=50`)
|
||
const branchNames = Array.isArray(branches) ? branches.map((entry) => entry.name) : []
|
||
|
||
if (branchNames.includes(preferredBranch)) return preferredBranch
|
||
if (repoInfo?.default_branch && branchNames.includes(repoInfo.default_branch)) {
|
||
return repoInfo.default_branch
|
||
}
|
||
if (branchNames.length > 0) return branchNames[0]
|
||
} catch {
|
||
/* retry */
|
||
}
|
||
await new Promise((resolve) => setTimeout(resolve, delayMs))
|
||
}
|
||
|
||
throw new Error(`Branch "${preferredBranch}" existiert im Repository ${owner}/${repo} noch nicht`)
|
||
}
|
||
|
||
async function updatePreviewConfigFile(repoFullName, previewConfig, branch = 'main') {
|
||
const [owner, repo] = repoFullName.split('/')
|
||
const content = JSON.stringify(previewConfig, null, 2) + '\n'
|
||
const encoded = Buffer.from(content).toString('base64')
|
||
|
||
let sha = null
|
||
try {
|
||
const existing = await giteaFetch(
|
||
`/repos/${owner}/${repo}/contents/.webklar-preview.json?ref=${encodeURIComponent(branch)}`
|
||
)
|
||
sha = existing.sha
|
||
} catch {
|
||
/* file may not exist yet */
|
||
}
|
||
|
||
await giteaFetch(`/repos/${owner}/${repo}/contents/.webklar-preview.json`, {
|
||
method: sha ? 'PUT' : 'POST',
|
||
body: JSON.stringify({
|
||
message: 'Update preview config from ticket system',
|
||
content: encoded,
|
||
branch,
|
||
...(sha ? { sha } : {}),
|
||
}),
|
||
})
|
||
}
|
||
|
||
export async function createProjectFromTemplate({
|
||
repoName,
|
||
displayName,
|
||
subdomain,
|
||
customerId = '',
|
||
ticketId = '',
|
||
owner,
|
||
}) {
|
||
const targetOwner = owner || config.gitea.defaultOwner
|
||
const safeRepoName = slugify(repoName) || slugify(displayName) || slugify(subdomain)
|
||
// Subdomain ist optional: ohne Subdomain wird nur das Repo angelegt (normales
|
||
// Projekt ohne Website-Preview), mit Subdomain zusaetzlich deployed.
|
||
const safeSubdomain = slugify(subdomain)
|
||
const hasWebsite = Boolean(safeSubdomain)
|
||
|
||
if (!safeRepoName) {
|
||
throw new Error('repoName oder displayName ist erforderlich')
|
||
}
|
||
|
||
if (hasWebsite) {
|
||
await ensureSubdomainAvailable(safeSubdomain)
|
||
}
|
||
|
||
const repo = await generateRepoFromTemplate({
|
||
owner: targetOwner,
|
||
repoName: safeRepoName,
|
||
})
|
||
|
||
const repoFullName = repo.full_name || `${targetOwner}/${safeRepoName}`
|
||
const [repoOwner, repoSlug] = repoFullName.split('/')
|
||
const branch = await waitForRepoBranch(repoOwner, repoSlug, 'main')
|
||
const previewConfig = {
|
||
enabled: hasWebsite,
|
||
type: 'static',
|
||
branch,
|
||
displayName: displayName || safeRepoName,
|
||
...(hasWebsite ? { subdomain: safeSubdomain } : {}),
|
||
templateName: config.gitea.templateRepo,
|
||
}
|
||
|
||
await updatePreviewConfigFile(repoFullName, previewConfig, branch)
|
||
|
||
let deployResult = { deployed: false, skipped: !hasWebsite }
|
||
if (hasWebsite) {
|
||
try {
|
||
deployResult = await runDeploy(repoFullName, branch, previewConfig)
|
||
} catch (deployErr) {
|
||
console.error('[giteaAdmin] deploy failed:', deployErr.message)
|
||
deployResult = { deployed: false, error: deployErr.message }
|
||
}
|
||
}
|
||
|
||
const previewUrl = hasWebsite ? buildPreviewUrl(safeSubdomain) : ''
|
||
const project = await upsertWebsiteProjectByRepo(repoFullName, {
|
||
customerId,
|
||
ticketId,
|
||
projectName: previewConfig.displayName,
|
||
templateName: config.gitea.templateRepo,
|
||
giteaRepoUrl: repo.html_url || `${config.gitea.baseUrl}/${repoFullName}`,
|
||
giteaRepoName: repo.name || safeRepoName,
|
||
repoFullName,
|
||
subdomain: safeSubdomain,
|
||
previewUrl,
|
||
projectType: hasWebsite ? 'website' : 'repo',
|
||
status: hasWebsite ? (deployResult.deployed ? 'deployed' : 'pending') : 'created',
|
||
provisioningStatus: hasWebsite
|
||
? (deployResult.deployed ? 'ready' : 'deploy_failed')
|
||
: 'ready',
|
||
})
|
||
|
||
return {
|
||
projectId: project.$id,
|
||
repoFullName,
|
||
previewUrl,
|
||
subdomain: safeSubdomain,
|
||
projectType: hasWebsite ? 'website' : 'repo',
|
||
deploy: deployResult,
|
||
}
|
||
}
|
||
|
||
/** 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) {
|
||
const [owner, repo] = repoFullName.split('/')
|
||
if (!owner || !repo) {
|
||
const error = new Error('repoFullName muss owner/repo sein')
|
||
error.status = 400
|
||
throw error
|
||
}
|
||
|
||
for (const filename of README_CANDIDATES) {
|
||
try {
|
||
const data = await giteaFetch(
|
||
`/repos/${owner}/${repo}/contents/${encodeURIComponent(filename)}`
|
||
)
|
||
const encoding = data.encoding || 'base64'
|
||
const raw = encoding === 'base64'
|
||
? Buffer.from(data.content || '', 'base64').toString('utf8')
|
||
: String(data.content || '')
|
||
return {
|
||
filename: data.name || filename,
|
||
content: raw,
|
||
htmlUrl: data.html_url || `${config.gitea.baseUrl}/${repoFullName}/src/branch/main/${filename}`,
|
||
}
|
||
} catch (err) {
|
||
if (err.status === 404) continue
|
||
throw err
|
||
}
|
||
}
|
||
|
||
return null
|
||
}
|