Projektarten: Mail T16 Repo zuordnen
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
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. ͏ ͏ ͏</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 →</a></td></tr></table></td></tr>
|
||||
<tr><td style="padding:0 0 40px 0;font-size:0;line-height:0;"> </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>
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
Reference in new issue
Block a user