Projektarten: Zuordnen-Ablauf serverseitig
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
df0c7a05e8
commit
36c7822783
3 files changed
+318
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
Reference in new issue
Block a user