From 8d5de87ead33e2599cb64e0e4dbde910d1efb6c5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 20:01:26 +0000 Subject: [PATCH] =?UTF-8?q?Push-Arbeitsbl=C3=A4tter:=20deutsche=20Zeit,=20?= =?UTF-8?q?keine=20System-Pushes,=20keine=20Doppelten?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- server/services/gitWorksheet.js | 116 +++++++++++++++++--------------- test/gitWorksheet.test.js | 59 ++++++++++++++++ 2 files changed, 120 insertions(+), 55 deletions(-) create mode 100644 test/gitWorksheet.test.js diff --git a/server/services/gitWorksheet.js b/server/services/gitWorksheet.js index 066481c..4566bbf 100644 --- a/server/services/gitWorksheet.js +++ b/server/services/gitWorksheet.js @@ -1,4 +1,6 @@ import { config } from '../config.js' +import { berlinDateTime } from './berlinTime.js' +import { withLocks } from './keyedLock.js' import { listDocuments, getDocument, @@ -11,17 +13,10 @@ const WSID_START = 100000 // worksheets.details ist auf 4000 Zeichen begrenzt const DETAILS_MAX_LENGTH = 3900 -function formatToday() { - // dd.mm.yyyy - gleiches Format wie CreateWorksheetModal der Ticketsystem-SPA - const d = new Date() - const day = String(d.getDate()).padStart(2, '0') - const m = String(d.getMonth() + 1).padStart(2, '0') - return `${day}.${m}.${d.getFullYear()}` -} - -function formatTimeNow() { - const d = new Date() - return `${String(d.getHours()).padStart(2, '0')}${String(d.getMinutes()).padStart(2, '0')}` +/** Pusht das System-Konto selbst (automatische Commits) - dafuer kein Arbeitsblatt. */ +export function isSystemPusher(pusher) { + const name = pusher?.username || pusher?.login || '' + return name.toLowerCase() === config.gitea.systemUser.toLowerCase() } async function generateNextWsid() { @@ -72,9 +67,14 @@ export async function createGitPushWorksheet({ commits = [], pusher, commitSha, + pushedAt = new Date(), }) { if (!ticketId) return null + if (isSystemPusher(pusher)) { + return { skipped: true, reason: 'system_pusher' } + } + let workorder try { workorder = await getDocument(config.collections.workorders, ticketId) @@ -86,51 +86,57 @@ export async function createGitPushWorksheet({ const woid = workorder.woid if (!woid) return null - if (await gitWorksheetExists(woid, commitSha)) { - return { skipped: true, reason: 'duplicate_commit' } - } + // Gesperrt je Ticket+Commit, damit zwei gleichzeitige Zustellungen desselben Webhooks + // (Gitea liefert bei Bedarf erneut zu) nicht zweimal denselben Duplikat-Check bestehen + // und zwei Arbeitsblaetter anlegen. + const lockKey = `ws:${woid}:${(commitSha || '').slice(0, 7)}` + return withLocks([lockKey], async () => { + if (await gitWorksheetExists(woid, commitSha)) { + return { skipped: true, reason: 'duplicate_commit' } + } - const wsid = await generateNextWsid() - const pusherName = pusher?.full_name || pusher?.username || pusher?.login || 'Gitea' - const latestCommit = commits[commits.length - 1] || commits[0] - let details = [ - `Git Push auf \`${repoFullName}\` (Branch: ${branch})`, - `Commit: ${commitSha?.slice(0, 7) || latestCommit?.id?.slice(0, 7) || '?'}`, - '', - formatCommitLines(commits, repoFullName), - ].join('\n') - if (details.length > DETAILS_MAX_LENGTH) { - details = `${details.slice(0, DETAILS_MAX_LENGTH)}\n… (gekuerzt)` - } + const wsid = await generateNextWsid() + const pusherName = pusher?.full_name || pusher?.username || pusher?.login || 'Gitea' + const latestCommit = commits[commits.length - 1] || commits[0] + let details = [ + `Git Push auf \`${repoFullName}\` (Branch: ${branch})`, + `Commit: ${commitSha?.slice(0, 7) || latestCommit?.id?.slice(0, 7) || '?'}`, + '', + formatCommitLines(commits, repoFullName), + ].join('\n') + if (details.length > DETAILS_MAX_LENGTH) { + details = `${details.slice(0, DETAILS_MAX_LENGTH)}\n… (gekuerzt)` + } - // Endzeit = Push-Zeitpunkt. Startzeit bleibt leer und wird im Ticketsystem - // nachgetragen ("Zeit nachtragen") - erst dann zaehlt die Arbeitszeit. - const today = formatToday() - const worksheet = await createDocument( - config.collections.worksheets, - { - wsid, - woid: String(woid), - workorderId: ticketId, - employeeId: 'gitea', - employeeName: pusherName, - employeeShort: 'GIT', - serviceType: 'GIT', - oldStatus: workorder.status || '', - newStatus: workorder.status || '', - oldResponseLevel: workorder.response || '', - newResponseLevel: workorder.response || '', - totalTime: 0, - startDate: today, - startTime: '', - endDate: today, - endTime: formatTimeNow(), - details, - isComment: true, - createdAt: new Date().toISOString(), - }, - ID.unique() - ) + // Endzeit = Push-Zeitpunkt in deutscher Zeit (MESZ/MEZ). Startzeit bleibt leer und wird + // im Ticketsystem nachgetragen ("Zeit nachtragen") - erst dann zaehlt die Arbeitszeit. + const { date, time } = berlinDateTime(pushedAt) + const worksheet = await createDocument( + config.collections.worksheets, + { + wsid, + woid: String(woid), + workorderId: ticketId, + employeeId: 'gitea', + employeeName: pusherName, + employeeShort: 'GIT', + serviceType: 'GIT', + oldStatus: workorder.status || '', + newStatus: workorder.status || '', + oldResponseLevel: workorder.response || '', + newResponseLevel: workorder.response || '', + totalTime: 0, + startDate: date, + startTime: '', + endDate: date, + endTime: time, + details, + isComment: true, + createdAt: new Date().toISOString(), + }, + ID.unique() + ) - return { created: true, wsid, worksheetId: worksheet.$id } + return { created: true, wsid, worksheetId: worksheet.$id } + }) } diff --git a/test/gitWorksheet.test.js b/test/gitWorksheet.test.js new file mode 100644 index 0000000..c1ec4b4 --- /dev/null +++ b/test/gitWorksheet.test.js @@ -0,0 +1,59 @@ +import { test, before, after } from 'node:test' +import assert from 'node:assert/strict' +import { startFakeAppwrite } from './helpers/fakeAppwrite.js' + +let fake +let createGitPushWorksheet + +before(async () => { + fake = await startFakeAppwrite({ + seed: { + workorders: [{ $id: 't1', woid: '100115', status: 'Assigned' }], + worksheets: [{ $id: 'w0', wsid: '100077', woid: '100001', details: '' }], + }, + }) + Object.assign(process.env, { + APPWRITE_ENDPOINT: fake.endpoint, + APPWRITE_API_KEY: 'test-key', + }) + // config.js liest die Umgebung beim Import + ;({ createGitPushWorksheet } = await import('../server/services/gitWorksheet.js')) +}) + +after(async () => { + await fake.close() +}) + +const base = { + ticketId: 't1', repoFullName: 'WEBklar/D-ner-Nation', branch: 'main', + commits: [{ id: '26bc22ecf17e2e5a5a8794dcb4cf3892f802c51d', message: 'Döner Nation Website hinzufügen', author: { name: 'JUSN' } }], + pusher: { login: 'JUSN', username: 'JUSN', full_name: '' }, commitSha: '26bc22ecf17e2e5a5a8794dcb4cf3892f802c51d', +} + +test('Push-Zeit in deutscher Zeit', async () => { + const r = await createGitPushWorksheet({ ...base, pushedAt: new Date('2026-09-27T14:46:34Z') }) + assert.equal(r.created, true) + const ws = fake.get('worksheets', r.worksheetId) + assert.equal(ws.wsid, '100078') + assert.equal(ws.employeeName, 'JUSN') + assert.deepEqual([ws.startDate, ws.startTime, ws.endDate, ws.endTime], ['27.09.2026', '', '27.09.2026', '1646']) + assert.equal(ws.totalTime, 0); assert.equal(ws.isComment, true); assert.equal(ws.serviceType, 'GIT') + assert.match(ws.details, /Commit: 26bc22e/) +}) +test('zweiter Aufruf: Duplikat', async () => { + assert.deepEqual(await createGitPushWorksheet({ ...base }), { skipped: true, reason: 'duplicate_commit' }) +}) +test('System-Konto erzeugt kein Arbeitsblatt', async () => { + const before = fake.all('worksheets').length + for (const name of ['webklar-system', 'WEBKLAR-SYSTEM']) { + const r = await createGitPushWorksheet({ ...base, commitSha: 'aaaaaaa1', pusher: { login: name, username: name, full_name: '' } }) + assert.deepEqual(r, { skipped: true, reason: 'system_pusher' }) + } + assert.equal(fake.all('worksheets').length, before) +}) +test('gleichzeitige Zustellung ergibt ein Arbeitsblatt', async () => { + const args = { ...base, commitSha: 'bbbbbbb2' } + const results = await Promise.all([createGitPushWorksheet(args), createGitPushWorksheet(args)]) + assert.equal(results.filter((r) => r?.created).length, 1) + assert.equal(fake.all('worksheets').filter((w) => w.details.includes('Commit: bbbbbbb')).length, 1) +})