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:
commit
af63b69ec1
56 files changed
+4583
-717
No files matched your search
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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). */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
Reference in new issue
Block a user