diff --git a/docs/superpowers/plans/2026-09-30-push-zeitmail.md b/docs/superpowers/plans/2026-09-30-push-zeitmail.md new file mode 100644 index 0000000..685fb55 --- /dev/null +++ b/docs/superpowers/plans/2026-09-30-push-zeitmail.md @@ -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=` (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 ` (keine Sammel-Adds). Commit-Nachrichten deutsch, mit Zeile `Co-Authored-By: Claude Opus 5.5 `. + +## 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 ` & Co` erscheint als `<script>` und `& 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 | `#`, Kunde, Repo-Zeile aus `details` (1. Zeile), Push-Zeit, Commit-Titel, ``, Knopf „Speichern“, Link „Ticket öffnen“ | +| GET/POST, schon eingetragen | 200 | „Schon eingetragen: “ + „Ticket öffnen“ | +| POST, Minuten ungültig | 400 | Formular erneut + „Bitte ganze Minuten zwischen 1 und 720 eingeben.“ | +| POST, gespeichert | 200 | „Eingetragen: auf #“ + „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 `#` ohne Kunde. +- Seite: ein kleines Inline-CSS (Farben wie Mailvorlagen: Hintergrund `#f1f0ec`, Karte weiß, Knopf `#111111`), ``, 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): fett` → Seite enthält `<b>`, nicht `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. diff --git a/docs/superpowers/specs/2026-09-30-push-zeitmail-design.md b/docs/superpowers/specs/2026-09-30-push-zeitmail-design.md index 3fe3d19..5669e9b 100644 --- a/docs/superpowers/specs/2026-09-30-push-zeitmail-design.md +++ b/docs/superpowers/specs/2026-09-30-push-zeitmail-design.md @@ -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: