Files
Webklar-Kundenbereich/docs/superpowers/plans/2026-09-30-push-zeitmail.md
T

224 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.