diff --git a/server/mail-templates/T15 Zeit eintragen.html b/server/mail-templates/T15 Zeit eintragen.html new file mode 100644 index 0000000..3eaf866 --- /dev/null +++ b/server/mail-templates/T15 Zeit eintragen.html @@ -0,0 +1,60 @@ + + + + + + + + +[Zeit] #{{woid}} {{kunde}} – {{anzahl_commits}} Commit(s) + + + + +Push auf {{repo}} – trag kurz deine Zeit ein. ͏ ͏ ͏ + + + + + diff --git a/server/services/mailer.js b/server/services/mailer.js index a4b83c7..e037280 100644 --- a/server/services/mailer.js +++ b/server/services/mailer.js @@ -129,3 +129,22 @@ export async function sendCustomerMail(to, { attachments = [], ...message }) { attachments, }) } + +/** + * Mail an eine einzelne Person im Team aus einer T-Vorlage (z. B. T15 Push-Zeitmail), ohne BCC. + * Wirft bei fehlendem SMTP oder Versandfehler - der Aufrufer entscheidet, ob das schlimm ist. + * @param {string} to + * @param {{ template: string, values?: object, blocks?: object }} message + */ +export async function sendStaffMail(to, message) { + const mailer = getTransporter() + if (!mailer) throw new Error('SMTP nicht konfiguriert') + const mail = render(message) + return mailer.sendMail({ + from: `"${TEAM_SENDER}" <${config.smtp.from}>`, + to, + subject: mail.subject, + text: mail.text, + html: mail.html, + }) +} diff --git a/server/services/pushTimeMail.js b/server/services/pushTimeMail.js new file mode 100644 index 0000000..932e688 --- /dev/null +++ b/server/services/pushTimeMail.js @@ -0,0 +1,106 @@ +import { config } from '../config.js' +import { berlinDateTime } from './berlinTime.js' +import { listDocuments, Query } from './appwriteAdmin.js' +import { sendStaffMail } from './mailer.js' +import { createPushTimeToken } from './pushTimeToken.js' + +/** + * Push-Zeitmail (Vorlage T15): nach einem neuen Git-Push-Arbeitsblatt bekommt die pushende Person + * eine Mail mit Ticket- und Push-Infos und Schnell-Knoepfen zum Eintragen der Zeit (/zeit/). + */ + +const QUICK_MINUTES = [15, 30, 45, 60, 90, 120, 180] +const MAX_COMMITS = 10 +const DESCRIPTION_MAX = 300 + +// Deutsche Bezeichnungen wie in WOMS (lib/ticketStatus.js) +const STATUS_LABELS = { + Open: 'Offen', + Assigned: 'Eingeplant', + Occupied: 'In Arbeit', + Awaiting: 'Wartet', + 'In Test': 'Prüfung', + Closed: 'Erledigt', + Cancelled: 'Abgebrochen', +} + +const norm = (value) => String(value || '').trim().toLowerCase() + +/** + * Gitea-Pusher einem Mitarbeiter zuordnen - dieselbe Regel wie canEditWorksheetTimes in WOMS: + * Gitea-Name gegen Kuerzel oder Anzeigenamen, ohne Gross-/Kleinschreibung. Nur mit E-Mail-Adresse. + */ +export function findEmployeeForPusher(pusher, employees) { + const names = [pusher?.username, pusher?.login, pusher?.full_name].map(norm).filter(Boolean) + if (!names.length) return null + const sorted = [...(employees || [])].sort((a, b) => String(a.$createdAt || '').localeCompare(String(b.$createdAt || ''))) + return ( + sorted.find( + (emp) => String(emp.email || '').trim() && names.some((n) => n === norm(emp.shortcode) || n === norm(emp.displayName)) + ) || null + ) +} + +function commitLines(commits) { + const list = Array.isArray(commits) ? commits : [] + const lines = list.slice(0, MAX_COMMITS).map((commit) => { + const sha = String(commit.id || commit.sha || '').slice(0, 7) + const title = String(commit.message || '').trim().split('\n')[0] + return `${sha} ${title}`.trim() + }) + if (list.length > MAX_COMMITS) lines.push(`… und ${list.length - MAX_COMMITS} weitere`) + return lines.join('\n') || '(keine Commit-Details)' +} + +function shorten(text, max) { + const value = String(text || '').trim() + return value.length > max ? `${value.slice(0, max)}…` : value +} + +/** Werte fuer Vorlage T15 */ +export function buildPushTimeMail({ employee, ticket, worksheet, repoFullName, branch, commits, pushedAt = new Date() }) { + const token = createPushTimeToken({ worksheetId: worksheet.$id, employeeId: employee.$id }) + const base = `${config.publicBaseUrl}/zeit/${token}` + const { date, time } = berlinDateTime(pushedAt) + const woid = String(ticket?.woid || worksheet?.woid || '') + const links = Object.fromEntries(QUICK_MINUTES.map((m) => [`link_${m}`, `${base}?min=${m}`])) + + return { + template: 'T15', + values: { + vorname: String(employee.displayName || '').trim().split(/\s+/)[0] || 'du', + woid, + kunde: ticket?.customerName || 'ohne Kunde', + ticket_titel: ticket?.topic || '', + ticket_beschreibung: shorten(ticket?.details, DESCRIPTION_MAX), + ticket_status: STATUS_LABELS[ticket?.status] || ticket?.status || '', + repo: repoFullName, + branch, + push_zeit: `${date} ${time.slice(0, 2)}:${time.slice(2)}`, + anzahl_commits: Array.isArray(commits) ? commits.length : 0, + commits: commitLines(commits), + ...links, + link_andere: base, + ticket_link: `${config.womsBaseUrl}/tickets?woid=${encodeURIComponent(woid)}`, + }, + } +} + +/** Mail verschicken; wirft nie (Webhook darf daran nicht scheitern). */ +export async function sendPushTimeMail({ ticket, worksheet, repoFullName, branch, commits, pusher, pushedAt }) { + try { + const employees = await listDocuments(config.collections.employees, [Query.limit(100)]) + const employee = findEmployeeForPusher(pusher, employees) + if (!employee) { + const name = pusher?.username || pusher?.login || pusher?.full_name || '?' + console.warn(`[pushTimeMail] Pusher "${name}" keinem Mitarbeiter mit E-Mail zugeordnet – keine Zeitmail`) + return { sent: false, reason: 'no_employee' } + } + const mail = buildPushTimeMail({ employee, ticket, worksheet, repoFullName, branch, commits, pushedAt }) + await sendStaffMail(employee.email, mail) + return { sent: true } + } catch (err) { + console.error('[pushTimeMail] Versand fehlgeschlagen:', err.message) + return { sent: false, reason: err.message } + } +} diff --git a/test/mailTemplates.test.js b/test/mailTemplates.test.js index 4ca9cbd..7a6cb33 100644 --- a/test/mailTemplates.test.js +++ b/test/mailTemplates.test.js @@ -6,7 +6,7 @@ import { contractAttachmentFor, termsDocFor } from '../server/services/products. const DIR = new URL('../server/mail-templates/', import.meta.url) // K12, K14 und T12 (Wechsel ins Wartungs-Abo) aus Task 9, K13 und T13 (Zusatzspeicher) aus Task 8 (Fix 1); -// Endfassung der Texte in Task 12; T14 „Löschfristen“ aus Task 21 (F39) +// Endfassung der Texte in Task 12; T14 „Löschfristen“ aus Task 21 (F39); T15 Push-Zeitmail const EXPECTED = [ ...Array.from({ length: 11 }, (_, i) => `K${String(i + 1).padStart(2, '0')}`), 'K12', @@ -16,6 +16,7 @@ const EXPECTED = [ 'T12', 'T13', 'T14', + 'T15', ] const sampleValues = (tpl) => Object.fromEntries(tpl.placeholders.map((p) => [p, `Wert-${p}`])) @@ -31,8 +32,8 @@ for (const id of EXPECTED) { const source = readFileSync(new URL(tpl.file, DIR), 'utf8') const title = source.match(/([\s\S]*?)<\/title>/)[1] assert.equal(title, tpl.subject, 'Betreff im Kopf und <title> sind gleich') - // F34: Team-Hinweise tragen [Abo], [Speicher] (Zusatzspeicher-Aufgaben) oder [Datenschutz] (Loeschfristen) - assert.equal(id.startsWith('T'), /^\[(Abo|Speicher|Datenschutz)\]/.test(tpl.subject) || id === 'T01', 'Team-Hinweise ab T02 mit [Abo], [Speicher] oder [Datenschutz]') + // F34: Team-Hinweise tragen [Abo], [Speicher] (Zusatzspeicher-Aufgaben), [Datenschutz] (Loeschfristen) oder [Zeit] (Push-Zeitmail) + assert.equal(id.startsWith('T'), /^\[(Abo|Speicher|Datenschutz|Zeit)\]/.test(tpl.subject) || id === 'T01', 'Team-Hinweise ab T02 mit [Abo], [Speicher], [Datenschutz] oder [Zeit]') const used = new Set([...tpl.body.matchAll(/\{\{(\w+)\}\}/g), ...tpl.subject.matchAll(/\{\{(\w+)\}\}/g)].map((m) => m[1])) assert.deepEqual([...used].sort(), [...tpl.placeholders].sort(), 'deklarierte = verwendete Platzhalter') diff --git a/test/pushTimeMail.test.js b/test/pushTimeMail.test.js new file mode 100644 index 0000000..c13643f --- /dev/null +++ b/test/pushTimeMail.test.js @@ -0,0 +1,117 @@ +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 findEmployeeForPusher, buildPushTimeMail, sendPushTimeMail +let renderMail, verifyPushTimeToken, setMailTransportForTests + +const KENSO = { $id: 'k', displayName: 'Kenso Grimm', shortcode: 'KNSO', email: 'kenso@webklar.com', $createdAt: '2026-01-01T00:00:00.000Z' } +const ANDREJ = { $id: 'a', displayName: 'Andrej Stevanovski', shortcode: 'ANDJ', email: '', $createdAt: '2026-01-02T00:00:00.000Z' } + +const ticket = { $id: 't1', woid: '100118', customerName: 'Amin Friseur', topic: 'Website Amin Friseur', details: 'Neue Seite', status: 'Occupied' } +const worksheet = { $id: 'ws9', woid: '100118', serviceType: 'GIT' } +const commits = [ + { id: 'abc1234def', message: 'Startseite: Hero neu\n\nlanger Text' }, + { id: 'def5678abc', message: '<script>x</script> & Co' }, +] +const input = (extra = {}) => ({ + ticket, worksheet, repoFullName: 'WEBklar/amin-friseur', branch: 'main', commits, + pushedAt: new Date('2026-09-30T14:46:00Z'), ...extra, +}) + +before(async () => { + fake = await startFakeAppwrite({ seed: { employees: [ANDREJ, KENSO] } }) + Object.assign(process.env, { + APPWRITE_ENDPOINT: fake.endpoint, + APPWRITE_API_KEY: 'test-key', + PUSH_TIME_SECRET: 'z'.repeat(40), + }) + ;({ findEmployeeForPusher, buildPushTimeMail, sendPushTimeMail } = await import('../server/services/pushTimeMail.js')) + ;({ renderMail } = await import('../server/services/mailTemplates.js')) + ;({ verifyPushTimeToken } = await import('../server/services/pushTimeToken.js')) + ;({ setMailTransportForTests } = await import('../server/services/mailer.js')) +}) + +after(async () => { + setMailTransportForTests?.(null) + await fake.close() +}) + +test('findEmployeeForPusher: Kürzel, voller Name, ohne E-Mail, unbekannt', () => { + const employees = [ANDREJ, KENSO] + assert.equal(findEmployeeForPusher({ username: 'knso' }, employees)?.$id, 'k') + assert.equal(findEmployeeForPusher({ full_name: ' kenso grimm ' }, employees)?.$id, 'k') + assert.equal(findEmployeeForPusher({ login: 'ANDJ' }, employees), null) + assert.equal(findEmployeeForPusher({ username: 'fremd' }, employees), null) + assert.equal(findEmployeeForPusher({}, employees), null) +}) + +test('buildPushTimeMail: Betreff, Links, Escaping, gültiger Token', () => { + const mail = buildPushTimeMail({ employee: KENSO, ...input() }) + assert.equal(mail.template, 'T15') + const out = renderMail('T15', mail.values) + assert.deepEqual(out.problems, []) + assert.equal(out.subject, '[Zeit] #100118 Amin Friseur – 2 Commit(s)') + assert.match(out.html, /Hallo Kenso,/) + assert.match(out.html, /In Arbeit/) + assert.match(out.html, /30\.09\.2026 16:46/) + for (const m of [15, 30, 45, 60, 90, 120, 180]) { + assert.match(out.html, new RegExp(`/zeit/[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+\\?min=${m}"`)) + } + assert.match(out.html, /\/zeit\/[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+"/, 'Andere Zeit ohne min') + assert.match(out.html, /https:\/\/ticket\.webklar\.com\/tickets\?woid=100118/) + assert.match(out.html, /abc1234 Startseite: Hero neu/) + assert.doesNotMatch(out.html, /langer Text/) + assert.match(out.html, /<script>x<\/script> & Co/) + assert.doesNotMatch(out.html, /<script>/) + + const token = mail.values.link_andere.split('/zeit/')[1] + assert.deepEqual(verifyPushTimeToken(token), { worksheetId: 'ws9', employeeId: 'k' }) +}) + +test('buildPushTimeMail: 12 Commits → 10 Zeilen + Rest', () => { + const many = Array.from({ length: 12 }, (_, i) => ({ id: `${String(i).padStart(7, '0')}ffff`, message: `Commit ${i}` })) + const { values } = buildPushTimeMail({ employee: KENSO, ...input({ commits: many }) }) + const lines = values.commits.split('\n') + assert.equal(lines.length, 11) + assert.equal(lines.at(-1), '… und 2 weitere') + assert.equal(values.anzahl_commits, 12) +}) + +test('buildPushTimeMail: lange Beschreibung gekürzt, ohne Kunde', () => { + const { values } = buildPushTimeMail({ + employee: KENSO, + ...input({ ticket: { ...ticket, customerName: '', details: 'x'.repeat(400), status: 'Weird' } }), + }) + assert.equal(values.ticket_beschreibung.length, 301) + assert.ok(values.ticket_beschreibung.endsWith('…')) + assert.equal(values.kunde, 'ohne Kunde') + assert.equal(values.ticket_status, 'Weird') +}) + +test('sendPushTimeMail: an den Pusher, ohne BCC', async () => { + const sink = mailSink() + setMailTransportForTests(sink) + const r = await sendPushTimeMail({ ...input(), pusher: { username: '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].from.name, 'WEBklar Kundenportal') + assert.equal(sink.sent[0].bcc, undefined) +}) + +test('sendPushTimeMail: unbekannter Pusher → keine Mail', async () => { + const sink = mailSink() + setMailTransportForTests(sink) + const r = await sendPushTimeMail({ ...input(), pusher: { username: 'fremd' } }) + assert.equal(r.sent, false) + assert.equal(sink.sent.length, 0) +}) + +test('sendPushTimeMail: Versandfehler wird geschluckt', async () => { + setMailTransportForTests({ sendMail: async () => { throw new Error('SMTP kaputt') } }) + const r = await sendPushTimeMail({ ...input(), pusher: { username: 'knso' } }) + assert.equal(r.sent, false) +})