From 36c78227832ecaa2e9a6da16aea84d4a560d046d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 17:26:39 +0000 Subject: [PATCH] Projektarten: Zuordnen-Ablauf serverseitig Co-Authored-By: Claude Sonnet 5.5 --- server/routes/admin/websiteProjects.js | 16 ++ server/services/projectKindAssign.js | 108 ++++++++++++++ test/projectKindAssign.test.js | 194 +++++++++++++++++++++++++ 3 files changed, 318 insertions(+) create mode 100644 server/services/projectKindAssign.js create mode 100644 test/projectKindAssign.test.js diff --git a/server/routes/admin/websiteProjects.js b/server/routes/admin/websiteProjects.js index 9303569..a22bb46 100644 --- a/server/routes/admin/websiteProjects.js +++ b/server/routes/admin/websiteProjects.js @@ -5,6 +5,7 @@ import { archiveRepo, } from '../../services/giteaAdmin.js' import { assignProject, reconcileProject } from '../../services/projectAssignment.js' +import { completeAssignment } from '../../services/projectKindAssign.js' import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js' const router = Router() @@ -90,6 +91,21 @@ router.post('/:id/assign', async (req, res) => { } }) +// Zuordnen-Flow (WOMS): Art speichern, ggf. Ticket anlegen, Kunde/Ticket zuweisen, Preview +router.post('/:id/zuordnen', async (req, res) => { + const staffName = req.appwriteUser?.name || req.appwriteUser?.email || '' + try { + const result = await completeAssignment(req.params.id, req.body || {}, { staffName }) + return res.json(result) + } catch (err) { + if (err.status === 409) { + return res.status(409).json({ error: 'bereits zugeordnet', kindSetBy: err.kindSetBy || '', kindSetAt: err.kindSetAt || '' }) + } + const status = err.status || 500 + return res.status(status).json({ error: err.message }) + } +}) + // Umzug + Pushes eines bereits zugewiesenen Projekts erneut nachtragen router.post('/:id/pushes', async (req, res) => { try { diff --git a/server/services/projectKindAssign.js b/server/services/projectKindAssign.js new file mode 100644 index 0000000..fe85f32 --- /dev/null +++ b/server/services/projectKindAssign.js @@ -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:' (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 + + 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 + }) +} diff --git a/test/projectKindAssign.test.js b/test/projectKindAssign.test.js new file mode 100644 index 0000000..92db8d4 --- /dev/null +++ b/test/projectKindAssign.test.js @@ -0,0 +1,194 @@ +import { test, before, after } from 'node:test' +import assert from 'node:assert/strict' +import express from 'express' +import { startFakeAppwrite } from './helpers/fakeAppwrite.js' + +let aw +let server +let baseUrl +let completeAssignment + +const now = new Date('2026-09-30T10:00:00Z') +const newProj = (id, repo, extra = {}) => ({ $id: id, repoFullName: `JUSN/${repo}`, projectName: repo, customerId: '', ticketId: '', ...extra }) + +function stubs({ failAssign = false } = {}) { + const calls = { assign: [], update: [] } + return { + calls, + deps: { + assignProject: async (id, data) => { + calls.assign.push([id, data]) + if (failAssign) throw new Error('Gitea kaputt') + return { ok: true } + }, + updateWebsiteProject: async (id, data) => { + calls.update.push([id, data]) + return {} + }, + }, + } +} + +before(async () => { + aw = await startFakeAppwrite({ + seed: { + websiteProjects: [ + newProj('p1', 'intern'), + newProj('p2', 'vorhanden'), + newProj('p3', 'foo'), + newProj('p4', 'umzug'), + newProj('p5', 'fremd'), + newProj('p6', 'zu'), + newProj('p7', 'art'), + newProj('p8', 'fehler'), + newProj('p9', 'doppelt'), + newProj('p10', 'prev'), + newProj('p11', 'route'), + newProj('p12', 'schon', { kind: 'app', kindSetBy: 'Max', kindSetAt: '2026-09-29T08:00:00.000Z' }), + ], + workorders: [ + { $id: 't1', woid: '100115', customerId: 'c1', status: 'Open' }, + { $id: 't2', woid: '100116', customerId: 'c2', status: 'Open' }, + { $id: 't3', woid: '100117', customerId: 'c1', status: 'Closed' }, + ], + customers: [{ $id: 'c1', name: 'Döner Nation', location: 'Berlin' }, { $id: 'c2', name: 'Andere' }], + }, + }) + Object.assign(process.env, { APPWRITE_ENDPOINT: aw.endpoint, APPWRITE_API_KEY: 'test-key' }) + ;({ completeAssignment } = await import('../server/services/projectKindAssign.js')) + const { default: routes } = await import('../server/routes/admin/websiteProjects.js') + const app = express() + app.use(express.json()) + app.use('/api/admin/website-projects', routes) + server = await new Promise((resolve) => { + const s = app.listen(0, '127.0.0.1', () => resolve(s)) + }) + baseUrl = `http://127.0.0.1:${server.address().port}` +}) + +after(async () => { + await new Promise((resolve) => server.close(resolve)) + await aw.close() +}) + +const opts = (deps) => ({ staffName: 'Kenso', now, deps }) + +test('intern speichert nur Art', async () => { + const s = stubs() + const r = await completeAssignment('p1', { kind: 'other', forCustomer: false }, opts(s.deps)) + assert.deepEqual(r, { ticketId: null }) + const p = aw.get('websiteProjects', 'p1') + assert.equal(p.kind, 'other') + assert.equal(p.forCustomer, false) + assert.equal(p.kindSetBy, 'Kenso') + assert.equal(p.kindSetAt, now.toISOString()) + assert.equal(s.calls.assign.length, 0) +}) + +test('vorhandenes Ticket wird zugewiesen', async () => { + const s = stubs() + const before = aw.all('workorders').length + const r = await completeAssignment('p2', { kind: 'webapp', forCustomer: true, customerId: 'c1', ticketId: 't1' }, opts(s.deps)) + assert.deepEqual(r, { ticketId: 't1' }) + assert.deepEqual(s.calls.assign, [['p2', { customerId: 'c1', ticketId: 't1' }]]) + assert.equal(aw.all('workorders').length, before) + assert.equal(aw.get('websiteProjects', 'p2').kind, 'webapp') + assert.equal(aw.get('websiteProjects', 'p2').forCustomer, true) +}) + +test('neues Ticket nur beim Abschliessen, Thema "Website – foo"', async () => { + const s = stubs() + const before = aw.all('workorders').length + const r = await completeAssignment( + 'p3', + { kind: 'website', forCustomer: true, customerId: 'c1', newTicket: { topic: '', requestedBy: 'Max', startDate: '2026-10-05', details: 'd' } }, + opts(s.deps) + ) + assert.equal(aw.all('workorders').length, before + 1) + const t = aw.get('workorders', r.ticketId) + assert.equal(t.topic, 'Website – foo') + assert.equal(t.type, 'Webpage') + assert.equal(t.status, 'Open') + assert.equal(t.startDate, '05.10.2026') + assert.deepEqual(s.calls.assign, [['p3', { customerId: 'c1', ticketId: r.ticketId }]]) +}) + +test('Migration ergibt Ticket-Art Migration', async () => { + const s = stubs() + const r = await completeAssignment('p4', { kind: 'migration', forCustomer: true, customerId: 'c1', newTicket: { topic: 'Umzug X' } }, opts(s.deps)) + const t = aw.get('workorders', r.ticketId) + assert.equal(t.type, 'Migration') + assert.equal(t.topic, 'Umzug X') +}) + +test('Ticket anderer Kunde ⇒ 400', async () => { + const s = stubs() + await assert.rejects( + () => completeAssignment('p5', { kind: 'website', forCustomer: true, customerId: 'c1', ticketId: 't2' }, opts(s.deps)), + (e) => e.status === 400 + ) + assert.equal(aw.get('websiteProjects', 'p5').kind, undefined) +}) + +test('Ticket Closed ⇒ 400', async () => { + const s = stubs() + await assert.rejects( + () => completeAssignment('p6', { kind: 'website', forCustomer: true, customerId: 'c1', ticketId: 't3' }, opts(s.deps)), + (e) => e.status === 400 + ) +}) + +test('unbekannte Art ⇒ 400', async () => { + const s = stubs() + await assert.rejects( + () => completeAssignment('p7', { kind: 'nix', forCustomer: false }, opts(s.deps)), + (e) => e.status === 400 + ) + assert.equal(aw.get('websiteProjects', 'p7').kind, undefined) +}) + +test('assignProject wirft ⇒ neues Ticket geloescht, kind leer', async () => { + const s = stubs({ failAssign: true }) + const before = aw.all('workorders').length + await assert.rejects( + () => completeAssignment('p8', { kind: 'website', forCustomer: true, customerId: 'c1', newTicket: { topic: 'x' } }, opts(s.deps)), + /Gitea kaputt/ + ) + assert.equal(aw.all('workorders').length, before) + assert.equal(aw.get('websiteProjects', 'p8').kind, undefined) +}) + +test('zweiter Aufruf ⇒ 409, nur ein Ticket', async () => { + const s = stubs() + const before = aw.all('workorders').length + const body = { kind: 'app', forCustomer: true, customerId: 'c1', newTicket: { topic: 'eins' } } + await completeAssignment('p9', body, opts(s.deps)) + await assert.rejects( + () => completeAssignment('p9', body, opts(s.deps)), + (e) => e.status === 409 && e.kindSetBy === 'Kenso' + ) + assert.equal(aw.all('workorders').length, before + 1) +}) + +test('Website mit Subdomain ⇒ updateWebsiteProject(preview)', async () => { + const s = stubs() + await completeAssignment('p10', { kind: 'website', forCustomer: true, customerId: 'c1', ticketId: 't1', preview: { subdomain: 'prev' } }, opts(s.deps)) + assert.deepEqual(s.calls.update, [['p10', { subdomain: 'prev', projectType: 'preview' }]]) +}) + +test('Route: ohne Staff-JWT ⇒ 401, mit JWT intern 200, bereits zugeordnet 409, unbekannt 404', async () => { + const post = (id, body, jwt) => + fetch(`${baseUrl}/api/admin/website-projects/${id}/zuordnen`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...(jwt ? { Authorization: `Bearer ${jwt}` } : {}) }, + body: JSON.stringify(body), + }).then(async (r) => ({ status: r.status, body: await r.json() })) + + assert.equal((await post('p11', { kind: 'other', forCustomer: false })).status, 401) + const ok = await post('p11', { kind: 'other', forCustomer: false }, 'jwt-admin') + assert.deepEqual([ok.status, ok.body], [200, { ticketId: null }]) + assert.ok(aw.get('websiteProjects', 'p11').kindSetAt) // Fake-Nutzer hat weder name noch email + const again = await post('p12', { kind: 'other', forCustomer: false }, 'jwt-admin') + assert.deepEqual([again.status, again.body], [409, { error: 'bereits zugeordnet', kindSetBy: 'Max', kindSetAt: '2026-09-29T08:00:00.000Z' }]) + assert.equal((await post('nix', { kind: 'other', forCustomer: false }, 'jwt-admin')).status, 404) +})