Spec: Push-Zeitmail (Zeit für Git-Pushes per E-Mail eintragen)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-09-30 13:20:52 +00:00
1 parent d7c2027d3f
commit 6f4be30c18
1 file changed
+163
@@ -0,0 +1,163 @@
# Push-Zeitmail: Zeit für Git-Pushes per E-Mail eintragen
Stand: 2026-09-30 · Status: Entwurf zur Freigabe
## Ziel
Nach jedem Git-Push auf ein Projekt mit Ticket bekommt die pushende Person eine E-Mail, damit das
Nachtragen der Arbeitszeit nicht vergessen wird. Die Mail enthält alle Infos zu Ticket und Push und
bietet Schnell-Knöpfe, mit denen die Zeit ohne Login eingetragen wird, sowie einen Knopf
„Ticket öffnen“ (WOMS).
Erfolg: Kein Git-Push bleibt mehr ohne Zeit („orange“) liegen, weil das Eintragen in der Mail
zwei Klicks kostet.
## Entscheidungen (mit dem Nutzer abgestimmt)
- Mailprogramme im Team sind gemischt → **keine Formulare in der Mail**, sondern Links
(Schnell-Knöpfe), die in jedem Mailprogramm funktionieren.
- **Eine Mail pro Push** (keine Bündelung, keine Tagesmail).
- **Keine Erinnerung** nach 24 h (später ergänzbar).
- Umsetzung komplett im **Kundenportal-Server**; WOMS bleibt unverändert.
## Ablauf
1. Gitea-Webhook (`server/routes/webhook/gitea.js`, Phase 5) ruft `createGitPushWorksheet` auf.
2. Nur wenn das Ergebnis `created: true` ist (also nicht bei System-Pusher, Duplikat oder fehlendem
Ticket), wird `sendPushTimeMail(...)` aufgerufen – **nach** der Projekt-Sperre, ohne `await` auf
das Webhook-Ergebnis (fire-and-forget mit `.catch` → `console.error`). Ein Mailfehler ändert die
Webhook-Antwort nicht.
3. Die Mail geht an die zugeordnete Person (siehe Empfänger).
4. Klick auf einen Zeit-Knopf öffnet `GET {publicBaseUrl}/zeit/<token>?min=<minuten>`: eine
Bestätigungsseite mit vorausgefüllter Dauer.
5. Klick auf „Speichern“ sendet `POST /zeit/<token>` (Formular, `minutes`) → Arbeitsblatt wird
aktualisiert → Erfolgsseite.
Der GET speichert **nie** etwas: Virenscanner und Link-Vorschauen öffnen Mail-Links automatisch.
## Empfänger
Neue Funktion `findEmployeeForPusher(pusher)` in `server/services/pushTimeMail.js`:
- Kandidatennamen aus dem Webhook: `pusher.username`, `pusher.login`, `pusher.full_name`.
- Vergleich (ohne Groß-/Kleinschreibung, getrimmt) gegen jedes `employees`-Dokument: `shortcode`
und `displayName`. Das ist dieselbe Regel wie `canEditWorksheetTimes` in WOMS
(knso → KNSO → Kenso Grimm).
- Treffer mit leerer `email` oder kein Treffer → keine Mail, `console.warn` mit Pusher-Namen.
- Mehrere Treffer → erster Treffer mit E-Mail (Reihenfolge nach `$createdAt`).
## Mail
Neue Team-Vorlage `server/mail-templates/T15 Zeit eintragen.html` im Stil der bestehenden
T-Vorlagen (Kopfkommentar, Platzhalter, Dark-Mode-Regeln), gerendert mit `renderMail('T15', …)`
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)`
- 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“)
- 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)
- Knopfbeschriftung: 15 Min · 30 Min · 45 Min · 1 Std · 1,5 Std · 2 Std · 3 Std · Andere Zeit;
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:
- Inhalt: `{ w: worksheetId, e: employeeDocId, x: ablaufUnixSekunden }`, base64url-JSON.
- Signatur: HMAC-SHA256 mit `PUSH_TIME_SECRET` (neue Umgebungsvariable, min. 32 Zeichen; Fallback
`SESSION_SECRET` wie bei `PREVIEW_SESSION_SECRET`). Token = `<payload>.<signatur>`.
- Gültigkeit: 14 Tage.
- Prüfung mit `timingSafeEqual`; ungültig/abgelaufen/manipuliert → Seite „Link ungültig oder
abgelaufen“ + Link zu WOMS, HTTP 400/410.
- Der Token berechtigt ausschließlich dazu, die Zeit genau dieses Arbeitsblatts einzutragen. Kein
Login, keine Session, keine anderen Daten → die Mitarbeiter-2FA wird nicht umgangen.
- „Einmaligkeit“ entsteht über den Datenzustand: Hat das Arbeitsblatt schon eine Zeit, wird nicht
gespeichert (siehe unten).
## Bestätigungsseite (`server/routes/pushTime.js`)
Server-gerendertes HTML (kein SPA), WEBklar-Look (Farben/Schrift wie die Mailvorlagen), mobil
tauglich, `Cache-Control: no-store`, `X-Robots-Tag: noindex`, `Referrer-Policy: no-referrer`.
GET `/zeit/:token`:
- Token prüfen, Arbeitsblatt laden. Fehlt es → „Nicht mehr vorhanden“.
- Arbeitsblatt muss `serviceType === 'GIT'` sein, sonst ungültig.
- Schon erledigt (`needsTimeEntry` falsch, d. h. `startTime` gesetzt und `totalTime > 0`) →
„Schon eingetragen: 45 Min“ + „Ticket öffnen“.
- Sonst: Ticketnummer, Kunde, Repo, Push-Zeit, Commit-Titel; Zahlenfeld „Minuten“ (ganze Minuten,
1–720, vorausgefüllt aus `?min=`), Knopf „Speichern“, Link „Ticket öffnen“.
- Kein CSRF-Token nötig: Der POST wirkt nur mit dem geheimen Token in der URL und kann nur dieses
eine Arbeitsblatt ausfüllen.
POST `/zeit/:token` (urlencoded, `minutes`):
- Token prüfen, Minuten validieren (ganze Zahl 1–720, sonst Seite mit Fehlermeldung).
- Unter `withLocks(['ws-time:' + worksheetId])`: Arbeitsblatt neu laden; schon erledigt →
„Schon eingetragen“ (kein Überschreiben).
- Update wie „Zeit nachtragen“ in WOMS:
- `startDate`/`startTime` = Endzeit (`endDate`/`endTime`) minus Minuten, Berlin-Zeit, über
Mitternacht hinweg korrekt (Datum wechselt).
- `totalTime` = Minuten, `isComment` = `false`.
- Erfolgsseite: „Eingetragen: 1 Std auf #100118“ + „Ticket öffnen“.
- Rate-Limit: bestehender Limiter-Stil, z. B. 30 Anfragen / 15 min je IP für `/zeit`.
Hinweis Abo: Das Stundenkonto im Portal liest `totalTime` live – eine eingetragene Zeit auf einem
Abo-Ticket zählt damit sofort, genau wie beim Eintragen in WOMS.
## Dateien
- neu `server/services/pushTimeToken.js` – `createPushTimeToken`, `verifyPushTimeToken`
- neu `server/services/pushTimeMail.js` – `findEmployeeForPusher`, `buildPushTimeMail`,
`sendPushTimeMail`
- neu `server/services/pushTimeEntry.js` – `startFromEnd(endDate, endTime, minutes)`,
`needsTimeEntry(ws)`, `applyPushTime(worksheetId, minutes)`
- neu `server/routes/pushTime.js` – GET/POST `/zeit/:token`
- neu `server/mail-templates/T15 Zeit eintragen.html`
- geändert `server/routes/webhook/gitea.js` – Mailaufruf nach Phase 5
- geändert `server/services/mailer.js` – `sendStaffMail`
- geändert `server/config.js` – `pushTime.secret`, `pushTime.ttlDays`, `womsBaseUrl`
(`https://ticket.webklar.com`)
- geändert Server-Einstieg – Route `/zeit` einhängen (vor dem SPA-Fallback)
- geändert `docker-compose`/`.env` – `PUSH_TIME_SECRET` (setzt der Nutzer bzw. Fallback greift)
`createGitPushWorksheet` gibt zusätzlich die für die Mail nötigen Daten zurück (`woid`, Ticket-Doc,
Datum/Uhrzeit), damit nichts doppelt geladen wird.
## Fehlerfälle
| Fall | Verhalten |
|---|---|
| Pusher nicht zuordenbar / ohne E-Mail | keine Mail, `console.warn` |
| SMTP-Fehler | `console.error`, Webhook-Antwort unverändert |
| Arbeitsblatt in WOMS schon ausgefüllt | Seite „Schon eingetragen“ |
| Arbeitsblatt gelöscht | Seite „Nicht mehr vorhanden“ |
| Token manipuliert/abgelaufen | Seite „Link ungültig oder abgelaufen“ |
| Minuten ungültig | Formular erneut mit Fehlermeldung |
| Zwei Klicks gleichzeitig | Sperre je Arbeitsblatt, der zweite sieht „Schon eingetragen“ |
## Tests
- Unit: Token (gültig, abgelaufen, falsche Signatur, anderes Secret), `findEmployeeForPusher`
(Kürzel, Anzeigename, Groß/klein, kein Treffer, ohne E-Mail), `startFromEnd` (normal, über
Mitternacht, Monats-/Jahreswechsel), `needsTimeEntry`, Mail-Render T15 (alle Platzhalter ersetzt,
HTML-Escaping der Commit-Titel, Kürzung).
- Route: GET/POST mit gemocktem Appwrite (offen, erledigt, gelöscht, falscher Token, ungültige
Minuten, GET speichert nie).
- Webhook: Mail nur bei `created: true`, Mailfehler bricht Webhook nicht ab.
- Abnahme: ein echter Test-Push auf ein Test-Repo nach dem Livegang (mit Nutzer-OK).
## Nicht enthalten
Bündelung mehrerer Pushes, Erinnerungsmails, Bearbeiten schon eingetragener Zeiten per Mail,
Änderungen an WOMS.