Projektarten: Zuordnen-Ablauf serverseitig

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-09-30 18:30:53 +00:00
1 parent df0c7a05e8
commit 36c7822783
3 files changed
+318

No files matched your search

+108
View File
@@ -0,0 +1,108 @@
import { config } from '../config.js'
import { getDocument, updateDocument } from './appwriteAdmin.js'
import { isKind, kindLabel, ticketTypeFor } from './projectKinds.js'
import { createTicketForProject, deleteTicket } from './ticketAdmin.js'
import { assignProject } from './projectAssignment.js'
import { updateWebsiteProject } from './giteaAdmin.js'
import { withLocks } from './keyedLock.js'
function fail(status, message, extra = {}) {
const err = new Error(message)
err.status = status
Object.assign(err, extra)
return err
}
async function loadOrNull(collection, id) {
if (!id) return null
return getDocument(collection, id).catch(() => null)
}
/**
* Schliesst das Zuordnen eines Projekts ab (WOMS-Flow): speichert die Projektart, legt bei
* Bedarf erst JETZT ein Ticket an, weist Kunde/Ticket zu und schaltet optional die Preview ein.
* Intern (`forCustomer: false`): nur Art speichern, `ticketId: null`.
*
* Serialisiert ueber den EIGENEN Schluessel 'zuordnen:<id>' (R6): `withLocks` ist nicht
* reentrant, und `assignProject` nimmt 'project:<id>' bzw. die Repo-Sperren selbst.
*
* `deps` ist nur fuer Tests gedacht (Gitea-Aufrufe ersetzen).
*/
export async function completeAssignment(
projectId,
{ kind, forCustomer, customerId = '', ticketId = '', newTicket = null, preview = null } = {},
{ staffName = '', now = new Date(), deps = {} } = {}
) {
const assign = deps.assignProject || assignProject
const updateProject = deps.updateWebsiteProject || updateWebsiteProject
return withLocks(['zuordnen:' + projectId], async () => {
const project = await getDocument(config.collections.websiteProjects, projectId).catch((err) => {
if (err.status === 404) throw fail(404, 'Projekt nicht gefunden')
throw err
})
if (project.kind) {
throw fail(409, 'bereits zugeordnet', { kindSetBy: project.kindSetBy || '', kindSetAt: project.kindSetAt || '' })
}
if (!isKind(kind)) throw fail(400, 'Unbekannte Projektart.')
const customer = !!forCustomer
let createdTicketId = null
let finalTicketId = null
if (customer) {
if (!(await loadOrNull(config.collections.customers, customerId))) throw fail(400, 'Kunde nicht gefunden.')
if (!!ticketId === !!newTicket) throw fail(400, 'Bitte genau eins angeben: vorhandenes Ticket oder neues Ticket.')
if (ticketId) {
const ticket = await loadOrNull(config.collections.workorders, ticketId)
if (!ticket) throw fail(400, 'Ticket nicht gefunden.')
if (ticket.customerId !== customerId) throw fail(400, 'Das Ticket gehört einem anderen Kunden.')
if (ticket.status === 'Closed' || ticket.status === 'Cancelled') throw fail(400, 'Das Ticket ist bereits abgeschlossen.')
finalTicketId = ticketId
} else {
const repoName = String(project.repoFullName || '').split('/').pop()
const topic = String(newTicket.topic || '').trim() || `${kindLabel(kind)} – ${repoName}`
const created = await createTicketForProject(
{
customerId,
topic,
type: ticketTypeFor(kind),
requestedBy: newTicket.requestedBy,
startDate: newTicket.startDate,
details: newTicket.details,
},
{ now }
)
createdTicketId = created.$id
finalTicketId = created.$id
}
try {
await assign(projectId, { customerId, ticketId: finalTicketId }, { now })
} catch (err) {
if (createdTicketId) await deleteTicket(createdTicketId).catch(() => {})
throw err
}
}
await updateDocument(config.collections.websiteProjects, projectId, {
kind,
forCustomer: customer,
kindSetBy: staffName || '',
kindSetAt: now.toISOString(),
})
const result = { ticketId: finalTicketId }
if (kind === 'website' && customer && preview?.subdomain) {
try {
await updateProject(projectId, { subdomain: preview.subdomain, projectType: 'preview' })
} catch (err) {
// Art und Zuordnung sind gespeichert - die Preview laesst sich nachtraeglich einschalten.
result.warnings = [`Die Preview konnte nicht eingeschaltet werden (${err.message}).`]
}
}
return result
})
}