19 KiB
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, FallbackSESSION_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/:tokenspeichert 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 ZeileCo-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>.
Review Focus
- Link-Vorschau / Virenscanner öffnet alle 8 Knöpfe → kein einziger Speichervorgang (Test in Task 4: 8× GET, Arbeitsblatt unverändert).
- Push über Mitternacht (Push 00:20, 45 Min) → Start 23:35 am Vortag, auch an Monats-/Jahresgrenze (Task 2).
- Zeit wurde in WOMS schon eingetragen, dann Klick in der Mail → „Schon eingetragen“, kein Überschreiben (Task 4).
- Commit-Titel mit
<script>/&→ in Mail und Seite escaped (Task 3 + Task 4). - Pusher-Name mit anderer Schreibweise (
knsovs. KürzelKNSO, 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 BlockpushTime, FeldwomsBaseUrl) - 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 EndecreatePushTimeToken({ worksheetId: string, employeeId: string }, now = Date.now()): string→<base64url(JSON {w,e,x})>.<base64url(HMAC-SHA256)>,x= Ablauf in Unix-SekundenverifyPushTimeToken(token: string, now = Date.now()): { worksheetId, employeeId } | { error: 'invalid' | 'expired' }
-
Step 1: Failing test
test/pushTimeToken.test.js(setztprocess.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 mitnow=0, verify mitnow = 14*86400*1000 + 1000→{ error: 'expired' }; mitnow = 14*86400*1000 - 1000→ gültig'manipulierte Nutzlast oder Signatur': ersetzewin 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 (createHmacim Test) →invalid
-
Step 2:
node --test test/pushTimeToken.test.js→ FAIL (Modul fehlt) -
Step 3: Implementieren. Vergleich per
timingSafeEqualnach Längenprüfung; leeres Secret →createwirftError('PUSH_TIME_SECRET fehlt'),verifyliefertinvalid. Inconfig.jsbeim bestehenden Secret-Check (Zeile ~282) eine Warnung ergänzen, wennpushTime.secretkürzer als 32 Zeichen ist. -
Step 4: Test → PASS;
npm testbleibt 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, perDate.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, sonstnull('1.5','0','721','','abc'→ null)formatMinutes(min): string→15 Min,1 Std,1,5 Std,2 Std 10 Min(volle/halbe Stunden alsx Std/x,5 Std, sonsth Std m Min, unter 60m Min)applyPushTime(worksheetId: string, minutes: number): Promise<{ status: 'saved' | 'already' | 'missing' | 'not_git', worksheet? }>
-
Step 1: Failing tests (reine Funktionen ohne Fake;
applyPushTimemitstartFakeAppwritewietest/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',...}→ falseparseMinutes:'45'→45,720→720, und die null-Fälle obenformatMinutes: 15→15 Min, 60→1 Std, 90→1,5 Std, 130→2 Std 10 MinapplyPushTimegegen Fake mitws1 {serviceType:'GIT',endDate:'30.09.2026',endTime:'1646',startTime:'',totalTime:0,isComment:true}: 60 →saved, Dokument hatstartDate '30.09.2026',startTime '1546',totalTime 60,isComment false; zweiter Aufruf mit 30 →already,totalTimebleibt 60; unbekannte ID →missing; Nicht-GIT-Blatt →not_git'gleichzeitig: nur einer speichert':Promise.allzweierapplyPushTimeauf frisches Blatt → genau einsaved, einalready
-
Step 2: Test → FAIL
-
Step 3: Implementieren;
applyPushTimeunterwithLocks(['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(neusendStaffMail) - Create:
server/services/pushTimeMail.js - Modify:
test/mailTemplates.test.js(EXPECTEDum'T15', Präfix-Regel umZeit) - 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>inmailer.js– wiesendCustomerMail, aber AbsenderTEAM_SENDER, ohne BCC; wirftError('SMTP nicht konfiguriert')ohne TransportfindEmployeeForPusher(pusher, employees): employee | null(rein; Kandidatenusername,login,full_name, verglichen getrimmt/klein gegenshortcodeunddisplayName; erster Treffer mit nicht-leereremail,employeesvorher nach$createdAtsortiert)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ädtemployees(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 vonemployee.displayName -
kunde=ticket.customerName || 'ohne Kunde',ticket_titel=ticket.topic || '',ticket_beschreibung=ticket.detailsauf 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)alstt.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'}→ nullbuildPushTimeMail+renderMail('T15', values):problemsleer; 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> & Coerscheint als<script>und& Co; 12 Commits → 10 Zeilen +… und 2 weitere; der Token aus dem Link bestehtverifyPushTimeTokenmitworksheetIdundemployeeIdder EingabesendPushTimeMailmitstartFakeAppwrite(Seedemployees) +setMailTransportForTests(mailSink()): Pusherknso→ eine Mail ankenso@webklar.com,from.name === 'WEBklar Kundenportal', kein BCC; Pusherfremd→{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.jsschreiben; intest/mailTemplates.test.js'T15'anEXPECTEDanhängen und Regex zu/^\[(Abo|Speicher|Datenschutz|Zeit)\]/(Meldung entsprechend). -
Step 4: beide Tests → PASS;
npm testgrün -
Step 5: Mail einmal rendern und als Datei ablegen (
node -emit 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(vorapp.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 /:tokenundPOST /: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, wennparseMinuteseinen Wert liefert. -
Ticket (
woid,customerName) übergetDocument(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 überescapeHtml. -
Rate-Limit als Map
ip → {count, resetAt}im Router-Modul (IP ausreq.ip), mit exportiertemresetPushTimeRateLimitForTests(). -
Step 1: Failing tests mit
startFakeAppwrite(Seed:workorders t1 {woid:'100118',customerName:'Amin Friseur'}, GIT-Blätteropen1,done1 {startTime:'1500',totalTime:45},remote1 {serviceType:'Remote'}) und einer Express-App nur miturlencoded+ Router (Mustertest/giteaRoutes.test.js):'GET speichert nie': 8× GET aufopen1mitmin15…180 und ohne → alle 200, danachfake.get('worksheets','open1').totalTime === 0undstartTime === '''GET mit min=60 füllt das Feld vor': HTML matchtvalue="60";min=abc→value=""'POST speichert':minutes=45→ 200, „Eingetragen: 45 Min auf #100118“, BlatttotalTime 45,isComment false'POST ungültige Minuten':0,721,1.5, `` → 400, Blatt unverändert'schon eingetragen': GET und POST(minutes=30) aufdone1→ „Schon eingetragen: 45 Min“,totalTimebleibt 45'Token-Fehler': Müll → 400; abgelaufen (Token mitnowvor 15 Tagen) → 410; unbekanntes Blatt → 404;remote1→ 400'Escaping': Blatt mitdetailsGit Push auf \X` (Branch: main)\n…\n- abc1234: fett→ Seite enthält<b>, nichtfett`'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.jsimplementieren -
Step 4: Test → PASS;
npm testgrü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:
createGitPushWorksheetliefert bei Erfolg zusätzlichticket(Workorder-Dokument) undworksheet(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ätzlichr.ticket.woid === '100115'undr.worksheet.$id === r.worksheetIdtest/pushTimeWebhook.test.js: Aufbau wietest/giteaRoutes.test.js(Fake-Gitea + Fake-Appwrite, Seed mit Projekt inkl.ticketId, Workorder, MitarbeiterKNSO),setMailTransportForTests(mailSink()):- Push von
knso→ Antwort 200,gitWorksheet.created === true, Antwort enthält keinticket-Feld, nach kurzem Warten (await new Promise(r => setImmediate(r))in Schleife bissent.lengthoder 1 s) genau eine Mail ankenso@webklar.com, Betreff beginnt mit[Zeit] # - derselbe Push erneut (Duplikat) → keine zweite Mail
- Push von
webklar-system→ keine Mail - Transport wirft → Antwort trotzdem 200
- Push von
-
Step 2: Tests → FAIL
-
Step 3:
gitWorksheet.js:ticket: workorder, worksheetergänzen.gitea.js: nach demwithLocks-Blockif (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.jsongitWorksheetauf{ created, wsid, worksheetId }reduzieren, wenncreated. -
Step 4: Tests → PASS;
npm testkomplett 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)
- Optional
PUSH_TIME_SECRET(≥ 32 Zeichen) in die Portal-.envsetzen; sonst greiftSESSION_SECRET. Achtung: späteres Setzen macht vorher verschickte Links ungültig. - Nutzer pusht
feature/push-zeitmail:main(Deploy über Gitea-Webhook). - Prüfen:
curl -sI https://project.webklar.com/zeit/abc→ 400 mitx-robots-tag: noindex. - Test-Push auf ein Test-Repo mit Ticket → Mail kommt, Knopf → Seite → Speichern → in WOMS grün.