diff --git a/server/mail-templates/T16 Repo zuordnen.html b/server/mail-templates/T16 Repo zuordnen.html new file mode 100644 index 0000000..eaae0f1 --- /dev/null +++ b/server/mail-templates/T16 Repo zuordnen.html @@ -0,0 +1,57 @@ + + + + + + + + +{{betreff_vorsatz}}Neues Repo zuordnen: {{repo}} + + + + +Neues Repo {{repo}} – ordne es kurz zu. ͏ ͏ ͏ + + + + + diff --git a/server/services/repoAssignMail.js b/server/services/repoAssignMail.js new file mode 100644 index 0000000..6d49ca4 --- /dev/null +++ b/server/services/repoAssignMail.js @@ -0,0 +1,83 @@ +import { config } from '../config.js' +import { berlinDateTime } from './berlinTime.js' +import { listDocuments, updateDocument, Query } from './appwriteAdmin.js' +import { sendStaffMail } from './mailer.js' +import { findEmployeeForPusher } from './pushTimeMail.js' + +/** + * Mail "Neues Repo zuordnen" (Vorlage T16): wer in Gitea ein Repo anlegt, bekommt eine Mail mit + * Knopf in die Zuordnung im Ticketsystem. Eine Erinnerung nach 24 Stunden, danach nichts mehr. + */ + +const DAY_MS = 24 * 60 * 60 * 1000 +const MAX_ATTEMPTS = 3 + +const time = (value) => { + const ms = new Date(value || '').getTime() + return Number.isNaN(ms) ? null : ms +} + +/** + * Soll fuer dieses Repo die erste Mail raus? Nur wenn das Projekt-Dokument jetzt neu angelegt wurde, + * das Gitea-Repo juenger als 24 h ist, noch keine Art gesetzt ist und noch keine Mail ging. + */ +export function shouldSendAssignMail(project, repo, { now = new Date(), createdNow = false } = {}) { + if (!createdNow) return false + if (project?.kind || project?.assignMailSentAt) return false + if (Number(project?.assignMailAttempts || 0) >= MAX_ATTEMPTS) return false + const created = time(repo?.created_at) + if (created === null) return false + return new Date(now).getTime() - created < DAY_MS +} + +/** Erinnerung faellig: Mail ging vor >= 24 h, Art fehlt noch, noch keine Erinnerung. */ +export function dueReminder(project, { now = new Date() } = {}) { + if (project?.kind || project?.assignReminderSentAt) return false + const sent = time(project?.assignMailSentAt) + if (sent === null) return false + return new Date(now).getTime() - sent >= DAY_MS +} + +/** Mail verschicken; wirft nie (Webhook/Sync darf daran nicht scheitern). */ +export async function sendRepoAssignMail(project, { giteaLogin, reminder = false, repoCreatedAt } = {}) { + try { + const employees = await listDocuments(config.collections.employees, [Query.limit(100)]) + const employee = findEmployeeForPusher({ username: giteaLogin }, employees) + if (!employee) { + console.warn(`[repoAssignMail] Gitea-Login "${giteaLogin || '?'}" keinem Mitarbeiter mit E-Mail zugeordnet – keine Mail`) + return { sent: false, reason: 'no_employee' } + } + + const created = new Date(repoCreatedAt || project.$createdAt || Date.now()) + const { date, time: hhmm } = berlinDateTime(Number.isNaN(created.getTime()) ? new Date() : created) + const attempts = Number(project.assignMailAttempts || 0) + 1 + const message = { + template: 'T16', + values: { + betreff_vorsatz: reminder ? 'Erinnerung: ' : '', + vorname: String(employee.displayName || '').trim().split(/\s+/)[0] || 'du', + repo: project.repoFullName, + repo_link: project.giteaRepoUrl, + angelegt_von: employee.displayName || giteaLogin, + angelegt_am: `${date} ${hhmm.slice(0, 2)}:${hhmm.slice(2)}`, + zuordnen_link: `https://ticket.webklar.com/projects/zuordnen/${project.$id}`, + }, + blocks: { erinnerung: Boolean(reminder) }, + } + + const patch = { assignMailAttempts: attempts } + try { + await sendStaffMail(employee.email, message) + } catch (err) { + console.error('[repoAssignMail] Versand fehlgeschlagen:', err.message) + await updateDocument(config.collections.websiteProjects, project.$id, patch) + return { sent: false, reason: err.message } + } + patch[reminder ? 'assignReminderSentAt' : 'assignMailSentAt'] = new Date().toISOString() + await updateDocument(config.collections.websiteProjects, project.$id, patch) + return { sent: true } + } catch (err) { + console.error('[repoAssignMail] Fehler:', err.message) + return { sent: false, reason: err.message } + } +} diff --git a/test/repoAssignMail.test.js b/test/repoAssignMail.test.js new file mode 100644 index 0000000..caf340b --- /dev/null +++ b/test/repoAssignMail.test.js @@ -0,0 +1,121 @@ +import { test, before, after } from 'node:test' +import assert from 'node:assert/strict' +import { startFakeAppwrite } from './helpers/fakeAppwrite.js' +import { mailSink } from './support/mailSink.js' + +let fake +let shouldSendAssignMail, dueReminder, sendRepoAssignMail +let renderMail, setMailTransportForTests, getDocument + +const NOW = new Date('2026-09-30T15:00:00Z') +const hoursAgo = (h) => new Date(NOW.getTime() - h * 3600_000).toISOString() + +const KENSO = { $id: 'k', displayName: 'Kenso Grimm', shortcode: 'KNSO', email: 'kenso@webklar.com', $createdAt: '2026-01-01T00:00:00.000Z' } +const projectDoc = (extra = {}) => ({ + $id: 'p1', + repoFullName: 'WEBklar/amin-friseur', + giteaRepoUrl: 'https://git.webklar.com/WEBklar/amin-friseur', + ...extra, +}) + +before(async () => { + fake = await startFakeAppwrite({ + seed: { + employees: [KENSO], + websiteProjects: [projectDoc(), projectDoc({ $id: 'p2' }), projectDoc({ $id: 'p3' })], + }, + }) + Object.assign(process.env, { APPWRITE_ENDPOINT: fake.endpoint, APPWRITE_API_KEY: 'test-key' }) + ;({ shouldSendAssignMail, dueReminder, sendRepoAssignMail } = await import('../server/services/repoAssignMail.js')) + ;({ renderMail } = await import('../server/services/mailTemplates.js')) + ;({ setMailTransportForTests } = await import('../server/services/mailer.js')) + ;({ getDocument } = await import('../server/services/appwriteAdmin.js')) +}) + +after(async () => { + setMailTransportForTests?.(null) + await fake.close() +}) + +const doc = (id) => getDocument(process.env.APPWRITE_COLLECTION_WEBSITE_PROJECTS || 'websiteProjects', id) + +test('shouldSendAssignMail: nur neu angelegt, < 24 h, ohne Art, < 3 Versuche', () => { + const repo = { created_at: hoursAgo(1) } + assert.equal(shouldSendAssignMail({}, repo, { now: NOW, createdNow: true }), true) + assert.equal(shouldSendAssignMail({}, repo, { now: NOW, createdNow: false }), false) + assert.equal(shouldSendAssignMail({}, { created_at: hoursAgo(25) }, { now: NOW, createdNow: true }), false) + assert.equal(shouldSendAssignMail({ kind: 'website' }, repo, { now: NOW, createdNow: true }), false) + assert.equal(shouldSendAssignMail({ assignMailAttempts: 3 }, repo, { now: NOW, createdNow: true }), false) + assert.equal(shouldSendAssignMail({ assignMailAttempts: 2 }, repo, { now: NOW, createdNow: true }), true) + assert.equal(shouldSendAssignMail({ assignMailSentAt: hoursAgo(1) }, repo, { now: NOW, createdNow: true }), false) + assert.equal(shouldSendAssignMail({}, {}, { now: NOW, createdNow: true }), false, 'ohne created_at keine Mail') +}) + +test('dueReminder: genau nach 24 h, ohne Art, ohne frühere Erinnerung', () => { + const base = { assignMailSentAt: hoursAgo(25) } + assert.equal(dueReminder(base, { now: NOW }), true) + assert.equal(dueReminder({ assignMailSentAt: hoursAgo(23) }, { now: NOW }), false) + assert.equal(dueReminder({}, { now: NOW }), false) + assert.equal(dueReminder({ ...base, kind: 'app' }, { now: NOW }), false) + assert.equal(dueReminder({ ...base, assignReminderSentAt: hoursAgo(1) }, { now: NOW }), false) +}) + +test('T16: rendert ohne Probleme, Betreff mit und ohne Erinnerung', () => { + const values = { + betreff_vorsatz: '', vorname: 'Kenso', repo: 'WEBklar/x', repo_link: 'https://git.webklar.com/WEBklar/x', + angelegt_von: 'Kenso Grimm', angelegt_am: '30.09.2026 17:00', zuordnen_link: 'https://ticket.webklar.com/projects/zuordnen/p1', + } + const plain = renderMail('T16', values) + assert.deepEqual(plain.problems, []) + assert.equal(plain.subject, 'Neues Repo zuordnen: WEBklar/x') + assert.doesNotMatch(plain.html, /einzige Erinnerung/) + const rem = renderMail('T16', { ...values, betreff_vorsatz: 'Erinnerung: ' }, { erinnerung: true }) + assert.equal(rem.subject, 'Erinnerung: Neues Repo zuordnen: WEBklar/x') + assert.match(rem.html, /einzige Erinnerung/) +}) + +test('sendRepoAssignMail: Mail mit exaktem Link, setzt assignMailSentAt + Versuche', async () => { + const sink = mailSink() + setMailTransportForTests(sink) + const r = await sendRepoAssignMail(projectDoc({ $id: 'p1', $createdAt: '2026-09-30T14:46:00Z' }), { giteaLogin: 'knso' }) + assert.deepEqual(r, { sent: true }) + assert.equal(sink.sent.length, 1) + assert.deepEqual(sink.sent[0].to.map((t) => t.address), ['kenso@webklar.com']) + assert.equal(sink.sent[0].subject, 'Neues Repo zuordnen: WEBklar/amin-friseur') + assert.match(sink.sent[0].html, /href="https:\/\/ticket\.webklar\.com\/projects\/zuordnen\/p1"/) + assert.match(sink.sent[0].html, /30\.09\.2026 16:46/) + const d = await doc('p1') + assert.ok(d.assignMailSentAt) + assert.equal(d.assignMailAttempts, 1) +}) + +test('sendRepoAssignMail: Erinnerung setzt assignReminderSentAt', async () => { + const sink = mailSink() + setMailTransportForTests(sink) + const r = await sendRepoAssignMail(projectDoc({ $id: 'p1', assignMailAttempts: 1 }), { giteaLogin: 'knso', reminder: true }) + assert.deepEqual(r, { sent: true }) + assert.match(sink.sent[0].subject, /^Erinnerung: Neues Repo zuordnen/) + const d = await doc('p1') + assert.ok(d.assignReminderSentAt) + assert.equal(d.assignMailAttempts, 2) +}) + +test('sendRepoAssignMail: unbekannter Login → no_employee, kein Zähler', async () => { + const sink = mailSink() + setMailTransportForTests(sink) + const r = await sendRepoAssignMail(projectDoc({ $id: 'p2' }), { giteaLogin: 'fremd' }) + assert.deepEqual(r, { sent: false, reason: 'no_employee' }) + assert.equal(sink.sent.length, 0) + const d = await doc('p2') + assert.equal(d.assignMailAttempts, undefined) + assert.equal(d.assignMailSentAt, undefined) +}) + +test('sendRepoAssignMail: SMTP-Fehler zählt Versuch, setzt kein SentAt, wirft nie', async () => { + setMailTransportForTests({ sendMail: async () => { throw new Error('SMTP kaputt') } }) + const r = await sendRepoAssignMail(projectDoc({ $id: 'p3', assignMailAttempts: 1 }), { giteaLogin: 'knso' }) + assert.equal(r.sent, false) + const d = await doc('p3') + assert.equal(d.assignMailAttempts, 2) + assert.equal(d.assignMailSentAt, undefined) +})