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:' (R6): `withLocks` ist nicht * reentrant, und `assignProject` nimmt 'project:' 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 const warnings = [] 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 { const assigned = await assign(projectId, { customerId, ticketId: finalTicketId }, { now }) // assignProject schluckt Umzug-/Nachtrage-Fehler und meldet sie als Warnungen. if (assigned?.warnings?.length) warnings.push(...assigned.warnings) } 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. warnings.push(`Die Preview konnte nicht eingeschaltet werden (${err.message}).`) } } if (warnings.length) result.warnings = warnings return result }) }