From 7d4767a137bc65b263ecf979b49d6b5e107fe051 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 13:43:00 +0000 Subject: [PATCH] Push-Zeitmail: Webhook schickt Mail nach neuem Git-Arbeitsblatt Co-Authored-By: Claude Opus 5.5 --- server/routes/webhook/gitea.js | 17 ++++ server/services/gitWorksheet.js | 3 +- test/gitWorksheet.test.js | 3 + test/pushTimeWebhook.test.js | 143 ++++++++++++++++++++++++++++++++ 4 files changed, 165 insertions(+), 1 deletion(-) create mode 100644 test/pushTimeWebhook.test.js diff --git a/server/routes/webhook/gitea.js b/server/routes/webhook/gitea.js index 4526263..229fa28 100644 --- a/server/routes/webhook/gitea.js +++ b/server/routes/webhook/gitea.js @@ -10,6 +10,7 @@ import { } from '../../services/previewDeploy.js' import { ensureRepoPreviewConfig } from '../../services/giteaAdmin.js' import { createGitPushWorksheet } from '../../services/gitWorksheet.js' +import { sendPushTimeMail } from '../../services/pushTimeMail.js' import { ensureProjectTicket } from '../../services/ticketAdmin.js' import { invalidateProjectCache } from '../../services/projectInsights.js' import { withLocks } from '../../services/keyedLock.js' @@ -184,6 +185,22 @@ router.post('/gitea', async (req, res) => { ) } + // Phase 6: Push-Zeitmail an die pushende Person - nur fuer ein neu angelegtes Arbeitsblatt, + // ohne auf den Versand zu warten; ein Fehler aendert die Webhook-Antwort nicht. + if (gitWorksheet?.created) { + sendPushTimeMail({ + ticket: gitWorksheet.ticket, + worksheet: gitWorksheet.worksheet, + repoFullName: current, + branch, + commits, + pusher, + pushedAt: new Date(), + }).catch((err) => console.error('[webhook] Zeitmail:', err.message)) + const { created, wsid, worksheetId } = gitWorksheet + gitWorksheet = { created, wsid, worksheetId } + } + return res.json({ ok: true, repoFullName: current, diff --git a/server/services/gitWorksheet.js b/server/services/gitWorksheet.js index 4566bbf..d9d357e 100644 --- a/server/services/gitWorksheet.js +++ b/server/services/gitWorksheet.js @@ -137,6 +137,7 @@ export async function createGitPushWorksheet({ ID.unique() ) - return { created: true, wsid, worksheetId: worksheet.$id } + // ticket + worksheet fuer die Push-Zeitmail (routes/webhook/gitea.js), nicht fuer die Webhook-Antwort + return { created: true, wsid, worksheetId: worksheet.$id, ticket: workorder, worksheet } }) } diff --git a/test/gitWorksheet.test.js b/test/gitWorksheet.test.js index c1ec4b4..6ffc4d3 100644 --- a/test/gitWorksheet.test.js +++ b/test/gitWorksheet.test.js @@ -39,6 +39,9 @@ test('Push-Zeit in deutscher Zeit', async () => { 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/) + // Push-Zeitmail: Ticket und Arbeitsblatt kommen mit zurück (nicht doppelt laden) + assert.equal(r.ticket.woid, '100115') + assert.equal(r.worksheet.$id, r.worksheetId) }) test('zweiter Aufruf: Duplikat', async () => { assert.deepEqual(await createGitPushWorksheet({ ...base }), { skipped: true, reason: 'duplicate_commit' }) diff --git a/test/pushTimeWebhook.test.js b/test/pushTimeWebhook.test.js new file mode 100644 index 0000000..9be2ae1 --- /dev/null +++ b/test/pushTimeWebhook.test.js @@ -0,0 +1,143 @@ +import { test, before, after } from 'node:test' +import assert from 'node:assert/strict' +import { createHmac } from 'node:crypto' +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import express from 'express' +import { startFakeGitea } from './helpers/fakeGitea.js' +import { startFakeAppwrite } from './helpers/fakeAppwrite.js' +import { mailSink } from './support/mailSink.js' + +const WEBHOOK_TOKEN = 's3cret' +const REPO = 'WEBklar/amin-friseur' + +let gitea +let aw +let tmp +let originalCwd +let server +let baseUrl +let setMailTransportForTests + +function pushPayload({ sha, pusher }) { + return { + ref: 'refs/heads/main', + repository: { full_name: REPO, name: 'amin-friseur', html_url: `${gitea.baseUrl}/${REPO}` }, + commits: [{ id: sha, message: `Commit ${sha}`, author: { name: pusher }, url: `${gitea.baseUrl}/${REPO}/commit/${sha}` }], + pusher: { username: pusher }, + after: sha, + } +} + +async function webhook(payload) { + const bodyText = JSON.stringify(payload) + const signature = createHmac('sha256', WEBHOOK_TOKEN).update(bodyText).digest('hex') + const res = await fetch(`${baseUrl}/webhook/gitea`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Gitea-Signature': signature }, + body: bodyText, + }) + return { status: res.status, body: await res.json().catch(() => null) } +} + +/** Die Mail geht ohne await auf die Webhook-Antwort raus - kurz warten, bis sie da ist. */ +async function waitFor(check, ms = 1000) { + const end = Date.now() + ms + while (Date.now() < end) { + if (check()) return true + await new Promise((r) => setTimeout(r, 10)) + } + return check() +} + +before(async () => { + gitea = await startFakeGitea({ repos: [{ full_name: REPO }] }) + aw = await startFakeAppwrite({ + seed: { + websiteProjects: [{ $id: 'p1', repoFullName: REPO, projectName: 'amin-friseur', customerId: 'c1', ticketId: 't1' }], + workorders: [{ $id: 't1', woid: '100118', customerName: 'Amin Friseur', topic: 'Website', status: 'Open' }], + customers: [{ $id: 'c1', name: 'Amin Friseur' }], + employees: [{ $id: 'k', displayName: 'Kenso Grimm', shortcode: 'KNSO', email: 'kenso@webklar.com' }], + }, + }) + + originalCwd = process.cwd() + tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'push-zeitmail-')) + process.chdir(tmp) + + Object.assign(process.env, { + GITEA_BASE_URL: gitea.baseUrl, + GITEA_API_TOKEN: 'test-token', + GITEA_WEBHOOK_TOKEN: WEBHOOK_TOKEN, + APPWRITE_ENDPOINT: aw.endpoint, + APPWRITE_API_KEY: 'test-key', + PREVIEW_DEPLOY_ROOT: '', + TRAEFIK_DYNAMIC_DIR: '', + PUSH_TIME_SECRET: 'w'.repeat(40), + }) + + const [{ default: giteaWebhookRoutes }, mailer] = await Promise.all([ + import('../server/routes/webhook/gitea.js'), + import('../server/services/mailer.js'), + ]) + setMailTransportForTests = mailer.setMailTransportForTests + + const app = express() + app.use(express.json({ limit: '2mb', verify: (req, _res, buf) => { req.rawBody = buf } })) + app.use('/webhook', giteaWebhookRoutes) + 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 () => { + setMailTransportForTests?.(null) + if (server) await new Promise((resolve) => server.close(resolve)) + if (originalCwd) process.chdir(originalCwd) + await gitea?.close() + await aw?.close() + if (tmp) await fs.rm(tmp, { recursive: true, force: true }) +}) + +test('Push von knso → eine Zeitmail, Antwort ohne Dokumente', async () => { + const sink = mailSink() + setMailTransportForTests(sink) + const r = await webhook(pushPayload({ sha: 'aaaa1111', pusher: 'knso' })) + assert.equal(r.status, 200) + assert.equal(r.body.gitWorksheet.created, true) + assert.equal(r.body.gitWorksheet.ticket, undefined) + assert.equal(r.body.gitWorksheet.worksheet, undefined) + assert.ok(r.body.gitWorksheet.worksheetId) + assert.ok(await waitFor(() => sink.sent.length === 1)) + assert.deepEqual(sink.sent[0].to.map((t) => t.address), ['kenso@webklar.com']) + assert.match(sink.sent[0].subject, /^\[Zeit\] #100118 Amin Friseur/) + assert.match(sink.sent[0].html, new RegExp(`/zeit/[^"?]+\\?min=60`)) +}) + +test('derselbe Push erneut → keine zweite Mail', async () => { + const sink = mailSink() + setMailTransportForTests(sink) + const r = await webhook(pushPayload({ sha: 'aaaa1111', pusher: 'knso' })) + assert.equal(r.status, 200) + assert.equal(r.body.gitWorksheet.reason, 'duplicate_commit') + await new Promise((res) => setTimeout(res, 100)) + assert.equal(sink.sent.length, 0) +}) + +test('System-Konto → keine Mail', async () => { + const sink = mailSink() + setMailTransportForTests(sink) + const r = await webhook(pushPayload({ sha: 'bbbb2222', pusher: 'webklar-system' })) + assert.equal(r.status, 200) + await new Promise((res) => setTimeout(res, 100)) + assert.equal(sink.sent.length, 0) +}) + +test('Mailfehler ändert die Webhook-Antwort nicht', async () => { + setMailTransportForTests({ sendMail: async () => { throw new Error('SMTP kaputt') } }) + const r = await webhook(pushPayload({ sha: 'cccc3333', pusher: 'knso' })) + assert.equal(r.status, 200) + assert.equal(r.body.gitWorksheet.created, true) +})