Plan: Push-Zeitmail (5 Tasks); Spec: Betreff [Zeit], Commits als Text

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-09-30 13:26:59 +00:00
1 parent af7ecce78d
commit cdc814ae09
2 files changed
+226 -6

No files matched your search

@@ -0,0 +1,223 @@
# Push-Zeitmail Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Nach jedem Git-Push bekommt die pushende Person eine Mail mit Ticket-/Push-Infos und Schnell-Knöpfen, über die sie die Zeit ohne Login einträgt.
**Architecture:** Alles im Kundenportal-Server (Express, ESM). Der Gitea-Webhook schickt nach `createGitPushWorksheet` eine Mail (Vorlage T15) mit signierten Links. `GET /zeit/:token` zeigt eine server-gerenderte Bestätigungsseite, erst `POST /zeit/:token` schreibt `startDate/startTime/totalTime/isComment` ins Arbeitsblatt. WOMS bleibt unverändert.
**Tech Stack:** Node ≥ 18, Express 4, node-appwrite (über `server/services/appwriteAdmin.js`), nodemailer, `node:test` (`npm test` = `node --test test/*.test.js`), Test-Nachbauten `test/helpers/fakeAppwrite.js`, `test/support/mailSink.js`.
**Spec:** `docs/superpowers/specs/2026-09-30-push-zeitmail-design.md`
## Global Constraints
- Token-Gültigkeit **14 Tage**; Secret `PUSH_TIME_SECRET`, Fallback `SESSION_SECRET`.
- Knöpfe exakt: 15 Min · 30 Min · 45 Min · 1 Std · 1,5 Std · 2 Std · 3 Std · Andere Zeit (Minuten 15, 30, 45, 60, 90, 120, 180, leer).
- Minuten: ganze Zahl **1–720**.
- `GET /zeit/:token` speichert **nie**.
- Schon eingetragene Zeiten werden **nie** überschrieben.
- Ticket-Link: `https://ticket.webklar.com/tickets?woid=<woid>` (bestehender WOMS-Deep-Link).
- Mailfehler/Zuordnungsfehler ändern die Webhook-Antwort nicht.
- Rate-Limit `/zeit`: 30 Anfragen / 15 min je IP, danach HTTP 429.
- Seiten: `Cache-Control: no-store`, `X-Robots-Tag: noindex`, `Referrer-Policy: no-referrer`.
- Nur `git add <dateien>` (keine Sammel-Adds). Commit-Nachrichten deutsch, mit Zeile `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.
## Review Focus
1. **Link-Vorschau / Virenscanner öffnet alle 8 Knöpfe** → kein einziger Speichervorgang (Test in Task 4: 8× GET, Arbeitsblatt unverändert).
2. **Push über Mitternacht** (Push 00:20, 45 Min) → Start 23:35 am Vortag, auch an Monats-/Jahresgrenze (Task 2).
3. **Zeit wurde in WOMS schon eingetragen, dann Klick in der Mail** → „Schon eingetragen“, kein Überschreiben (Task 4).
4. **Commit-Titel mit `<script>` / `&`** → in Mail und Seite escaped (Task 3 + Task 4).
5. **Pusher-Name mit anderer Schreibweise** (`knso` vs. Kürzel `KNSO`, voller Name „Kenso Grimm“) → wird zugeordnet; ein unbekannter Pusher → keine Mail, kein Fehler (Task 3).
---
### Task 1: Signierter Token + Konfiguration
**Files:**
- Modify: `server/config.js` (neuer Block `pushTime`, Feld `womsBaseUrl`)
- Create: `server/services/pushTimeToken.js`
- Test: `test/pushTimeToken.test.js`
**Interfaces:**
- Produces:
- `config.pushTime = { secret: string, ttlDays: 14 }` (`process.env.PUSH_TIME_SECRET || process.env.SESSION_SECRET || ''`)
- `config.womsBaseUrl` = `(process.env.WOMS_PUBLIC_URL || 'https://ticket.webklar.com')` ohne Schrägstrich am Ende
- `createPushTimeToken({ worksheetId: string, employeeId: string }, now = Date.now()): string` → `<base64url(JSON {w,e,x})>.<base64url(HMAC-SHA256)>`, `x` = Ablauf in Unix-Sekunden
- `verifyPushTimeToken(token: string, now = Date.now()): { worksheetId, employeeId } | { error: 'invalid' | 'expired' }`
- [ ] **Step 1: Failing test** `test/pushTimeToken.test.js` (setzt `process.env.PUSH_TIME_SECRET = 'x'.repeat(40)` vor dynamischem Import):
- `'Token rundet sauber'`: verify(create({worksheetId:'ws1',employeeId:'e1'})) deepEqual `{ worksheetId:'ws1', employeeId:'e1' }`
- `'nach 14 Tagen abgelaufen'`: create mit `now=0`, verify mit `now = 14*86400*1000 + 1000` → `{ error: 'expired' }`; mit `now = 14*86400*1000 - 1000` → gültig
- `'manipulierte Nutzlast oder Signatur'`: ersetze `w` in der Nutzlast durch `'ws2'` (neu base64url, alte Signatur) → `{ error:'invalid' }`; letztes Zeichen der Signatur ändern → `invalid`; `''`, `'abc'`, `'a.b.c'` → `invalid`
- `'anderes Secret'`: Token, dessen Signatur mit einem anderen Schlüssel erzeugt wurde (`createHmac` im Test) → `invalid`
- [ ] **Step 2:** `node --test test/pushTimeToken.test.js` → FAIL (Modul fehlt)
- [ ] **Step 3:** Implementieren. Vergleich per `timingSafeEqual` nach Längenprüfung; leeres Secret → `create` wirft `Error('PUSH_TIME_SECRET fehlt')`, `verify` liefert `invalid`. In `config.js` beim bestehenden Secret-Check (Zeile ~282) eine Warnung ergänzen, wenn `pushTime.secret` kürzer als 32 Zeichen ist.
- [ ] **Step 4:** Test → PASS; `npm test` bleibt grün
- [ ] **Step 5:** Commit `Push-Zeitmail: signierter Token für Zeit-Links`
---
### Task 2: Zeit eintragen (Rechnung + Speichern)
**Files:**
- Create: `server/services/pushTimeEntry.js`
- Test: `test/pushTimeEntry.test.js`
**Interfaces:**
- Consumes: `getDocument`, `updateDocument` (appwriteAdmin), `withLocks` (keyedLock), `config.collections.worksheets`
- Produces:
- `startFromEnd(endDate: 'tt.mm.jjjj', endTime: 'hhmm', minutes: number): { startDate, startTime }` (reine Kalenderrechnung auf den Wand-Uhrzeit-Werten, per `Date.UTC`, keine Zeitzonen-Umrechnung)
- `needsTimeEntry(ws): boolean` → `ws.serviceType === 'GIT' && !(ws.startTime && Number(ws.totalTime) > 0)`
- `parseMinutes(raw): number | null` → ganze Zahl 1–720 aus String/Zahl, sonst `null` (`'1.5'`, `'0'`, `'721'`, `''`, `'abc'` → null)
- `formatMinutes(min): string` → `15 Min`, `1 Std`, `1,5 Std`, `2 Std 10 Min` (volle/halbe Stunden als `x Std`/`x,5 Std`, sonst `h Std m Min`, unter 60 `m Min`)
- `applyPushTime(worksheetId: string, minutes: number): Promise<{ status: 'saved' | 'already' | 'missing' | 'not_git', worksheet? }>`
- [ ] **Step 1: Failing tests** (reine Funktionen ohne Fake; `applyPushTime` mit `startFakeAppwrite` wie `test/gitWorksheet.test.js`):
- `startFromEnd('30.09.2026','1646',60)` → `{startDate:'30.09.2026',startTime:'1546'}`
- `startFromEnd('01.10.2026','0020',45)` → `{startDate:'30.09.2026',startTime:'2335'}`
- `startFromEnd('01.01.2027','0010',30)` → `{startDate:'31.12.2026',startTime:'2340'}`
- `startFromEnd('01.03.2028','0000',1)` → `{startDate:'29.02.2028',startTime:'2359'}`
- `needsTimeEntry`: `{serviceType:'GIT',startTime:'',totalTime:0}` → true; `{serviceType:'GIT',startTime:'1500',totalTime:45}` → false; `{serviceType:'Remote',...}` → false
- `parseMinutes`: `'45'`→45, `720`→720, und die null-Fälle oben
- `formatMinutes`: 15→`15 Min`, 60→`1 Std`, 90→`1,5 Std`, 130→`2 Std 10 Min`
- `applyPushTime` gegen Fake mit `ws1 {serviceType:'GIT',endDate:'30.09.2026',endTime:'1646',startTime:'',totalTime:0,isComment:true}`: 60 → `saved`, Dokument hat `startDate '30.09.2026'`, `startTime '1546'`, `totalTime 60`, `isComment false`; zweiter Aufruf mit 30 → `already`, `totalTime` bleibt 60; unbekannte ID → `missing`; Nicht-GIT-Blatt → `not_git`
- `'gleichzeitig: nur einer speichert'`: `Promise.all` zweier `applyPushTime` auf frisches Blatt → genau ein `saved`, ein `already`
- [ ] **Step 2:** Test → FAIL
- [ ] **Step 3:** Implementieren; `applyPushTime` unter `withLocks(['ws-time:' + worksheetId], …)`, Blatt darin neu laden, `getDocument`-Fehler → `missing`.
- [ ] **Step 4:** Test → PASS
- [ ] **Step 5:** Commit `Push-Zeitmail: Zeit für Git-Arbeitsblatt eintragen`
---
### Task 3: Mail T15, Zuordnung und Versand
**Files:**
- Create: `server/mail-templates/T15 Zeit eintragen.html`
- Modify: `server/services/mailer.js` (neu `sendStaffMail`)
- Create: `server/services/pushTimeMail.js`
- Modify: `test/mailTemplates.test.js` (`EXPECTED` um `'T15'`, Präfix-Regel um `Zeit`)
- Test: `test/pushTimeMail.test.js`
**Interfaces:**
- Consumes: `createPushTimeToken` (Task 1), `formatMinutes` (Task 2), `listDocuments`, `config.publicBaseUrl`, `config.womsBaseUrl`, `renderMail`
- Produces:
- `sendStaffMail(to: string, { template, values, blocks }): Promise<info>` in `mailer.js` – wie `sendCustomerMail`, aber Absender `TEAM_SENDER`, ohne BCC; wirft `Error('SMTP nicht konfiguriert')` ohne Transport
- `findEmployeeForPusher(pusher, employees): employee | null` (rein; Kandidaten `username`, `login`, `full_name`, verglichen getrimmt/klein gegen `shortcode` und `displayName`; erster Treffer mit nicht-leerer `email`, `employees` vorher nach `$createdAt` sortiert)
- `buildPushTimeMail({ employee, ticket, worksheet, repoFullName, branch, commits, pushedAt }): { template:'T15', values }`
- `sendPushTimeMail({ ticket, worksheet, repoFullName, branch, commits, pusher, pushedAt }): Promise<{ sent: boolean, reason?: string }>` – lädt `employees` (`Query.limit(100)`), ordnet zu, baut, versendet; fängt alle Fehler (`console.error`/`console.warn`), wirft nie
**Vorlage T15** – Kopie des Aufbaus von `T11 Antwort auf Rueckfrage.html` (Kopfkommentar, Dark-Mode-Styles, Logo, Outlook-Kommentar), Inhalt:
- Kopf: `T15 · Zeit für einen Git-Push eintragen`, `Betreff: [Zeit] #{{woid}} {{kunde}} – {{anzahl_commits}} Commit(s)`, `Empfänger: pushende Person (employees.email)`
- Platzhalter: `{{vorname}}, {{woid}}, {{kunde}}, {{ticket_titel}}, {{ticket_beschreibung}}, {{ticket_status}}, {{repo}}, {{branch}}, {{push_zeit}}, {{anzahl_commits}}, {{commits}}, {{link_15}}, {{link_30}}, {{link_45}}, {{link_60}}, {{link_90}}, {{link_120}}, {{link_180}}, {{link_andere}}, {{ticket_link}}`
- Keine optionalen Blöcke.
- Text: „Hallo {{vorname}}, du hast gerade auf {{repo}} ({{branch}}) gepusht. Wie lange hast du daran gearbeitet?“, Info-Tabelle Ticket/Kunde/Status/Beschreibung/Push-Zeit, Box „Commits“ mit `{{commits}}` (Text mit Zeilenumbrüchen), Knopfreihe (8 kleine Knöpfe, `.btn`-Stil, zwei Zeilen à 4), darunter heller Knopf „Ticket öffnen →“ auf `{{ticket_link}}`, Fußzeile „Der Link gilt 14 Tage und trägt nur die Zeit für diesen Push ein.“
- `<title>` = Betreff (Test prüft das).
**Werte in `buildPushTimeMail`:**
- `vorname` = erstes Wort von `employee.displayName`
- `kunde` = `ticket.customerName || 'ohne Kunde'`, `ticket_titel` = `ticket.topic || ''`, `ticket_beschreibung` = `ticket.details` auf 300 Zeichen + `…`
- `ticket_status` über Karte `{ Open:'Offen', Assigned:'Eingeplant', Occupied:'In Arbeit', Awaiting:'Wartet', 'In Test':'Prüfung', Closed:'Erledigt', Cancelled:'Abgebrochen' }`, sonst Rohwert
- `push_zeit` = `berlinDateTime(pushedAt)` als `tt.mm.jjjj hh:mm`
- `commits` = je Zeile `<sha7> <Titel (erste Zeile)>`, max. 10, dann `… und N weitere`; `anzahl_commits` = `commits.length`
- `link_<m>` = `${publicBaseUrl}/zeit/${token}?min=<m>`, `link_andere` = `${publicBaseUrl}/zeit/${token}`; ein Token je Mail
- `ticket_link` = `${womsBaseUrl}/tickets?woid=${woid}`
- [ ] **Step 1: Failing tests** `test/pushTimeMail.test.js`:
- `findEmployeeForPusher`: Mitarbeiter `[{$id:'k',displayName:'Kenso Grimm',shortcode:'KNSO',email:'kenso@webklar.com',$createdAt:'1'}, {$id:'a',displayName:'Andrej Stevanovski',shortcode:'ANDJ',email:'',$createdAt:'2'}]`: `{username:'knso'}` → k; `{full_name:'kenso grimm'}` → k; `{login:'ANDJ'}` → null (keine E-Mail); `{username:'fremd'}` → null
- `buildPushTimeMail` + `renderMail('T15', values)`: `problems` leer; Betreff `[Zeit] #100118 Amin Friseur – 2 Commit(s)`; HTML enthält alle 8 `/zeit/`-Links (7 mit `?min=` 15…180, einer ohne), `https://ticket.webklar.com/tickets?woid=100118`; Commit-Titel `<script>x</script> & Co` erscheint als `&lt;script&gt;` und `&amp; Co`; 12 Commits → 10 Zeilen + `… und 2 weitere`; der Token aus dem Link besteht `verifyPushTimeToken` mit `worksheetId` und `employeeId` der Eingabe
- `sendPushTimeMail` mit `startFakeAppwrite` (Seed `employees`) + `setMailTransportForTests(mailSink())`: Pusher `knso` → eine Mail an `kenso@webklar.com`, `from.name === 'WEBklar Kundenportal'`, kein BCC; Pusher `fremd` → `{sent:false}`, keine Mail; Transport, der wirft → `{sent:false}`, kein Throw
- [ ] **Step 2:** `node --test test/pushTimeMail.test.js test/mailTemplates.test.js` → FAIL
- [ ] **Step 3:** Vorlage, `sendStaffMail`, `pushTimeMail.js` schreiben; in `test/mailTemplates.test.js` `'T15'` an `EXPECTED` anhängen und Regex zu `/^\[(Abo|Speicher|Datenschutz|Zeit)\]/` (Meldung entsprechend).
- [ ] **Step 4:** beide Tests → PASS; `npm test` grün
- [ ] **Step 5:** Mail einmal rendern und als Datei ablegen (`node -e` mit Beispielwerten → `/root/reports/push-zeitmail/T15-vorschau.html`) zur Sichtprüfung
- [ ] **Step 6:** Commit `Push-Zeitmail: Vorlage T15, Zuordnung Pusher → Mitarbeiter, Versand`
---
### Task 4: Bestätigungsseite `/zeit/:token`
**Files:**
- Create: `server/routes/pushTime.js`
- Modify: `server/index.js` (vor `app.use(express.static(publicDir))`: `app.use('/zeit', express.urlencoded({ extended: false, limit: '4kb' }), pushTimeRoutes)`)
- Test: `test/pushTimeRoute.test.js`
**Interfaces:**
- Consumes: `verifyPushTimeToken` (Task 1), `applyPushTime`, `needsTimeEntry`, `parseMinutes`, `formatMinutes` (Task 2), `getDocument`, `escapeHtml` (mailTemplates), `config.womsBaseUrl`
- Produces: Express-Router mit `GET /:token` und `POST /:token`
Verhalten (alle Antworten HTML, `text/html; charset=utf-8`, die drei Sicherheits-Header aus den Global Constraints):
| Fall | Status | Seite enthält |
|---|---|---|
| Token `invalid` | 400 | „Link ungültig“ + Link WOMS-Startseite |
| Token `expired` | 410 | „Link abgelaufen“ + Link WOMS-Startseite |
| Blatt fehlt | 404 | „Nicht mehr vorhanden“ |
| Blatt nicht GIT | 400 | „Link ungültig“ |
| GET, offen | 200 | `#<woid>`, Kunde, Repo-Zeile aus `details` (1. Zeile), Push-Zeit, Commit-Titel, `<input type="number" name="minutes" min="1" max="720" step="1" value="<min oder leer>" required>`, Knopf „Speichern“, Link „Ticket öffnen“ |
| GET/POST, schon eingetragen | 200 | „Schon eingetragen: <formatMinutes(totalTime)>“ + „Ticket öffnen“ |
| POST, Minuten ungültig | 400 | Formular erneut + „Bitte ganze Minuten zwischen 1 und 720 eingeben.“ |
| POST, gespeichert | 200 | „Eingetragen: <formatMinutes> auf #<woid>“ + „Ticket öffnen“ |
| > 30 Anfragen / 15 min je IP | 429 | „Zu viele Anfragen“ |
- `?min=` wird nur übernommen, wenn `parseMinutes` einen Wert liefert.
- Ticket (`woid`, `customerName`) über `getDocument(workorders, ws.workorderId)`; Fehler → nur `#<ws.woid>` ohne Kunde.
- Seite: ein kleines Inline-CSS (Farben wie Mailvorlagen: Hintergrund `#f1f0ec`, Karte weiß, Knopf `#111111`), `<meta name="viewport">`, kein JS, kein externes Asset außer `/webklar-logo.png`. Alle eingesetzten Werte über `escapeHtml`.
- Rate-Limit als Map `ip → {count, resetAt}` im Router-Modul (IP aus `req.ip`), mit exportiertem `resetPushTimeRateLimitForTests()`.
- [ ] **Step 1: Failing tests** mit `startFakeAppwrite` (Seed: `workorders t1 {woid:'100118',customerName:'Amin Friseur'}`, GIT-Blätter `open1`, `done1 {startTime:'1500',totalTime:45}`, `remote1 {serviceType:'Remote'}`) und einer Express-App nur mit `urlencoded` + Router (Muster `test/giteaRoutes.test.js`):
- `'GET speichert nie'`: 8× GET auf `open1` mit `min` 15…180 und ohne → alle 200, danach `fake.get('worksheets','open1').totalTime === 0` und `startTime === ''`
- `'GET mit min=60 füllt das Feld vor'`: HTML matcht `value="60"`; `min=abc` → `value=""`
- `'POST speichert'`: `minutes=45` → 200, „Eingetragen: 45 Min auf #100118“, Blatt `totalTime 45`, `isComment false`
- `'POST ungültige Minuten'`: `0`, `721`, `1.5`, `` → 400, Blatt unverändert
- `'schon eingetragen'`: GET und POST(`minutes=30`) auf `done1` → „Schon eingetragen: 45 Min“, `totalTime` bleibt 45
- `'Token-Fehler'`: Müll → 400; abgelaufen (Token mit `now` vor 15 Tagen) → 410; unbekanntes Blatt → 404; `remote1` → 400
- `'Escaping'`: Blatt mit `details` `Git Push auf \`X\` (Branch: main)\n…\n- [abc1234](u): <b>fett</b>` → Seite enthält `&lt;b&gt;`, nicht `<b>fett`
- `'Header'`: `cache-control` = `no-store`, `x-robots-tag` = `noindex`, `referrer-policy` = `no-referrer`
- `'Rate-Limit'`: 31. Anfrage → 429
- [ ] **Step 2:** Test → FAIL
- [ ] **Step 3:** Router + Einbindung in `server/index.js` implementieren
- [ ] **Step 4:** Test → PASS; `npm test` grün
- [ ] **Step 5:** Commit `Push-Zeitmail: Bestätigungsseite /zeit`
---
### Task 5: Webhook verschickt die Mail
**Files:**
- Modify: `server/services/gitWorksheet.js` (Rückgabe erweitern)
- Modify: `server/routes/webhook/gitea.js` (nach Phase 5)
- Test: `test/gitWorksheet.test.js` (bestehend, erweitern), `test/pushTimeWebhook.test.js`
**Interfaces:**
- Consumes: `sendPushTimeMail` (Task 3)
- Produces: `createGitPushWorksheet` liefert bei Erfolg zusätzlich `ticket` (Workorder-Dokument) und `worksheet` (angelegtes Dokument): `{ created: true, wsid, worksheetId, ticket, worksheet }`. Die Webhook-JSON-Antwort gibt weiterhin nur `{ created, wsid, worksheetId }` bzw. das Skip-Objekt aus (Dokumente nicht in die Antwort).
- [ ] **Step 1: Failing tests**
- `test/gitWorksheet.test.js`, Test `'Push-Zeit in deutscher Zeit'`: zusätzlich `r.ticket.woid === '100115'` und `r.worksheet.$id === r.worksheetId`
- `test/pushTimeWebhook.test.js`: Aufbau wie `test/giteaRoutes.test.js` (Fake-Gitea + Fake-Appwrite, Seed mit Projekt inkl. `ticketId`, Workorder, Mitarbeiter `KNSO`), `setMailTransportForTests(mailSink())`:
- Push von `knso` → Antwort 200, `gitWorksheet.created === true`, Antwort enthält **kein** `ticket`-Feld, nach kurzem Warten (`await new Promise(r => setImmediate(r))` in Schleife bis `sent.length` oder 1 s) genau eine Mail an `kenso@webklar.com`, Betreff beginnt mit `[Zeit] #`
- derselbe Push erneut (Duplikat) → keine zweite Mail
- Push von `webklar-system` → keine Mail
- Transport wirft → Antwort trotzdem 200
- [ ] **Step 2:** Tests → FAIL
- [ ] **Step 3:** `gitWorksheet.js`: `ticket: workorder, worksheet` ergänzen. `gitea.js`: nach dem `withLocks`-Block
```js
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))
}
```
und in `res.json` `gitWorksheet` auf `{ created, wsid, worksheetId }` reduzieren, wenn `created`.
- [ ] **Step 4:** Tests → PASS; `npm test` komplett grün (bekannt flakig und unabhängig: `repoMove`)
- [ ] **Step 5:** Commit `Push-Zeitmail: Webhook schickt Mail nach neuem Git-Arbeitsblatt`
---
## Livegang (nach Freigabe durch den Nutzer, kein Task für Subagenten)
1. Optional `PUSH_TIME_SECRET` (≥ 32 Zeichen) in die Portal-`.env` setzen; sonst greift `SESSION_SECRET`. Achtung: späteres Setzen macht vorher verschickte Links ungültig.
2. Nutzer pusht `feature/push-zeitmail:main` (Deploy über Gitea-Webhook).
3. Prüfen: `curl -sI https://project.webklar.com/zeit/abc` → 400 mit `x-robots-tag: noindex`.
4. Test-Push auf ein Test-Repo mit Ticket → Mail kommt, Knopf → Seite → Speichern → in WOMS grün.
@@ -53,14 +53,14 @@ T-Vorlagen (Kopfkommentar, Platzhalter, Dark-Mode-Regeln), gerendert mit `render
und versendet über den bestehenden Mailer (neue Exportfunktion `sendStaffMail(to, message)` in
`mailer.js`, gleicher Transport/Absender wie die übrigen Team-Mails).
- Betreff: `Zeit eintragen: #{{woid}} {{kunde}} – {{anzahl_commits}} Commit(s)`
- Betreff: `[Zeit] #{{woid}} {{kunde}} – {{anzahl_commits}} Commit(s)` (Team-Betreffe tragen ein `[…]`-Präfix, siehe test/mailTemplates.test.js)
- Platzhalter:
- `{{vorname}}`: Name des Mitarbeiters
- Ticket: `{{woid}}`, `{{kunde}}`, `{{ticket_titel}}`, `{{ticket_beschreibung}}` (auf
300 Zeichen gekürzt), `{{ticket_status}}` (deutsche Bezeichnung)
- Push: `{{repo}}`, `{{branch}}`, `{{push_zeit}}` (dd.mm.yyyy hh:mm Berlin), `{{commits}}`
(Liste: Kurz-SHA + Titel, jeweils auf Gitea verlinkt; HTML-sicher erzeugt, max. 10 Einträge
+ „… und N weitere“)
(Text, je Zeile Kurz-SHA + Titel, max. 10 Einträge + „… und N weitere“; Platzhalter werden
HTML-escaped, daher ohne Links)
- Knöpfe: `{{link_15}}`, `{{link_30}}`, `{{link_45}}`, `{{link_60}}`, `{{link_90}}`,
`{{link_120}}`, `{{link_180}}`, `{{link_andere}}` (ohne `min`, Feld leer)
- `{{ticket_link}}` = `https://ticket.webklar.com/tickets?woid={{woid}}` (bestehender Deep-Link)
@@ -68,9 +68,6 @@ und versendet über den bestehenden Mailer (neue Exportfunktion `sendStaffMail(t
darunter ein zweiter, schlichter Knopf „Ticket öffnen“.
- Textfassung (für Mailprogramme ohne HTML) listet die Links als Zeilen.
Falls `renderMail` für Listen-Platzhalter kein HTML zulässt, wird die Commit-Liste als Text mit
Zeilenumbrüchen übergeben (wie `{{bericht}}` in T14).
## Token
Zustandslos und signiert, damit Links einen Neustart überleben: