Merge origin/main in feature/terminplaner-tp2 (Analyse-Cockpit, Subdomain-Sperre, Projekte)

Konflikte: funktionen.js folgt der Aufteilung aus main (fn-util.js, analyse.js); der Link
'Zu Verträge' der Analyse-Seite kommt jetzt auch in analyse.js aus vertraege-link.js.
Checkout und Team-Ansicht übernehmen assertOwnProject(..., { forNewOrder: true }) und
includeArchived aus main. Test-Pins der absoluten Importe nachgezogen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-05 07:11:58 +00:00
commit af63b69ec1
56 files changed
+4583 -717

No files matched your search

+18 -4
View File
@@ -1,6 +1,20 @@
import { config } from '../config.js'
import { ADMIN_LABEL, STAFF_LABELS } from '../services/staffLabels.js'
export async function requireAppwriteStaff(req, res, next) {
/**
* Prueft das Appwrite-JWT aus dem Ticketsystem und verlangt eines der Labels `allowed`.
* requireAppwriteStaff: nur "admin" (Kunden, Mitarbeiter, Finanzen ...).
* requireAppwriteTeam: jeder Mitarbeiter ("mitarbeiter" oder "admin") - Projekt-Aktionen
* (Anlegen, Zuordnen, Archivieren, Preview, Analyse, Gitea-Import), Entscheidung 2026-10-04.
*/
function requireAppwriteLabels(allowed, deniedMessage) {
return (req, res, next) => checkAppwriteJwt(req, res, next, allowed, deniedMessage)
}
export const requireAppwriteStaff = requireAppwriteLabels([ADMIN_LABEL], 'Admin-Berechtigung erforderlich')
export const requireAppwriteTeam = requireAppwriteLabels(STAFF_LABELS, 'Nur für Mitarbeiter')
async function checkAppwriteJwt(req, res, next, allowed, deniedMessage) {
const authHeader = req.get('Authorization') || ''
const match = authHeader.match(/^Bearer\s+(.+)$/i)
if (!match) {
@@ -29,11 +43,11 @@ export async function requireAppwriteStaff(req, res, next) {
}
if (!response.ok) {
return res.status(401).json({ error: data?.message || 'Ung�ltiges JWT' })
return res.status(401).json({ error: data?.message || 'Ungültiges JWT' })
}
if (!Array.isArray(data?.labels) || !data.labels.includes('admin')) {
return res.status(403).json({ error: 'Admin-Berechtigung erforderlich' })
if (!Array.isArray(data?.labels) || !allowed.some((label) => data.labels.includes(label))) {
return res.status(403).json({ error: deniedMessage })
}
req.appwriteUser = data
+1 -1
View File
@@ -7,7 +7,7 @@ export function corsMiddleware(allowedOrigins) {
if (origin && allowedOrigins.includes(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin)
res.setHeader('Access-Control-Allow-Headers', ALLOW_HEADERS)
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PATCH, DELETE, OPTIONS')
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS')
if (req.method === 'OPTIONS') {
return res.sendStatus(204)
}
+3 -2
View File
@@ -2,12 +2,13 @@
import { Router } from 'express'
import { config } from '../../config.js'
import { getDocument } from '../../services/appwriteAdmin.js'
import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js'
import { requireAppwriteTeam } from '../../middleware/appwriteStaff.js'
import { activateAnalyse, analyseEligibility, controllerView, deactivateAnalyse, ELIGIBILITY_MESSAGES, validateTextform } from '../../services/analyse.js'
import { getFeatureDoc } from '../../services/siteFeatures.js'
const router = Router()
router.use(requireAppwriteStaff)
// Projekt-Aktionen: jeder Mitarbeiter (nicht nur Admins)
router.use(requireAppwriteTeam)
function sendError(res, err) {
const status = [400, 403, 404, 409, 503].includes(err.status) && !err.type ? err.status : 500
+1 -1
View File
@@ -121,7 +121,7 @@ async function unterlagenAlsAnhang() {
// je Anfrage einmal gelesen (ein gemeinsamer aboMemo fuer alle Projekte des Kunden), portalFeatures nicht erneut.
async function adminView(customerId) {
const docs = await listCustomerFeatureDocs(customerId)
const projects = await listCustomerFeatures(customerId, { docs })
const projects = await listCustomerFeatures(customerId, { docs, includeArchived: true })
const abo = aboMemo()
for (const p of projects) {
for (const key of FEATURE_KEYS) {
+11 -2
View File
@@ -1,10 +1,13 @@
import { Router } from 'express'
import { config } from '../../config.js'
import { listDocuments, Query } from '../../services/appwriteAdmin.js'
import { syncReposToAppwrite, fetchRepoReadme, importNewRepos } from '../../services/giteaAdmin.js'
import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js'
import { requireAppwriteTeam } from '../../middleware/appwriteStaff.js'
const router = Router()
router.use(requireAppwriteStaff)
// Projekt-Aktionen: jeder Mitarbeiter (nicht nur Admins)
router.use(requireAppwriteTeam)
router.post('/sync-repos', async (_req, res) => {
try {
@@ -35,6 +38,12 @@ router.get('/repo-readme', async (req, res) => {
}
try {
// Nur Repos, die ein Projekt sind - der System-Schluessel sieht sonst jedes (auch private) Repo
const known = await listDocuments(config.collections.websiteProjects, [
Query.equal('repoFullName', repoFullName),
Query.limit(1),
])
if (!known[0]) return res.status(404).json({ error: 'Projekt nicht gefunden' })
const readme = await fetchRepoReadme(repoFullName)
if (!readme) {
return res.json({ ok: true, found: false, repoFullName })
+3 -2
View File
@@ -2,7 +2,7 @@
import { Router } from 'express'
import { config } from '../../config.js'
import { getDocument, updateDocument } from '../../services/appwriteAdmin.js'
import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js'
import { requireAppwriteTeam } from '../../middleware/appwriteStaff.js'
import { envKey, encryptEnv, decryptEnv, envNames, validateEnvChange } from '../../services/previewEnv.js'
import { listRunLogs, readRunLog, isRunFile } from '../../services/previewLogs.js'
import * as runnerClient from '../../services/previewRunnerClient.js'
@@ -11,7 +11,8 @@ import { withLocks } from '../../services/keyedLock.js'
import { repoHasDevScript } from '../../services/previewDevScript.js'
const router = Router()
router.use(requireAppwriteStaff)
// Projekt-Aktionen: jeder Mitarbeiter (nicht nur Admins)
router.use(requireAppwriteTeam)
const BUSY = 'Server ausgelastet, versuche es gleich noch mal'
const MODES = ['auto', 'build', 'dev', 'off']
+3 -2
View File
@@ -2,11 +2,12 @@ import { Router } from 'express'
import { config } from '../../config.js'
import { listDocuments, Query } from '../../services/appwriteAdmin.js'
import { servicesCatalog } from '../../services/projectKinds.js'
import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js'
import { requireAppwriteTeam } from '../../middleware/appwriteStaff.js'
const router = Router()
router.use(requireAppwriteStaff)
// Projekt-Aktionen: jeder Mitarbeiter (nicht nur Admins)
router.use(requireAppwriteTeam)
const CHUNK = 100
+10 -3
View File
@@ -6,14 +6,15 @@ import {
} from '../../services/giteaAdmin.js'
import { assignProject, reconcileProject } from '../../services/projectAssignment.js'
import { completeAssignment } from '../../services/projectKindAssign.js'
import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js'
import { requireAppwriteTeam } from '../../middleware/appwriteStaff.js'
const router = Router()
router.use(requireAppwriteStaff)
// Projekt-Aktionen: jeder Mitarbeiter (nicht nur Admins)
router.use(requireAppwriteTeam)
router.post('/create-from-template', async (req, res) => {
const { repoName, displayName, subdomain, customerId = '', ticketId = '', owner } = req.body || {}
const { repoName, displayName, subdomain, customerId = '', ticketId = '', owner, kind = '' } = req.body || {}
if (!repoName && !displayName) {
return res.status(400).json({ error: 'repoName oder displayName ist erforderlich' })
@@ -27,6 +28,8 @@ router.post('/create-from-template', async (req, res) => {
customerId,
ticketId,
owner,
kind,
staffName: req.appwriteUser?.name || req.appwriteUser?.email || '',
})
return res.status(201).json({ ok: true, ...result })
} catch (err) {
@@ -38,6 +41,10 @@ router.post('/create-from-template', async (req, res) => {
// Projekt bearbeiten: Name / Subdomain / Typ -> synchronisiert Git + Preview + Appwrite
router.patch('/:id', async (req, res) => {
const { projectName, subdomain, projectType } = req.body || {}
// Vorlagen bleiben Admin-Sache (im Ticketsystem nur fuer Admins sichtbar)
if (projectType === 'template' && !req.appwriteUser?.labels?.includes('admin')) {
return res.status(403).json({ error: 'Nur Admins können Vorlagen anlegen.' })
}
try {
const result = await updateWebsiteProject(req.params.id, { projectName, subdomain, projectType })
return res.json({ ok: true, ...result })
+2 -1
View File
@@ -292,7 +292,8 @@ router.get('/preview-access', async (req, res) => {
const projects = await listDocuments(config.collections.websiteProjects, queries)
const project = projects[0]
if (!project) {
// Archiviert: fuer Kunden wie kein Projekt (das Portal blendet es aus)
if (!project || (project.archived && !admin)) {
return res.json({ allowed: false, reason: 'no_project' })
}
if (!isPreviewProjectReady(project)) {
+3 -1
View File
@@ -76,6 +76,7 @@ async function analyseView(customerId, projectId) {
avvVersion: config.analyse.avvVersion,
legal: legalPaths(config.analyse.avvVersion),
controller,
activatedAt: doc?.state === 'active' ? doc.activatedAt || null : null,
}
}
@@ -258,7 +259,7 @@ router.post('/:key/checkout', requireSession, async (req, res) => {
return res.status(400).json({ error: 'Bitte wähle ein Projekt.' })
}
const customerId = getSessionCustomerId(req)
await assertOwnProject(customerId, projectId)
await assertOwnProject(customerId, projectId, { forNewOrder: true })
if (fortsetzen && (await getFeatureDoc(customerId, projectId, key))?.state !== 'ordered') {
throw bestellFehler(400, ZUSTIMMUNG_NOETIG, 'zustimmung_fehlt')
}
@@ -342,6 +343,7 @@ router.post('/analyse/activate', analyseGuard, async (req, res) => {
return res.status(403).json({ error: 'In der Kundenansicht kann nichts geändert werden.' })
}
const { projectId, accentColor, consent } = req.body || {}
await assertOwnProject(getSessionCustomerId(req), analyseProjectId(projectId), { forNewOrder: true })
const { doc } = await activateAnalyse({
customerId: getSessionCustomerId(req),
projectId: analyseProjectId(projectId),
+5 -2
View File
@@ -14,7 +14,8 @@ const router = Router()
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) {
// Archivierte Projekte sieht der Kunde nicht mehr (wie in der Liste)
if (!project || !customerId || project.customerId !== customerId || project.archived) {
return res.status(404).json({ error: 'Projekt nicht gefunden' })
}
req.project = project
@@ -31,9 +32,11 @@ router.get('/', requireSession, async (req, res) => {
const projects = await listDocuments(config.collections.websiteProjects, [
Query.equal('customerId', customerId),
Query.orderDesc('$createdAt'),
Query.limit(500), // ohne Limit liefert Appwrite nur 25
])
const sanitized = projects.map((p) => ({
// Archivierte Projekte (Vorschau entfernt) blendet das Portal aus (Entscheidung 2026-10-04)
const sanitized = projects.filter((p) => !p.archived).map((p) => ({
id: p.$id,
projectName: p.projectName || '',
subdomain: p.subdomain || '',
+32 -7
View File
@@ -1,16 +1,17 @@
import crypto from 'node:crypto'
import { Router } from 'express'
import { config } from '../../config.js'
import { upsertWebsiteProjectByRepo, getDocument } from '../../services/appwriteAdmin.js'
import { upsertWebsiteProjectByRepo, getDocument, listDocuments, Query } from '../../services/appwriteAdmin.js'
import {
fetchPreviewConfig,
defaultPreviewConfig,
runDeploy,
foreignSubdomainBlock,
deployKeepsStatus,
buildPreviewUrl,
} from '../../services/previewDeploy.js'
import { ensureRepoPreviewConfig } from '../../services/giteaAdmin.js'
import { createGitPushWorksheet } from '../../services/gitWorksheet.js'
import { createGitPushWorksheet, isSystemPusher } from '../../services/gitWorksheet.js'
import { sendPushTimeMail } from '../../services/pushTimeMail.js'
import { ensureProjectTicket } from '../../services/ticketAdmin.js'
import { invalidateProjectCache } from '../../services/projectInsights.js'
@@ -107,18 +108,24 @@ router.post('/gitea', async (req, res) => {
}
}
const previewConfig = previewConfigFile || defaultPreviewConfig(name)
const hasPreview = Boolean(previewConfigFile?.enabled !== false && previewConfigFile)
const wantsPreview = Boolean(previewConfigFile?.enabled !== false && previewConfigFile)
// Kopiertes Repo mit der .webklar-preview.json eines anderen Projekts: dessen Subdomain weder
// beliefern noch uebernehmen - das Projekt bleibt ohne Preview, bis WOMS ihm eine eigene gibt.
const blocked = wantsPreview
? await foreignSubdomainBlock(name, previewConfig.subdomain || name.split('/').pop())
: null
const hasPreview = wantsPreview && !blocked
// Nur Projekte mit aktivierter Preview bekommen eine Subdomain zugewiesen -
// Repo-only-Projekte (enabled:false, keine Subdomain) bleiben ohne Website.
const subdomain = hasPreview ? previewConfig.subdomain || name.split('/').pop() : ''
const previewUrl = subdomain ? buildPreviewUrl(subdomain) : ''
let deployResult = { deployed: false, skipped: true }
let deployResult = blocked || { deployed: false, skipped: true }
const configBranch = previewConfig.branch || 'main'
if (hasPreview && previewConfig.enabled !== false && branch === configBranch) {
try {
deployResult = await runDeploy(name, branch, previewConfig, { sha: commitSha })
deployResult = await runDeploy(name, branch, previewConfig, { sha: commitSha, subdomainChecked: true })
} catch (deployErr) {
console.error('[webhook] deploy failed:', deployErr.message)
deployResult = { deployed: false, error: deployErr.message }
@@ -141,7 +148,8 @@ router.post('/gitea', async (req, res) => {
const payloadNameStillCurrent = payloadName.toLowerCase() === current.toLowerCase()
const projectPayload = {
projectName: previewConfig.displayName || (payloadNameStillCurrent ? repo.name : '') || subdomain,
// der Anzeigename einer fremden Config gehoert ebenfalls dem Original
projectName: (blocked ? '' : previewConfig.displayName) || (payloadNameStillCurrent ? repo.name : '') || subdomain,
templateName: previewConfig.templateName || config.gitea.templateRepo,
giteaRepoUrl: payloadNameStillCurrent
? repo.html_url || `${config.gitea.baseUrl}/${current}`
@@ -161,6 +169,20 @@ router.post('/gitea', async (req, res) => {
} else if (hasPreview) {
projectPayload.status = 'pending'
projectPayload.provisioningStatus = deployResult.error ? 'deploy_failed' : 'pending'
} else if (blocked) {
// eine schon vergebene eigene Preview bleibt, wie sie ist - sonst Projekt ohne Preview;
// ein vorhandener Projektname auch (der Repo-Name waere nur der Ersatz fuer neue Projekte)
const existing = (await listDocuments(config.collections.websiteProjects, [
Query.equal('repoFullName', current),
Query.limit(1),
]))[0]
if (!existing?.subdomain) projectPayload.status = 'synced'
else {
// sonst stuende im WOMS weiter der alte Bau als aktuell da (Fehlertext nur bei 'failed')
projectPayload.previewState = 'failed'
projectPayload.previewError = `Neuer Stand nicht veröffentlicht: In .webklar-preview.json steht die Subdomain „${blocked.subdomain}“, die gehört ${blocked.owner}. Projekt im WOMS bearbeiten und speichern, dann stimmt die Datei wieder.`.slice(0, 500)
}
if (existing?.projectName) delete projectPayload.projectName
} else {
projectPayload.status = 'synced'
}
@@ -177,7 +199,10 @@ router.post('/gitea', async (req, res) => {
// (derselben wie assignProject/reconcileProject), damit ein gleichzeitiges Zuweisen nicht
// dieselbe ticketId-Aufloesung doppelt anstoesst.
let gitWorksheet = null
if (commitSha && commitSha !== '0000000000000000000000000000000000000000') {
// System-Commits (z. B. .webklar-preview.json beim Anlegen aus dem Ticketsystem) bekommen weder
// Arbeitsblatt noch Auto-Ticket - sonst entstand beim Anlegen mit Kunde ein zusaetzliches
// Ticket "Website <name>" neben dem, das im Zuordnen-Assistenten gewaehlt wird.
if (commitSha && commitSha !== '0000000000000000000000000000000000000000' && !isSystemPusher(pusher)) {
gitWorksheet = await withLocks(
['project:' + project.$id],
async () => {
+26 -13
View File
@@ -317,19 +317,32 @@ export async function upsertWebsiteProjectByRepo(repoFullName, data) {
}
const now = new Date().toISOString()
return adminFetch(collectionPath(config.collections.websiteProjects), {
method: 'POST',
body: {
...payload,
customerId: payload.customerId ?? '',
ticketId: payload.ticketId ?? '',
status: payload.status ?? 'pending',
provisioningStatus: payload.provisioningStatus ?? 'pending',
projectName: payload.projectName || repoFullName.split('/').pop() || repoFullName,
createdAt: now,
documentId: ID.unique(),
},
})
try {
return await adminFetch(collectionPath(config.collections.websiteProjects), {
method: 'POST',
body: {
...payload,
customerId: payload.customerId ?? '',
ticketId: payload.ticketId ?? '',
status: payload.status ?? 'pending',
provisioningStatus: payload.provisioningStatus ?? 'pending',
projectName: payload.projectName || repoFullName.split('/').pop() || repoFullName,
createdAt: now,
documentId: ID.unique(),
},
})
} catch (err) {
// Ein anderer Schreiber ohne gemeinsame Sperre hat das Dokument zwischen Suche und Anlage
// angelegt: der Unique-Index auf repoFullName meldet 409. Dann das vorhandene Dokument
// aktualisieren, statt den Fehler bis in die Oberflaeche zu reichen.
if (err.status !== 409) throw err
const raced = await listDocuments(config.collections.websiteProjects, [
Query.equal('repoFullName', repoFullName),
Query.limit(1),
])
if (!raced[0]) throw err
return updateDocument(config.collections.websiteProjects, raced[0].$id, buildProjectPayload(raced[0], data))
}
}
export async function verifyDatabaseAccess() {
+1 -1
View File
@@ -198,7 +198,7 @@ export async function updateCustomerWithPortalAccess(customerId, payload) {
portalPassword = normalizedPassword
}
} else if (normalizedPassword) {
const error = new Error('Portal-Login fehlt � bitte E-Mail setzen und Passwort erneut speichern')
const error = new Error('Portal-Login fehlt – bitte E-Mail setzen und Passwort erneut speichern')
error.status = 400
throw error
}
+210 -73
View File
@@ -5,6 +5,7 @@ import {
Query,
getDocument,
updateDocument,
deleteDocument,
} from './appwriteAdmin.js'
import {
runDeploy,
@@ -17,7 +18,8 @@ import {
import { giteaFetch } from './giteaClient.js'
import { slugify } from './slug.js'
import { withLocks } from './keyedLock.js'
import { repoLockKey } from './repoMove.js'
import { repoLockKey, isUnlinkedProject } from './repoMove.js'
import { isKind } from './projectKinds.js'
// Sicherheitsnetz wie bei listRecentPushes (siehe pushHistory.js, Controller-Ruling R8): sollte
// Gitea "page" ignorieren und immer dieselbe volle Seite liefern, blaettert die Schleife nicht
@@ -49,12 +51,16 @@ export async function listAllAccessibleRepos() {
/**
* Findet Repos, die in Gitea liegen, aber noch kein Appwrite-Projektdokument haben, und legt fuer
* jedes nicht archivierte ein minimales Dokument an (status "synced", ohne Hosting/Subdomain) -
* ohne dass dafuer manuell "Abgleich" gedrueckt werden muesste. Wird vom Projekt-Dialog bei jedem
* jedes ein minimales Dokument an (status "synced", ohne Hosting/Subdomain; in Gitea archivierte
* als archiviertes Projekt) - ohne dass dafuer manuell "Abgleich" gedrueckt werden muesste. Bei
* vorhandenen Dokumenten wird nur der Archiv-Stand aus Gitea gespiegelt (Repo dort direkt
* archiviert oder reaktiviert). Wird vom Projekt-Dialog bei jedem
* Oeffnen aufgerufen, laeuft also oft: die aeussere `withLocks(['import'])`-Sperre serialisiert
* parallele Laeufe komplett (ein zweiter Aufruf sieht danach garantiert den Stand des ersten), die
* innere `withLocks([repoLockKey(...)])` je neuem Repo schuetzt zusaetzlich gegen andere
* gleichzeitige Schreiber desselben Projektdokuments (z. B. Umzug/Zuweisung).
* Ein Fehler bei einem Repo bricht den Lauf nicht ab (sonst fehlten alle Repos danach).
* `createdRepos` enthaelt nur neue, nicht archivierte Repos (nur sie bekommen Zuordnungs-Mails).
*/
export async function importNewRepos() {
return withLocks(['import'], async () => {
@@ -64,39 +70,75 @@ export async function importNewRepos() {
])
// Ohne Ruecksicht auf Gross-/Kleinschreibung vergleichen (Controller-Ruling).
const existingNames = new Set(
docs.map((d) => String(d.repoFullName || '').toLowerCase()).filter(Boolean)
)
let created = 0
const createdRepos = []
for (const repo of repos) {
const fullName = repo.full_name
if (!fullName || repo.archived) continue
if (existingNames.has(fullName.toLowerCase())) continue
await withLocks([repoLockKey(fullName)], async () => {
const recheck = await listDocuments(config.collections.websiteProjects, [
Query.equal('repoFullName', fullName),
Query.limit(1),
])
if (recheck[0]) return
const doc = await upsertWebsiteProjectByRepo(fullName, {
projectName: repo.description || repo.name || fullName.split('/').pop(),
giteaRepoUrl: repo.html_url,
giteaRepoName: repo.name,
repoFullName: fullName,
archived: false,
status: 'synced',
giteaSyncedAt: new Date().toISOString(),
})
created += 1
createdRepos.push({ projectId: doc.$id, repo })
})
const docsByName = new Map()
for (const d of docs) {
const key = String(d.repoFullName || '').toLowerCase()
if (key && !docsByName.has(key)) docsByName.set(key, d)
}
return { created, createdRepos }
let created = 0
let archivedImported = 0
let archiveSynced = 0
const createdRepos = []
const failed = []
for (const repo of repos) {
const fullName = repo.full_name
if (!fullName) continue
const repoArchived = Boolean(repo.archived)
try {
const existing = docsByName.get(fullName.toLowerCase())
if (existing) {
if (Boolean(existing.archived) === repoArchived) continue
await withLocks([repoLockKey(fullName)], async () => {
// Unter der Sperre frisch lesen (Dokument UND Gitea): die Repo-Liste oben kann aelter
// sein als ein gerade laufendes Archivieren/Reaktivieren im Ticketsystem.
const fresh = await getDocument(config.collections.websiteProjects, existing.$id)
const live = await giteaFetch(`/repos/${fullName}`)
const liveArchived = Boolean(live?.archived)
if (!fresh || Boolean(fresh.archived) === liveArchived) return
await updateDocument(config.collections.websiteProjects, fresh.$id, {
archived: liveArchived,
...(liveArchived
? { status: 'archived' }
: fresh.status === 'archived' ? { status: 'synced' } : {}),
updatedAt: new Date().toISOString(),
})
archiveSynced += 1
}, { waitMs: 10000 })
continue
}
await withLocks([repoLockKey(fullName)], async () => {
const recheck = await listDocuments(config.collections.websiteProjects, [
Query.equal('repoFullName', fullName),
Query.limit(1),
])
if (recheck[0]) return
const doc = await upsertWebsiteProjectByRepo(fullName, {
projectName: repo.description || repo.name || fullName.split('/').pop(),
giteaRepoUrl: repo.html_url,
giteaRepoName: repo.name,
repoFullName: fullName,
archived: repoArchived,
status: repoArchived ? 'archived' : 'synced',
giteaSyncedAt: new Date().toISOString(),
})
if (repoArchived) {
archivedImported += 1
return
}
created += 1
createdRepos.push({ projectId: doc.$id, repo })
})
} catch (err) {
failed.push(fullName)
console.error(`[importNewRepos] ${fullName} übersprungen:`, err.message)
}
}
return { created, createdRepos, archivedImported, archiveSynced, failed }
})
}
@@ -104,6 +146,7 @@ export async function syncReposToAppwrite() {
const repos = await listAllAccessibleRepos()
let created = 0
let updated = 0
const failed = []
for (const repo of repos) {
const repoFullName = repo.full_name
@@ -115,25 +158,38 @@ export async function syncReposToAppwrite() {
])
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,
// In Gitea archivierte Repos werden im Ticketsystem ausgeblendet.
archived: Boolean(repo.archived),
}
const repoTitle = repo.description || repo.name || repoFullName.split('/').pop()
if (!existing[0]) {
payload.status = 'synced'
payload.projectName = repoTitle
payload.status = repo.archived ? 'archived' : 'synced'
created += 1
} else {
// Der Name im Ticketsystem bleibt: frueher ueberschrieb der Abgleich ihn mit der
// Gitea-Beschreibung (aus "Döner Nation" wurde so "D-ner-Nation").
if (!existing[0].projectName) payload.projectName = repoTitle
// Archiv-Stand aus Gitea uebernehmen, ohne dass status auf 'archived' haengen bleibt
if (repo.archived && existing[0].status !== 'archived') payload.status = 'archived'
if (!repo.archived && existing[0].status === 'archived') payload.status = 'synced'
updated += 1
}
await upsertWebsiteProjectByRepo(repoFullName, payload)
try {
await upsertWebsiteProjectByRepo(repoFullName, payload)
} catch (err) {
// Ein fehlerhaftes Repo darf den Abgleich aller folgenden nicht abbrechen
failed.push(repoFullName)
console.error(`[syncReposToAppwrite] ${repoFullName} übersprungen:`, err.message)
}
}
return { synced: repos.length, created, updated }
return { synced: repos.length, created, updated, failed }
}
export async function ensureSubdomainAvailable(subdomain) {
@@ -148,7 +204,7 @@ export async function ensureSubdomainAvailable(subdomain) {
}
}
export async function generateRepoFromTemplate({ owner, repoName }) {
export async function generateRepoFromTemplate({ owner, repoName, description }) {
const templateOwner = config.gitea.templateOwner
const templateRepo = config.gitea.templateRepo
const targetOwner = owner || config.gitea.defaultOwner
@@ -162,8 +218,11 @@ export async function generateRepoFromTemplate({ owner, repoName }) {
body: JSON.stringify({
owner: targetOwner,
name: repoName,
private: false,
description: `Website-Projekt ${repoName}`,
// Kundenquelltext nicht oeffentlich (Entscheidung 2026-10-04). Portal und Preview-Runner
// lesen Gitea mit dem System-Schluessel, Mitarbeiter sehen Org-Repos ueber das Team Owners.
private: true,
// Die Beschreibung ist der Projektname in Gitea (Abgleich und Webhook lesen ihn von dort)
description: description || `Website-Projekt ${repoName}`,
git_content: true,
}),
})
@@ -271,6 +330,37 @@ export async function ensureRepoPreviewConfig(repoFullName, branch = 'main') {
return previewConfig
}
/**
* Vor dem Anlegen: gibt es das Repo in Gitea nicht (mehr), aber noch ein Projekt-Dokument mit dem
* Namen, wuerde das neue Projekt in diesen alten Eintrag uebernommen - samt archived, Art, Preview-
* Stand (und waere z. B. als archiviert unsichtbar). Platzhalter ohne Bezug werden entfernt, ein
* verknuepftes altes Projekt blockiert den Namen mit einer klaren Meldung - bevor ein Repo entsteht.
*/
async function releaseLeftoverProjectName(fullName) {
try {
await giteaFetch(`/repos/${fullName}`)
return // Repo existiert: generate meldet gleich "existiert bereits"
} catch (err) {
if (err.status !== 404) return
}
const leftovers = await listDocuments(config.collections.websiteProjects, [
Query.equal('repoFullName', fullName),
Query.limit(10),
])
for (const old of leftovers) {
if (isUnlinkedProject(old)) {
await deleteDocument(config.collections.websiteProjects, old.$id)
continue
}
const error = new Error(
`Der Name "${fullName}" gehört noch zu einem alten Projekt ("${old.projectName || fullName}"` +
`${old.archived ? ', archiviert' : ''}). Bitte einen anderen Namen wählen.`
)
error.status = 409
throw error
}
}
export async function createProjectFromTemplate({
repoName,
displayName,
@@ -278,6 +368,8 @@ export async function createProjectFromTemplate({
customerId = '',
ticketId = '',
owner,
kind = '',
staffName = '',
}) {
const targetOwner = owner || config.gitea.defaultOwner
const safeRepoName = slugify(repoName) || slugify(displayName) || slugify(subdomain)
@@ -289,14 +381,22 @@ export async function createProjectFromTemplate({
if (!safeRepoName) {
throw new Error('repoName oder displayName ist erforderlich')
}
if (kind && !isKind(kind)) {
const error = new Error('Unbekannte Projektart.')
error.status = 400
throw error
}
if (hasWebsite) {
await ensureSubdomainAvailable(safeSubdomain)
}
await releaseLeftoverProjectName(`${targetOwner}/${safeRepoName}`)
const repo = await generateRepoFromTemplate({
owner: targetOwner,
repoName: safeRepoName,
description: displayName || safeRepoName,
})
const repoFullName = repo.full_name || `${targetOwner}/${safeRepoName}`
@@ -324,7 +424,11 @@ export async function createProjectFromTemplate({
}
const previewUrl = hasWebsite ? buildPreviewUrl(safeSubdomain) : ''
const project = await upsertWebsiteProjectByRepo(repoFullName, {
// Gitea meldet das neue Repo sofort per Webhook (repository/created, dann der Push der Config).
// Der Webhook legt das Dokument unter dieser Repo-Sperre an; ohne dieselbe Sperre legen beide
// gleichzeitig an und der Unique-Index auf repoFullName lehnt dieses hier ab ("Document with
// the requested ID already exists") - das Repo gab es dann schon, Kunde und Ticket fehlten.
const project = await withLocks([repoLockKey(repoFullName)], () => upsertWebsiteProjectByRepo(repoFullName, {
customerId,
ticketId,
projectName: previewConfig.displayName,
@@ -332,9 +436,24 @@ export async function createProjectFromTemplate({
giteaRepoUrl: repo.html_url || `${config.gitea.baseUrl}/${repoFullName}`,
giteaRepoName: repo.name || safeRepoName,
repoFullName,
subdomain: safeSubdomain,
// Ohne Website null statt '': auf subdomain liegt ein Unique-Index, und ein zweites
// Projekt mit '' scheiterte mit "Document with the requested ID already exists".
subdomain: safeSubdomain || null,
previewUrl,
projectType: hasWebsite ? 'website' : 'repo',
// Login-geschuetzte Preview (isPublic bleibt aus) - wie deriveProjectType und das Ticketsystem
projectType: hasWebsite ? 'preview' : 'repo',
archived: false,
// Art direkt mitgeben (Ticket: "Neues Projekt" = Website) - sonst stand das Projekt auf
// "Art fehlt" und der Zuordnungs-Lauf mahnte es an. Felder wie completeAssignment.
...(kind
? {
kind,
forCustomer: Boolean(customerId),
kindSetBy: staffName || '',
kindSetAt: new Date().toISOString(),
previewMode: kind === 'website' || kind === 'webapp' ? 'auto' : 'off',
}
: {}),
...(hasWebsite && deployKeepsStatus(deployResult)
? {} // Preview-Runner (C1): Status setzt der Verfolger
: {
@@ -343,14 +462,14 @@ export async function createProjectFromTemplate({
? (deployResult.deployed ? 'ready' : 'deploy_failed')
: 'ready',
}),
})
}), { waitMs: 10000 })
return {
projectId: project.$id,
repoFullName,
previewUrl,
subdomain: safeSubdomain,
projectType: hasWebsite ? 'website' : 'repo',
projectType: hasWebsite ? 'preview' : 'repo',
deploy: deployResult,
}
}
@@ -494,7 +613,7 @@ export async function updateWebsiteProject(projectId, { projectName, subdomain,
const previewUrl = hasWebsite && nextSubdomain ? buildPreviewUrl(nextSubdomain) : ''
const updated = await updateDocument(config.collections.websiteProjects, projectId, {
projectName: nextName,
subdomain: nextSubdomain,
subdomain: nextSubdomain || null, // null statt '' (Unique-Index, siehe createProjectFromTemplate)
previewUrl,
projectType: nextType,
isPublic,
@@ -550,30 +669,48 @@ export async function archiveRepo(projectId, archived = true) {
const repoFullName = doc.repoFullName || ''
let repoMissing = false
if (repoFullName.includes('/')) {
try {
await patchGiteaRepo(repoFullName, { archived: Boolean(archived) })
} catch (err) {
// Verwaistes Projekt: Repo existiert nicht mehr in Gitea (404). Dann trotzdem
// nur das Appwrite-Dokument (ent)archivieren, damit es aus der Liste verschwindet.
if (err.status === 404) {
repoMissing = true
console.warn(`[archiveRepo] Gitea-Repo ${repoFullName} nicht gefunden (404) - nur Appwrite wird aktualisiert`)
} else {
const error = new Error('Gitea-Archivierung fehlgeschlagen: ' + err.message)
error.status = err.status || 500
throw error
// Gitea und Dokument unter der Repo-Sperre aendern - dieselbe nimmt der Import, der den
// Archiv-Stand aus Gitea spiegelt; sonst koennte er einen alten Stand zurueckschreiben.
const locks = repoFullName ? [repoLockKey(repoFullName)] : []
let updated = await withLocks(locks, async () => {
if (repoFullName.includes('/')) {
try {
await patchGiteaRepo(repoFullName, { archived: Boolean(archived) })
} catch (err) {
// Verwaistes Projekt: Repo existiert nicht mehr in Gitea (404). Dann trotzdem
// nur das Appwrite-Dokument (ent)archivieren, damit es aus der Liste verschwindet.
if (err.status === 404) {
repoMissing = true
console.warn(`[archiveRepo] Gitea-Repo ${repoFullName} nicht gefunden (404) - nur Appwrite wird aktualisiert`)
} else {
const error = new Error('Gitea-Archivierung fehlgeschlagen: ' + err.message)
error.status = err.status || 500
throw error
}
}
}
}
if (archived && doc.subdomain) {
try { await removeDeploy(doc.subdomain) } catch (err) {
console.error('[archiveRepo] Deploy entfernen fehlgeschlagen:', err.message)
// Reaktivieren: status 'archived' zuruecksetzen, und zwar VOR dem Redeploy - der Preview-Runner
// setzt nach dem Bau selbst 'deployed', das darf hier danach nicht ueberschrieben werden.
// Blieb 'archived' stehen, bot das Ticket das Projekt unter "Projekt wählen" nicht mehr an.
return updateDocument(config.collections.websiteProjects, projectId, {
archived: Boolean(archived),
status: archived ? 'archived' : (!repoMissing && doc.subdomain ? 'pending' : 'synced'),
updatedAt: new Date().toISOString(),
})
}, { waitMs: 10000 })
if (archived) {
if (doc.subdomain) {
try { await removeDeploy(doc.subdomain) } catch (err) {
console.error('[archiveRepo] Deploy entfernen fehlgeschlagen:', err.message)
}
}
return { project: updated, archived: true }
}
if (!archived && !repoMissing && doc.subdomain) {
if (!repoMissing && doc.subdomain) {
let after = null
try {
const branch = (await getRepoInfo(repoFullName))?.defaultBranch || 'main'
const cfg = (await fetchPreviewConfig(repoFullName, branch)) || {
@@ -583,19 +720,19 @@ export async function archiveRepo(projectId, archived = true) {
subdomain: doc.subdomain,
displayName: doc.projectName,
}
await runDeploy(repoFullName, branch, cfg, { project: doc })
const deployResult = await runDeploy(repoFullName, branch, cfg, { project: doc })
if (deployResult?.deployed) after = { status: 'deployed', provisioningStatus: 'ready' }
else if (deployResult?.skipped) after = { status: 'synced' } // Preview ausgeschaltet
else if (!deployResult?.queued) after = { provisioningStatus: 'deploy_failed' }
// queued: der Runner-Verfolger setzt den Status nach dem Bau
} catch (err) {
console.error('[archiveRepo] Redeploy bei Reaktivierung fehlgeschlagen:', err.message)
after = { provisioningStatus: 'deploy_failed' }
}
if (after) updated = await updateDocument(config.collections.websiteProjects, projectId, after)
}
const updated = await updateDocument(config.collections.websiteProjects, projectId, {
archived: Boolean(archived),
...(archived ? { status: 'archived' } : {}),
updatedAt: new Date().toISOString(),
})
return { project: updated, archived: Boolean(archived) }
return { project: updated, archived: false }
}
/** Repo-Basisdaten (Groesse in KB, Default-Branch). */
+1 -1
View File
@@ -52,7 +52,7 @@ export async function giteaFetch(path, options = {}) {
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.'
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
+68
View File
@@ -802,7 +802,75 @@ async function runDeployViaRunner(repoFullName, branch, previewConfig, { project
}
}
/**
* Gehoert `subdomain` schon einem ANDEREN Projekt? Ein kopiertes Repo (git clone + push, Vorlage)
* bringt die .webklar-preview.json des Originals mit - ohne diese Pruefung ueberschrieb sein erster
* Push die Preview des Originals (Olzinger -> HSK-Vorlage, 04.10.2026). Ein in Gitea umbenanntes
* oder umgezogenes Repo ist nicht fremd: Gitea leitet den alten Namen auf `repoFullName` weiter.
* Ein Projekt ohne Repo (z. B. die Relaunch-Vorschau) haelt seine Subdomain ebenfalls.
* @returns {Promise<string>} Repo-Name bzw. Projekt des fremden Besitzers, sonst ''
*/
export async function foreignSubdomainOwner(repoFullName, subdomain) {
const own = String(repoFullName || '').toLowerCase()
// ohne Repo gibt es nichts zu klonen - und ein Projekt ohne Repo sperrt sich nicht selbst
if (!subdomain || !own) return ''
const docs = await listDocuments(config.collections.websiteProjects, [
Query.equal('subdomain', subdomain),
Query.limit(5),
])
for (const doc of docs) {
const other = String(doc.repoFullName || '')
if (!other) return `Projekt „${doc.projectName || doc.$id}“ (ohne Repo)`
if (other.toLowerCase() === own) continue
let resolved = ''
try {
resolved = String((await giteaFetch(`/repos/${other}`))?.full_name || '')
} catch {
// geloescht oder Gitea nicht erreichbar: im Zweifel fremd
}
if (resolved.toLowerCase() !== own) return other
}
return ''
}
/**
* Ergebnis fuer einen gesperrten Deploy oder null. `failOpen`: ist die Pruefung selbst nicht
* moeglich (Appwrite weg), trotzdem deployen - so nutzt es runDeploy; der Webhook prueft vorher
* streng, und die WOMS-Wege lesen das Projekt ohnehin zuerst aus Appwrite.
*/
export async function foreignSubdomainBlock(repoFullName, subdomain, { failOpen = false } = {}) {
let owner
try {
owner = await foreignSubdomainOwner(repoFullName, subdomain)
} catch (err) {
if (!failOpen) throw err
console.error(`[previewDeploy] ${repoFullName}: Besitzer der Subdomain "${subdomain}" nicht pruefbar, deploye trotzdem:`, err.message)
return null
}
if (!owner) return null
console.warn(`[previewDeploy] ${repoFullName}: Subdomain "${subdomain}" gehoert ${owner} - kein Deploy`)
return { deployed: false, blocked: true, reason: 'subdomain_foreign', subdomain, owner }
}
/**
* Muss runDeploy den Besitzer der Subdomain pruefen? Nicht, wenn der Aufrufer es schon getan hat
* (`subdomainChecked`, der Webhook) oder wenn es die schon vergebene Subdomain des uebergebenen
* Projekts selbst ist.
*/
function needsOwnerCheck(repoFullName, subdomain, { project, subdomainChecked } = {}) {
if (subdomainChecked) return false
const same = (a, b) => String(a || '').toLowerCase() === String(b || '').toLowerCase()
return !(project?.subdomain && same(project.subdomain, subdomain) && same(project.repoFullName, repoFullName))
}
export async function runDeploy(repoFullName, branch, previewConfig, options = {}) {
// Vor dem Runner pruefen: ein gesperrter Aufruf belegt dort keinen Platz und laesst so den
// Verfolger eines laufenden Baus desselben Repos in Ruhe.
const target = previewConfig.subdomain || repoFullName.split('/').pop()
if (needsOwnerCheck(repoFullName, target, options)) {
const blocked = await foreignSubdomainBlock(repoFullName, target, { failOpen: true })
if (blocked) return blocked
}
if (config.previewRunner.enabled) {
return runDeployViaRunner(repoFullName, branch, previewConfig, options)
}
+4 -1
View File
@@ -149,7 +149,10 @@ export async function runRepoAssignJob({
const docs = await listDocuments(collection(), [Query.limit(5000)])
for (const p of docs) {
if (!p.createdByGitea || p.kind || handled.has(p.$id)) continue
if (!p.createdByGitea || p.kind || p.archived || handled.has(p.$id)) continue
// Vom System-Konto angelegt (WOMS "Neues Projekt"): kein Mitarbeiter, den man erinnern
// koennte - sonst alle 10 Minuten Gitea-Abfrage und Log "keinem Mitarbeiter zugeordnet".
if (String(p.createdByGitea).toLowerCase() === String(config.gitea.systemUser || '').toLowerCase()) continue
// Wiederholung/Nachholen: erste Mail noch nicht raus (0-2 Versuche), Repo noch juenger als 24 h.
const attempts = Number(p.assignMailAttempts || 0)
if (!p.assignMailSentAt && attempts < 3) {
+2
View File
@@ -59,6 +59,8 @@ function sameRepoFullName(a, b) {
export async function isInternalRepo(repoFullName) {
const lower = repoFullName.toLowerCase()
if (config.gitea.keepInPlace.some((entry) => entry.toLowerCase() === lower)) return true
// Die Vorlage fuer "Neues Projekt" darf nie umziehen, sonst findet generate sie nicht mehr
if (`${config.gitea.templateOwner}/${config.gitea.templateRepo}`.toLowerCase() === lower) return true
const hooks = await giteaFetch(`/repos/${repoFullName}/hooks`)
const portalUrl = portalWebhookUrl()
+15 -6
View File
@@ -72,7 +72,8 @@ export async function getFeatureDoc(customerId, projectId, key) {
}
}
export async function assertOwnProject(customerId, projectId) {
/** forNewOrder: Neu bestellen/einschalten geht auf archivierten Projekten nicht mehr (Kunde sieht sie nicht). */
export async function assertOwnProject(customerId, projectId, { forNewOrder = false } = {}) {
let project = null
try {
project = await getDocument(config.collections.websiteProjects, projectId)
@@ -80,6 +81,9 @@ export async function assertOwnProject(customerId, projectId) {
if (err.status !== 404) throw err
}
if (!project || !customerId || project.customerId !== customerId) throw httpError(403, 'Kein Zugriff auf dieses Projekt')
if (forNewOrder && project.archived) {
throw httpError(409, 'Dieses Projekt ist archiviert - dafür kann nichts mehr bestellt oder eingeschaltet werden.')
}
return project
}
@@ -149,14 +153,19 @@ export function listCustomerFeatureDocs(customerId) {
return listDocuments(config.collections.portalFeatures, [Query.equal('customerId', customerId), Query.limit(1000)])
}
/** docs: bereits geladene Funktionsdokumente des Kunden (listCustomerFeatureDocs), damit sie nicht doppelt gelesen werden. */
export async function listCustomerFeatures(customerId, { docs: geladen } = {}) {
const projects = await listDocuments(config.collections.websiteProjects, [
/**
* docs: bereits geladene Funktionsdokumente des Kunden (listCustomerFeatureDocs), damit sie nicht doppelt gelesen werden.
* includeArchived: nur fuer die Mitarbeiter-Ansicht. Der Kunde sieht archivierte Projekte nur noch, solange dort
* eine Funktion bestellt/eingerichtet/aktiv ist - sonst kaeme er z. B. nicht mehr an seine Terminbuchungen.
*/
export async function listCustomerFeatures(customerId, { docs: geladen, includeArchived = false } = {}) {
const docs = geladen ?? (await listCustomerFeatureDocs(customerId))
const laufend = new Set(docs.filter((d) => ['ordered', 'setup', 'active'].includes(d.state)).map((d) => d.projectId))
const projects = (await listDocuments(config.collections.websiteProjects, [
Query.equal('customerId', customerId),
Query.orderDesc('$createdAt'),
Query.limit(1000),
])
const docs = geladen ?? (await listCustomerFeatureDocs(customerId))
])).filter((p) => includeArchived || !p.archived || laufend.has(p.$id))
const result = []
for (const p of projects) {
const features = {}