Projekt-Ablauf im Ticketsystem: Anlegen, Archivieren, Import repariert

- Neues Projekt ohne Subdomain: subdomain null statt '' (Unique-Index
  subdomain_unique, jedes weitere scheiterte mit "Document with the
  requested ID already exists"); ebenso Typ "Internes Projekt"
- Anlegen unter derselben Repo-Sperre wie der Webhook repository/created;
  upsertWebsiteProjectByRepo macht aus einem 409 ein Update
- Gitea-Beschreibung = Anzeigename; Art (kind) beim Anlegen aus dem Ticket
- Reaktivieren setzt status zurueck (vorher blieb 'archived' stehen)
- Import: archivierte Repos als archiviert, Archiv-Stand aus Gitea wird
  gespiegelt, ein fehlerhaftes Repo bricht den Lauf nicht mehr ab
- Abgleich ueberschreibt Projektnamen nicht mehr
- CORS erlaubt PUT (Preview-Variablen speichern)
- Vorlage zieht beim Zuweisen nie in die Org um
- Erinnerungs-Lauf ueberspringt archivierte Projekte
- kaputte Umlaute (U+FFFD) in drei Fehlermeldungen

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-04 13:22:53 +00:00
1 parent 3a0b0089dd
commit e43cdb667b
13 files changed
+498 -83

No files matched your search

+26 -13
View File
@@ -300,19 +300,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
}
+151 -54
View File
@@ -18,6 +18,7 @@ import { giteaFetch } from './giteaClient.js'
import { slugify } from './slug.js'
import { withLocks } from './keyedLock.js'
import { repoLockKey } 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 +50,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 +69,71 @@ 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 () => {
const fresh = await getDocument(config.collections.websiteProjects, existing.$id)
if (!fresh || Boolean(fresh.archived) === repoArchived) return
await updateDocument(config.collections.websiteProjects, fresh.$id, {
archived: repoArchived,
...(repoArchived
? { status: 'archived' }
: fresh.status === 'archived' ? { status: 'synced' } : {}),
updatedAt: new Date().toISOString(),
})
archiveSynced += 1
})
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 +141,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 +153,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 +199,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
@@ -163,7 +214,8 @@ export async function generateRepoFromTemplate({ owner, repoName }) {
owner: targetOwner,
name: repoName,
private: false,
description: `Website-Projekt ${repoName}`,
// Die Beschreibung ist der Projektname in Gitea (Abgleich und Webhook lesen ihn von dort)
description: description || `Website-Projekt ${repoName}`,
git_content: true,
}),
})
@@ -278,6 +330,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,6 +343,11 @@ 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)
@@ -297,6 +356,7 @@ export async function createProjectFromTemplate({
const repo = await generateRepoFromTemplate({
owner: targetOwner,
repoName: safeRepoName,
description: displayName || safeRepoName,
})
const repoFullName = repo.full_name || `${targetOwner}/${safeRepoName}`
@@ -324,7 +384,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 +396,23 @@ 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',
// 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 +421,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 +572,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,
@@ -573,7 +651,26 @@ export async function archiveRepo(projectId, archived = true) {
}
}
if (!archived && !repoMissing && doc.subdomain) {
if (archived) {
const updated = await updateDocument(config.collections.websiteProjects, projectId, {
archived: true,
status: 'archived',
updatedAt: new Date().toISOString(),
})
return { project: updated, archived: true }
}
// 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.
const redeploy = !repoMissing && Boolean(doc.subdomain)
let updated = await updateDocument(config.collections.websiteProjects, projectId, {
archived: false,
status: redeploy ? 'pending' : 'synced',
updatedAt: new Date().toISOString(),
})
if (redeploy) {
try {
const branch = (await getRepoInfo(repoFullName))?.defaultBranch || 'main'
const cfg = (await fetchPreviewConfig(repoFullName, branch)) || {
@@ -583,19 +680,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) {
updated = await updateDocument(config.collections.websiteProjects, projectId, {
status: 'deployed',
provisioningStatus: 'ready',
})
}
} catch (err) {
console.error('[archiveRepo] Redeploy bei Reaktivierung fehlgeschlagen:', err.message)
}
}
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
+1 -1
View File
@@ -149,7 +149,7 @@ 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
// 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()