Projektarten: Mail T16 Repo zuordnen

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-09-30 18:31:07 +00:00
1 parent 08958dee77
commit b8a530958f
3 files changed
+261

No files matched your search

@@ -0,0 +1,57 @@
<!DOCTYPE html>
<html lang="de" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8">
<!--
T16 · Neues Repo zuordnen
Betreff: {{betreff_vorsatz}}Neues Repo zuordnen: {{repo}}
Absender: „WEBklar Kundenportal“ <Portal-Postfach>
Empfänger: die Person, die das Repo in Gitea angelegt hat (employees.email)
Platzhalter: {{betreff_vorsatz}}, {{vorname}}, {{repo}}, {{repo_link}}, {{angelegt_von}}, {{angelegt_am}}, {{zuordnen_link}}
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): erinnerung
betreff_vorsatz ist leer oder „Erinnerung: “; der Block erinnerung (Hinweis auf die Erinnerung nach 24 Stunden) erscheint nur in der Erinnerung; zuordnen_link öffnet im Ticketsystem die Zuordnung (Art, Kunde, Ticket).
-->
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
<title>{{betreff_vorsatz}}Neues Repo zuordnen: {{repo}}</title>
<!--[if mso]><style>table,td{font-family:Arial,Helvetica,sans-serif!important;}</style><![endif]-->
<style>
a{color:#111111;}
@media (max-width:620px){.w{width:100%!important;}.px{padding-left:24px!important;padding-right:24px!important;}.lbl{display:block!important;width:100%!important;padding-bottom:2px!important;border-bottom:0!important;}.val{display:block!important;width:100%!important;padding-top:0!important;}}
@media (prefers-color-scheme:dark){.bg{background:#161615!important;}.card{background:#1f1f1e!important;}.ink{color:#f2f1ee!important;}.mut{color:#a8a6a0!important;}.box{background:#2a2a28!important;border-color:#3a3a37!important;}.btn{background:#f2f1ee!important;}.btn a{color:#161615!important;}.btn2{background:#2a2a28!important;border-color:#3a3a37!important;}.btn2 a{color:#f2f1ee!important;}.rowline{border-color:#3a3a37!important;}}
</style>
</head>
<body style="margin:0;padding:0;background:#f1f0ec;">
<span style="display:none;max-height:0;overflow:hidden;opacity:0;mso-hide:all;font-size:1px;line-height:1px;color:#f1f0ec;">Neues Repo {{repo}} – ordne es kurz zu.&#8199;&#847;&#8199;&#847;&#8199;&#847;</span>
<table role="presentation" class="bg" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f1f0ec;">
<tr><td align="center" style="padding:32px 12px;">
<table role="presentation" class="w" width="600" cellpadding="0" cellspacing="0" border="0" style="width:600px;max-width:600px;">
<tr><td class="px" style="padding:0 40px 20px 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr>
<td><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td valign="middle" style="padding-right:10px;"><img src="https://project.webklar.com/webklar-logo.png" width="40" height="40" alt="WEBklar" style="display:block;width:40px;height:40px;border:0;outline:none;text-decoration:none;border-radius:10px;"></td><td valign="middle" class="ink" style="font-family:Arial,Helvetica,sans-serif;font-size:26px;font-weight:bold;letter-spacing:-1px;color:#111111;">WEBklar</td></tr></table></td>
<td align="right" class="mut" style="font-family:Arial,Helvetica,sans-serif;font-size:12px;color:#6b6964;">Intern · Ticketsystem</td>
</tr></table></td></tr>
<tr><td class="card" style="background:#ffffff;border-radius:16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr><td class="px" style="padding:44px 40px 8px 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td style="background:#fdebd3;border-radius:100px;padding:6px 12px;font-family:Arial,Helvetica,sans-serif;font-size:12px;font-weight:bold;color:#8a4b00;">Repo zuordnen</td></tr></table></td></tr>
<tr><td class="px ink" style="padding:16px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:28px;line-height:34px;mso-line-height-rule:exactly;font-weight:bold;letter-spacing:-0.5px;color:#111111;">Hallo {{vorname}}, zu welchem Projekt gehört dein neues Repo?</td></tr>
<tr><td class="px mut" style="padding:10px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Du hast {{repo}} in Gitea angelegt. Wähle im Ticketsystem die Art des Projekts und den Kunden aus – danach entsteht das Ticket.</td></tr>
<!-- WENN erinnerung -->
<tr><td class="px" style="padding:20px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:14px 20px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;">Das Repo ist seit gestern nicht zugeordnet. Das ist die einzige Erinnerung.</td></tr></table></td></tr>
<!-- ENDE erinnerung -->
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;mso-line-height-rule:exactly;">
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:10px 12px 10px 0;color:#6b6964;border-bottom:1px solid #eeece6;">Repo</td><td class="val ink rowline" valign="top" style="padding:10px 0;color:#111111;border-bottom:1px solid #eeece6;">{{repo}}</td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:10px 12px 10px 0;color:#6b6964;border-bottom:1px solid #eeece6;">Gitea</td><td class="val ink rowline" valign="top" style="padding:10px 0;color:#111111;border-bottom:1px solid #eeece6;"><a href="{{repo_link}}" style="color:#111111;">{{repo_link}}</a></td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:10px 12px 10px 0;color:#6b6964;border-bottom:1px solid #eeece6;">Angelegt von</td><td class="val ink rowline" valign="top" style="padding:10px 0;color:#111111;border-bottom:1px solid #eeece6;">{{angelegt_von}}</td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:10px 12px 10px 0;color:#6b6964;border-bottom:1px solid #eeece6;">Angelegt am</td><td class="val ink rowline" valign="top" style="padding:10px 0;color:#111111;border-bottom:1px solid #eeece6;">{{angelegt_am}}</td></tr>
</table></td></tr>
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td class="btn" bgcolor="#111111" style="background:#111111;border-radius:10px;"><a href="{{zuordnen_link}}" style="display:block;padding:12px 22px;font-family:Arial,Helvetica,sans-serif;font-size:15px;font-weight:bold;color:#ffffff;text-decoration:none;">Jetzt zuordnen &rarr;</a></td></tr></table></td></tr>
<tr><td style="padding:0 0 40px 0;font-size:0;line-height:0;">&nbsp;</td></tr>
</table>
</td></tr>
<tr><td class="px mut" align="center" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;">Du bekommst diese Mail, weil du das Repo angelegt hast. Es folgt höchstens eine Erinnerung nach 24 Stunden.</td></tr>
</table>
</td></tr>
</table>
</body>
</html>
+83
View File
@@ -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 }
}
}
+121
View File
@@ -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)
})