Files

19 KiB
Raw Permalink Blame History

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: 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 #<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: 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

    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.