Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4fab31603 | ||
|
|
6643283c6f | ||
|
|
7d4767a137 | ||
|
|
a28ae21389 | ||
|
|
1a1005d099 | ||
|
|
ec3f22d98e | ||
|
|
5b7365b99e | ||
|
|
cdc814ae09 | ||
|
|
af7ecce78d | ||
|
|
6f4be30c18 |
No files matched your search
@@ -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=<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 `<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 | `#<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 `<b>`, 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.
|
||||
@@ -0,0 +1,160 @@
|
||||
# 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] #{{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}}`
|
||||
(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)
|
||||
- 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.
|
||||
|
||||
## 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: einfacher In-Memory-Zähler, 30 Anfragen / 15 min je IP für `/zeit` (danach HTTP 429).
|
||||
|
||||
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.
|
||||
@@ -121,6 +121,14 @@ export const config = {
|
||||
},
|
||||
|
||||
publicBaseUrl: (process.env.PORTAL_PUBLIC_URL || 'https://project.webklar.com').replace(/\/$/, ''),
|
||||
// Ticketsystem (WOMS), z. B. fuer den Link "Ticket oeffnen" in der Push-Zeitmail
|
||||
womsBaseUrl: (process.env.WOMS_PUBLIC_URL || 'https://ticket.webklar.com').replace(/\/$/, ''),
|
||||
|
||||
// Push-Zeitmail: signierte Links zum Eintragen der Zeit eines Git-Pushes (ohne Login)
|
||||
pushTime: {
|
||||
secret: process.env.PUSH_TIME_SECRET || process.env.SESSION_SECRET || '',
|
||||
ttlDays: 14,
|
||||
},
|
||||
|
||||
products,
|
||||
mailStorage,
|
||||
@@ -281,6 +289,9 @@ export function assertServerConfig() {
|
||||
if (!config.sessionSecret || config.sessionSecret.length < 32) {
|
||||
console.warn('[config] SESSION_SECRET fehlt oder ist zu kurz (min. 32 Zeichen).')
|
||||
}
|
||||
if (!config.pushTime.secret || config.pushTime.secret.length < 32) {
|
||||
console.warn('[config] PUSH_TIME_SECRET (bzw. SESSION_SECRET) fehlt oder ist zu kurz – Zeit-Links aus Push-Mails funktionieren nicht.')
|
||||
}
|
||||
if (!config.appwrite.apiKey) {
|
||||
console.warn('[config] APPWRITE_API_KEY fehlt – DB-Zugriff und Webhook schlagen fehl.')
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import takeoverCreditRoutes from './routes/takeoverCredits.js'
|
||||
import { purgeExpiredUploads } from './services/takeoverCredits.js'
|
||||
import { purgePendingChatUploads } from './services/chatAttachments.js'
|
||||
import giteaWebhookRoutes from './routes/webhook/gitea.js'
|
||||
import pushTimeRoutes from './routes/pushTime.js'
|
||||
import adminGiteaRoutes from './routes/admin/gitea.js'
|
||||
import adminWebsiteProjectsRoutes from './routes/admin/websiteProjects.js'
|
||||
import adminCustomersRoutes from './routes/admin/customers.js'
|
||||
@@ -68,6 +69,8 @@ app.use('/api/admin/invoice-clients', adminInvoiceClientsRoutes)
|
||||
app.use('/api/admin/preview-open', adminPreviewOpenRoutes)
|
||||
app.use('/api/admin/switch-link', adminSwitchLinkRoutes)
|
||||
app.use('/webhook', giteaWebhookRoutes)
|
||||
// Push-Zeitmail: Zeit eines Git-Pushes per Link aus der Mail eintragen (ohne Login, signierter Token)
|
||||
app.use('/zeit', express.urlencoded({ extended: false, limit: '4kb' }), pushTimeRoutes)
|
||||
|
||||
app.get('/api/health', async (_req, res) => {
|
||||
const dbAccess = await verifyDatabaseAccess()
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de" xmlns="http://www.w3.org/1999/xhtml">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<!--
|
||||
T15 · Zeit für einen Git-Push eintragen
|
||||
Betreff: [Zeit] #{{woid}} {{kunde}} – {{anzahl_commits}} Commit(s)
|
||||
Absender: „WEBklar Kundenportal“ <Portal-Postfach>
|
||||
Empfänger: die Person, die gepusht hat (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}}
|
||||
commits ist Text (je Zeile Kurz-SHA und Titel); die link_*-Adressen öffnen die Bestätigungsseite /zeit/… (speichert erst nach Klick auf „Speichern“).
|
||||
-->
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<meta name="supported-color-schemes" content="light dark">
|
||||
<title>[Zeit] #{{woid}} {{kunde}} – {{anzahl_commits}} Commit(s)</title>
|
||||
<!--[if mso]><style>table,td{font-family:Arial,Helvetica,sans-serif!important;}</style><![endif]-->
|
||||
<style>
|
||||
a{color:#111111;}
|
||||
@media (max-width:620px){.w{width:100%!important;}.px{padding-left:24px!important;padding-right:24px!important;}.lbl{display:block!important;width:100%!important;padding-bottom:2px!important;border-bottom:0!important;}.val{display:block!important;width:100%!important;padding-top:0!important;}}
|
||||
@media (prefers-color-scheme:dark){.bg{background:#161615!important;}.card{background:#1f1f1e!important;}.ink{color:#f2f1ee!important;}.mut{color:#a8a6a0!important;}.box{background:#2a2a28!important;border-color:#3a3a37!important;}.btn{background:#f2f1ee!important;}.btn a{color:#161615!important;}.btn2{background:#2a2a28!important;border-color:#3a3a37!important;}.btn2 a{color:#f2f1ee!important;}.rowline{border-color:#3a3a37!important;}}
|
||||
</style>
|
||||
</head>
|
||||
<body style="margin:0;padding:0;background:#f1f0ec;">
|
||||
<span style="display:none;max-height:0;overflow:hidden;opacity:0;mso-hide:all;font-size:1px;line-height:1px;color:#f1f0ec;">Push auf {{repo}} – trag kurz deine Zeit ein. ͏ ͏ ͏</span>
|
||||
<table role="presentation" class="bg" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f1f0ec;">
|
||||
<tr><td align="center" style="padding:32px 12px;">
|
||||
<table role="presentation" class="w" width="600" cellpadding="0" cellspacing="0" border="0" style="width:600px;max-width:600px;">
|
||||
<tr><td class="px" style="padding:0 40px 20px 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr>
|
||||
<td><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td valign="middle" style="padding-right:10px;"><img src="https://project.webklar.com/webklar-logo.png" width="40" height="40" alt="WEBklar" style="display:block;width:40px;height:40px;border:0;outline:none;text-decoration:none;border-radius:10px;"></td><td valign="middle" class="ink" style="font-family:Arial,Helvetica,sans-serif;font-size:26px;font-weight:bold;letter-spacing:-1px;color:#111111;">WEBklar</td></tr></table></td>
|
||||
<td align="right" class="mut" style="font-family:Arial,Helvetica,sans-serif;font-size:12px;color:#6b6964;">Intern · Ticketsystem</td>
|
||||
</tr></table></td></tr>
|
||||
<tr><td class="card" style="background:#ffffff;border-radius:16px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr><td class="px" style="padding:44px 40px 8px 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td style="background:#fdebd3;border-radius:100px;padding:6px 12px;font-family:Arial,Helvetica,sans-serif;font-size:12px;font-weight:bold;color:#8a4b00;">Zeit eintragen</td></tr></table></td></tr>
|
||||
<tr><td class="px ink" style="padding:16px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:28px;line-height:34px;mso-line-height-rule:exactly;font-weight:bold;letter-spacing:-0.5px;color:#111111;">Hallo {{vorname}}, wie lange hast du daran gearbeitet?</td></tr>
|
||||
<tr><td class="px mut" style="padding:10px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Du hast gerade auf {{repo}} ({{branch}}) gepusht. Ein Klick trägt die Zeit im Ticket ein.</td></tr>
|
||||
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr><td class="btn" width="23%" bgcolor="#111111" align="center" style="width:23%;background:#111111;border-radius:10px;"><a href="{{link_15}}" style="display:block;padding:12px 2px;font-family:Arial,Helvetica,sans-serif;font-size:14px;font-weight:bold;color:#ffffff;text-decoration:none;">15 Min</a></td><td width="2%" style="width:2%;font-size:0;line-height:0;"> </td><td class="btn" width="23%" bgcolor="#111111" align="center" style="width:23%;background:#111111;border-radius:10px;"><a href="{{link_30}}" style="display:block;padding:12px 2px;font-family:Arial,Helvetica,sans-serif;font-size:14px;font-weight:bold;color:#ffffff;text-decoration:none;">30 Min</a></td><td width="2%" style="width:2%;font-size:0;line-height:0;"> </td><td class="btn" width="23%" bgcolor="#111111" align="center" style="width:23%;background:#111111;border-radius:10px;"><a href="{{link_45}}" style="display:block;padding:12px 2px;font-family:Arial,Helvetica,sans-serif;font-size:14px;font-weight:bold;color:#ffffff;text-decoration:none;">45 Min</a></td><td width="2%" style="width:2%;font-size:0;line-height:0;"> </td><td class="btn" width="23%" bgcolor="#111111" align="center" style="width:23%;background:#111111;border-radius:10px;"><a href="{{link_60}}" style="display:block;padding:12px 2px;font-family:Arial,Helvetica,sans-serif;font-size:14px;font-weight:bold;color:#ffffff;text-decoration:none;">1 Std</a></td></tr>
|
||||
<tr><td colspan="7" style="height:8px;font-size:0;line-height:0;"> </td></tr>
|
||||
<tr><td class="btn" width="23%" bgcolor="#111111" align="center" style="width:23%;background:#111111;border-radius:10px;"><a href="{{link_90}}" style="display:block;padding:12px 2px;font-family:Arial,Helvetica,sans-serif;font-size:14px;font-weight:bold;color:#ffffff;text-decoration:none;">1,5 Std</a></td><td width="2%" style="width:2%;font-size:0;line-height:0;"> </td><td class="btn" width="23%" bgcolor="#111111" align="center" style="width:23%;background:#111111;border-radius:10px;"><a href="{{link_120}}" style="display:block;padding:12px 2px;font-family:Arial,Helvetica,sans-serif;font-size:14px;font-weight:bold;color:#ffffff;text-decoration:none;">2 Std</a></td><td width="2%" style="width:2%;font-size:0;line-height:0;"> </td><td class="btn" width="23%" bgcolor="#111111" align="center" style="width:23%;background:#111111;border-radius:10px;"><a href="{{link_180}}" style="display:block;padding:12px 2px;font-family:Arial,Helvetica,sans-serif;font-size:14px;font-weight:bold;color:#ffffff;text-decoration:none;">3 Std</a></td><td width="2%" style="width:2%;font-size:0;line-height:0;"> </td><td class="btn" width="23%" bgcolor="#111111" align="center" style="width:23%;background:#111111;border-radius:10px;"><a href="{{link_andere}}" style="display:block;padding:12px 2px;font-family:Arial,Helvetica,sans-serif;font-size:14px;font-weight:bold;color:#ffffff;text-decoration:none;">Andere Zeit</a></td></tr>
|
||||
</table></td></tr>
|
||||
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;mso-line-height-rule:exactly;">
|
||||
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:10px 12px 10px 0;color:#6b6964;border-bottom:1px solid #eeece6;">Ticket</td><td class="val ink rowline" valign="top" style="padding:10px 0;color:#111111;border-bottom:1px solid #eeece6;">#{{woid}} · {{ticket_titel}}</td></tr>
|
||||
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:10px 12px 10px 0;color:#6b6964;border-bottom:1px solid #eeece6;">Kunde</td><td class="val ink rowline" valign="top" style="padding:10px 0;color:#111111;border-bottom:1px solid #eeece6;">{{kunde}}</td></tr>
|
||||
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:10px 12px 10px 0;color:#6b6964;border-bottom:1px solid #eeece6;">Status</td><td class="val ink rowline" valign="top" style="padding:10px 0;color:#111111;border-bottom:1px solid #eeece6;">{{ticket_status}}</td></tr>
|
||||
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:10px 12px 10px 0;color:#6b6964;border-bottom:1px solid #eeece6;">Beschreibung</td><td class="val ink rowline" valign="top" style="padding:10px 0;color:#111111;border-bottom:1px solid #eeece6;">{{ticket_beschreibung}}</td></tr>
|
||||
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:10px 12px 10px 0;color:#6b6964;border-bottom:1px solid #eeece6;">Push</td><td class="val ink rowline" valign="top" style="padding:10px 0;color:#111111;border-bottom:1px solid #eeece6;">{{push_zeit}} · {{anzahl_commits}} Commit(s)</td></tr>
|
||||
</table></td></tr>
|
||||
<tr><td class="px" style="padding:20px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:16px 20px;font-family:Menlo,Consolas,monospace;font-size:13px;line-height:20px;color:#111111;">{{commits}}</td></tr></table></td></tr>
|
||||
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td class="btn2" bgcolor="#ffffff" style="background:#ffffff;border:1px solid #d9d6ce;border-radius:10px;"><a href="{{ticket_link}}" style="display:block;padding:12px 22px;font-family:Arial,Helvetica,sans-serif;font-size:15px;font-weight:bold;color:#111111;text-decoration:none;">Ticket öffnen →</a></td></tr></table></td></tr>
|
||||
<tr><td style="padding:0 0 40px 0;font-size:0;line-height:0;"> </td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
<tr><td class="px mut" align="center" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;">Der Link gilt 14 Tage und trägt nur die Zeit für diesen Push ein. Ändern kannst du sie danach im Ticketsystem.</td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,249 @@
|
||||
import { Router } from 'express'
|
||||
import { config } from '../config.js'
|
||||
import { getDocument } from '../services/appwriteAdmin.js'
|
||||
import { escapeHtml } from '../services/mailTemplates.js'
|
||||
import { verifyPushTimeToken } from '../services/pushTimeToken.js'
|
||||
import { applyPushTime, formatMinutes, needsTimeEntry, parseMinutes, MAX_MINUTES } from '../services/pushTimeEntry.js'
|
||||
|
||||
/**
|
||||
* Bestaetigungsseite der Push-Zeitmail: /zeit/<token>[?min=60].
|
||||
* GET zeigt nur an (Mail-Scanner und Link-Vorschauen oeffnen Links automatisch), erst POST speichert.
|
||||
* Kein Login: der signierte Token erlaubt nur, die Zeit genau dieses Git-Arbeitsblatts einzutragen.
|
||||
*/
|
||||
|
||||
const router = Router()
|
||||
|
||||
// Einfaches Rate-Limit je IP (30 Anfragen / 15 Minuten). Der Speicher bleibt begrenzt: abgelaufene
|
||||
// Eintraege fliegen hoechstens einmal pro Minute raus, und bei mehr als RATE_MAX_KEYS IPs wird der
|
||||
// aelteste Teil verworfen (Map behaelt die Einfuege-Reihenfolge).
|
||||
const RATE_LIMIT = 30
|
||||
const RATE_WINDOW_MS = 15 * 60 * 1000
|
||||
const RATE_MAX_KEYS = 10000
|
||||
const hits = new Map()
|
||||
let lastPrune = 0
|
||||
|
||||
export function resetPushTimeRateLimitForTests() {
|
||||
hits.clear()
|
||||
lastPrune = 0
|
||||
}
|
||||
|
||||
export function pushTimeRateLimitSizeForTests() {
|
||||
return hits.size
|
||||
}
|
||||
|
||||
export function isRateLimited(ip, now = Date.now()) {
|
||||
if (now - lastPrune >= 60 * 1000) {
|
||||
lastPrune = now
|
||||
for (const [key, entry] of hits) if (entry.resetAt <= now) hits.delete(key)
|
||||
}
|
||||
let entry = hits.get(ip)
|
||||
if (!entry || entry.resetAt <= now) {
|
||||
hits.delete(ip)
|
||||
if (hits.size >= RATE_MAX_KEYS) {
|
||||
let drop = hits.size - RATE_MAX_KEYS + 1
|
||||
for (const key of hits.keys()) {
|
||||
if (drop-- <= 0) break
|
||||
hits.delete(key)
|
||||
}
|
||||
}
|
||||
entry = { count: 0, resetAt: now + RATE_WINDOW_MS }
|
||||
hits.set(ip, entry)
|
||||
}
|
||||
entry.count += 1
|
||||
return entry.count > RATE_LIMIT
|
||||
}
|
||||
|
||||
const e = (value) => escapeHtml(value ?? '')
|
||||
|
||||
function page(res, status, title, inner) {
|
||||
res.status(status)
|
||||
res.set({
|
||||
'Content-Type': 'text/html; charset=utf-8',
|
||||
'Cache-Control': 'no-store',
|
||||
'X-Robots-Tag': 'noindex',
|
||||
'Referrer-Policy': 'no-referrer',
|
||||
})
|
||||
res.send(`<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex">
|
||||
<title>${e(title)} · WEBklar</title>
|
||||
<style>
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;background:#f1f0ec;color:#111;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:1.5}
|
||||
main{max-width:520px;margin:0 auto;padding:32px 16px}
|
||||
.brand{display:flex;align-items:center;gap:10px;font-size:22px;font-weight:bold;letter-spacing:-1px;margin-bottom:20px}
|
||||
.brand img{width:36px;height:36px;border-radius:9px}
|
||||
.card{background:#fff;border-radius:16px;padding:28px 24px}
|
||||
h1{font-size:24px;line-height:1.25;margin:0 0 8px;letter-spacing:-.3px}
|
||||
.mut{color:#6b6964}
|
||||
dl{margin:18px 0 0;font-size:14px}
|
||||
dt{color:#6b6964;margin-top:8px}
|
||||
dd{margin:0}
|
||||
ul{margin:4px 0 0;padding-left:18px;font-family:Menlo,Consolas,monospace;font-size:13px}
|
||||
label{display:block;font-weight:bold;margin:22px 0 6px}
|
||||
input[type=number]{width:100%;font-size:20px;padding:12px;border:1px solid #d9d6ce;border-radius:10px}
|
||||
.err{background:#fdecec;color:#8a1c1c;border-radius:10px;padding:10px 12px;margin-top:16px;font-size:14px}
|
||||
.ok{background:#e6f4ea;color:#1d5b2c;border-radius:10px;padding:10px 12px;margin-top:4px}
|
||||
button,.btn{display:inline-block;margin-top:16px;padding:13px 22px;border-radius:10px;font-size:16px;font-weight:bold;text-decoration:none;cursor:pointer}
|
||||
button{background:#111;color:#fff;border:0;width:100%}
|
||||
.btn{background:#fff;color:#111;border:1px solid #d9d6ce}
|
||||
@media (prefers-color-scheme:dark){body{background:#161615;color:#f2f1ee}.card{background:#1f1f1e}input[type=number]{background:#2a2a28;color:#f2f1ee;border-color:#3a3a37}button{background:#f2f1ee;color:#161615}.btn{background:#2a2a28;color:#f2f1ee;border-color:#3a3a37}.mut,dt{color:#a8a6a0}}
|
||||
</style>
|
||||
</head>
|
||||
<body><main>
|
||||
<div class="brand"><img src="/webklar-logo.png" alt="">WEBklar</div>
|
||||
<div class="card">${inner}</div>
|
||||
</main></body>
|
||||
</html>`)
|
||||
}
|
||||
|
||||
const womsHome = () => `${config.womsBaseUrl}/tickets`
|
||||
const ticketLink = (woid) => `${config.womsBaseUrl}/tickets?woid=${encodeURIComponent(woid || '')}`
|
||||
const openTicket = (woid) => `<a class="btn" href="${e(ticketLink(woid))}">Ticket öffnen →</a>`
|
||||
|
||||
function errorPage(res, status, title, text) {
|
||||
page(res, status, title, `<h1>${e(title)}</h1><p class="mut">${e(text)}</p><a class="btn" href="${e(womsHome())}">Zum Ticketsystem →</a>`)
|
||||
}
|
||||
|
||||
/** Stoerung (z. B. Appwrite nicht erreichbar): eigene HTML-Seite statt JSON des globalen Handlers */
|
||||
function failurePage(res, err) {
|
||||
console.error('[pushTime]', err?.message || err)
|
||||
errorPage(res, 503, 'Gerade nicht erreichbar', 'Bitte versuch es gleich noch einmal. Die Zeit wurde nicht gespeichert.')
|
||||
}
|
||||
|
||||
/** Repo und Commit-Titel aus den Arbeitsblatt-Details (Format von gitWorksheet.js) */
|
||||
function pushInfo(details) {
|
||||
const lines = String(details || '').split('\n')
|
||||
const repo = lines[0]?.match(/`([^`]+)`/)?.[1] || ''
|
||||
const commits = lines
|
||||
.map((line) => line.match(/^- \[([0-9a-f]{7})\]\([^)]*\): (.*?)(?: \([^()]*\))?$/i))
|
||||
.filter(Boolean)
|
||||
.map((m) => `${m[1]} ${m[2]}`)
|
||||
return { repo, commits }
|
||||
}
|
||||
|
||||
async function loadTicket(ws) {
|
||||
try {
|
||||
return await getDocument(config.collections.workorders, ws.workorderId)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function formPage(res, status, { ws, ticket, token, minutes, error }) {
|
||||
const { repo, commits } = pushInfo(ws.details)
|
||||
const woid = ticket?.woid || ws.woid
|
||||
const endTime = String(ws.endTime || '')
|
||||
const pushTime = `${ws.endDate || ''}${endTime ? ` ${endTime.slice(0, 2)}:${endTime.slice(2, 4)}` : ''}`
|
||||
page(
|
||||
res,
|
||||
status,
|
||||
'Zeit eintragen',
|
||||
`<h1>Zeit eintragen</h1>
|
||||
<p class="mut">Für deinen Push auf Ticket #${e(woid)}${ticket?.customerName ? ` · ${e(ticket.customerName)}` : ''}</p>
|
||||
<dl>
|
||||
${repo ? `<dt>Repository</dt><dd>${e(repo)}</dd>` : ''}
|
||||
<dt>Push</dt><dd>${e(pushTime)}</dd>
|
||||
${commits.length ? `<dt>Commits</dt><dd><ul>${commits.map((c) => `<li>${e(c)}</li>`).join('')}</ul></dd>` : ''}
|
||||
</dl>
|
||||
${error ? `<div class="err">${e(error)}</div>` : ''}
|
||||
<form method="post" action="/zeit/${e(token)}">
|
||||
<label for="minutes">Minuten</label>
|
||||
<input id="minutes" type="number" name="minutes" min="1" max="${MAX_MINUTES}" step="1" inputmode="numeric" value="${e(minutes ?? '')}" required autofocus>
|
||||
<button type="submit">Speichern</button>
|
||||
</form>
|
||||
${openTicket(woid)}`
|
||||
)
|
||||
}
|
||||
|
||||
function alreadyPage(res, ws, ticket) {
|
||||
const woid = ticket?.woid || ws.woid
|
||||
page(
|
||||
res,
|
||||
200,
|
||||
'Schon eingetragen',
|
||||
`<h1>Schon eingetragen: ${e(formatMinutes(ws.totalTime))}</h1><p class="mut">Die Zeit für diesen Push auf #${e(woid)} ist bereits erfasst. Ändern kannst du sie im Ticketsystem.</p>${openTicket(woid)}`
|
||||
)
|
||||
}
|
||||
|
||||
/** Token pruefen und Arbeitsblatt laden; schickt bei Fehlern selbst die Seite und liefert null. */
|
||||
async function resolve(req, res) {
|
||||
if (isRateLimited(req.ip || 'unbekannt')) {
|
||||
errorPage(res, 429, 'Zu viele Anfragen', 'Bitte versuch es in ein paar Minuten noch einmal.')
|
||||
return null
|
||||
}
|
||||
const check = verifyPushTimeToken(req.params.token)
|
||||
if (check.error === 'expired') {
|
||||
errorPage(res, 410, 'Link abgelaufen', 'Der Link aus der Mail gilt 14 Tage. Trag die Zeit bitte direkt im Ticketsystem ein.')
|
||||
return null
|
||||
}
|
||||
if (check.error) {
|
||||
errorPage(res, 400, 'Link ungültig', 'Dieser Link ist nicht gültig. Trag die Zeit bitte direkt im Ticketsystem ein.')
|
||||
return null
|
||||
}
|
||||
let ws
|
||||
try {
|
||||
ws = await getDocument(config.collections.worksheets, check.worksheetId)
|
||||
} catch (err) {
|
||||
if (err?.status !== 404) throw err
|
||||
errorPage(res, 404, 'Nicht mehr vorhanden', 'Dieses Arbeitsblatt gibt es nicht mehr.')
|
||||
return null
|
||||
}
|
||||
if (ws.serviceType !== 'GIT') {
|
||||
errorPage(res, 400, 'Link ungültig', 'Dieser Link ist nicht gültig. Trag die Zeit bitte direkt im Ticketsystem ein.')
|
||||
return null
|
||||
}
|
||||
return { ws, ticket: await loadTicket(ws) }
|
||||
}
|
||||
|
||||
router.get('/:token', async (req, res) => {
|
||||
try {
|
||||
const found = await resolve(req, res)
|
||||
if (!found) return
|
||||
if (!needsTimeEntry(found.ws)) return alreadyPage(res, found.ws, found.ticket)
|
||||
formPage(res, 200, { ...found, token: req.params.token, minutes: parseMinutes(req.query.min) ?? '' })
|
||||
} catch (err) {
|
||||
failurePage(res, err)
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/:token', async (req, res) => {
|
||||
try {
|
||||
const found = await resolve(req, res)
|
||||
if (!found) return
|
||||
if (!needsTimeEntry(found.ws)) return alreadyPage(res, found.ws, found.ticket)
|
||||
|
||||
const minutes = parseMinutes(req.body?.minutes)
|
||||
if (minutes === null) {
|
||||
return formPage(res, 400, {
|
||||
...found,
|
||||
token: req.params.token,
|
||||
minutes: '',
|
||||
error: `Bitte ganze Minuten zwischen 1 und ${MAX_MINUTES} eingeben.`,
|
||||
})
|
||||
}
|
||||
|
||||
const result = await applyPushTime(found.ws.$id, minutes)
|
||||
if (result.status === 'already') return alreadyPage(res, result.worksheet, found.ticket)
|
||||
if (result.status === 'invalid') {
|
||||
return errorPage(res, 422, 'Nicht möglich', 'Bei diesem Push fehlt die Uhrzeit. Trag die Zeit bitte direkt im Ticketsystem ein.')
|
||||
}
|
||||
if (result.status !== 'saved') {
|
||||
return errorPage(res, 404, 'Nicht mehr vorhanden', 'Dieses Arbeitsblatt gibt es nicht mehr.')
|
||||
}
|
||||
const woid = found.ticket?.woid || found.ws.woid
|
||||
page(
|
||||
res,
|
||||
200,
|
||||
'Eingetragen',
|
||||
`<h1>Danke!</h1><p class="ok">Eingetragen: ${e(formatMinutes(minutes))} auf #${e(woid)}</p>${openTicket(woid)}`
|
||||
)
|
||||
} catch (err) {
|
||||
failurePage(res, err)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from '../../services/previewDeploy.js'
|
||||
import { ensureRepoPreviewConfig } from '../../services/giteaAdmin.js'
|
||||
import { createGitPushWorksheet } from '../../services/gitWorksheet.js'
|
||||
import { sendPushTimeMail } from '../../services/pushTimeMail.js'
|
||||
import { ensureProjectTicket } from '../../services/ticketAdmin.js'
|
||||
import { invalidateProjectCache } from '../../services/projectInsights.js'
|
||||
import { withLocks } from '../../services/keyedLock.js'
|
||||
@@ -184,6 +185,22 @@ router.post('/gitea', async (req, res) => {
|
||||
)
|
||||
}
|
||||
|
||||
// Phase 6: Push-Zeitmail an die pushende Person - nur fuer ein neu angelegtes Arbeitsblatt,
|
||||
// ohne auf den Versand zu warten; ein Fehler aendert die Webhook-Antwort nicht.
|
||||
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))
|
||||
const { created, wsid, worksheetId } = gitWorksheet
|
||||
gitWorksheet = { created, wsid, worksheetId }
|
||||
}
|
||||
|
||||
return res.json({
|
||||
ok: true,
|
||||
repoFullName: current,
|
||||
|
||||
@@ -137,6 +137,7 @@ export async function createGitPushWorksheet({
|
||||
ID.unique()
|
||||
)
|
||||
|
||||
return { created: true, wsid, worksheetId: worksheet.$id }
|
||||
// ticket + worksheet fuer die Push-Zeitmail (routes/webhook/gitea.js), nicht fuer die Webhook-Antwort
|
||||
return { created: true, wsid, worksheetId: worksheet.$id, ticket: workorder, worksheet }
|
||||
})
|
||||
}
|
||||
@@ -129,3 +129,22 @@ export async function sendCustomerMail(to, { attachments = [], ...message }) {
|
||||
attachments,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Mail an eine einzelne Person im Team aus einer T-Vorlage (z. B. T15 Push-Zeitmail), ohne BCC.
|
||||
* Wirft bei fehlendem SMTP oder Versandfehler - der Aufrufer entscheidet, ob das schlimm ist.
|
||||
* @param {string} to
|
||||
* @param {{ template: string, values?: object, blocks?: object }} message
|
||||
*/
|
||||
export async function sendStaffMail(to, message) {
|
||||
const mailer = getTransporter()
|
||||
if (!mailer) throw new Error('SMTP nicht konfiguriert')
|
||||
const mail = render(message)
|
||||
return mailer.sendMail({
|
||||
from: `"${TEAM_SENDER}" <${config.smtp.from}>`,
|
||||
to,
|
||||
subject: mail.subject,
|
||||
text: mail.text,
|
||||
html: mail.html,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { config } from '../config.js'
|
||||
import { withLocks } from './keyedLock.js'
|
||||
import { getDocument, updateDocument } from './appwriteAdmin.js'
|
||||
|
||||
/**
|
||||
* Zeit fuer ein Git-Push-Arbeitsblatt eintragen (Push-Zeitmail). Ergebnis wie "Zeit nachtragen" in
|
||||
* WOMS: Startzeit = Push-Zeit (Endzeit) minus Dauer, totalTime = Minuten, isComment = false.
|
||||
* Eine schon eingetragene Zeit wird nie ueberschrieben - Aendern geht nur in WOMS.
|
||||
*/
|
||||
|
||||
export const MAX_MINUTES = 720
|
||||
|
||||
const pad = (n) => String(n).padStart(2, '0')
|
||||
|
||||
/**
|
||||
* Wand-Uhrzeit rueckwaerts rechnen ('tt.mm.jjjj', 'hhmm'); reine Kalenderrechnung, ohne Zeitzone
|
||||
* (wie "Zeit nachtragen" in WOMS). Ungueltiges Datum/Uhrzeit -> null.
|
||||
*/
|
||||
export function startFromEnd(endDate, endTime, minutes) {
|
||||
const date = String(endDate || '').match(/^(\d{2})\.(\d{2})\.(\d{4})$/)
|
||||
const time = String(endTime || '').match(/^(\d{2})(\d{2})$/)
|
||||
if (!date || !time) return null
|
||||
const [day, month, year] = [Number(date[1]), Number(date[2]), Number(date[3])]
|
||||
const [hours, mins] = [Number(time[1]), Number(time[2])]
|
||||
const end = new Date(Date.UTC(year, month - 1, day, hours, mins))
|
||||
// 31.02. o. ae. rollt in Date.UTC weiter - dann ist das Datum ungueltig
|
||||
if (end.getUTCDate() !== day || end.getUTCMonth() !== month - 1 || hours > 23 || mins > 59) return null
|
||||
const start = new Date(end.getTime() - minutes * 60 * 1000)
|
||||
return {
|
||||
startDate: `${pad(start.getUTCDate())}.${pad(start.getUTCMonth() + 1)}.${start.getUTCFullYear()}`,
|
||||
startTime: `${pad(start.getUTCHours())}${pad(start.getUTCMinutes())}`,
|
||||
}
|
||||
}
|
||||
|
||||
/** Git-Push ohne eingetragene Zeit (wie needsTimeEntry in WOMS) */
|
||||
export function needsTimeEntry(ws) {
|
||||
return ws?.serviceType === 'GIT' && !(ws.startTime && Number(ws.totalTime) > 0)
|
||||
}
|
||||
|
||||
/** Ganze Minuten 1-720 aus Formular/Query, sonst null */
|
||||
export function parseMinutes(raw) {
|
||||
if (raw === null || raw === undefined) return null
|
||||
const text = String(raw).trim()
|
||||
if (!/^\d+$/.test(text)) return null
|
||||
const value = Number(text)
|
||||
return value >= 1 && value <= MAX_MINUTES ? value : null
|
||||
}
|
||||
|
||||
/** 15 -> '15 Min', 60 -> '1 Std', 90 -> '1,5 Std', 130 -> '2 Std 10 Min' */
|
||||
export function formatMinutes(min) {
|
||||
const total = Number(min) || 0
|
||||
if (total < 60) return `${total} Min`
|
||||
const hours = Math.floor(total / 60)
|
||||
const rest = total % 60
|
||||
if (rest === 0) return `${hours} Std`
|
||||
if (rest === 30) return `${hours},5 Std`
|
||||
return `${hours} Std ${rest} Min`
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<{ status: 'saved' | 'already' | 'missing' | 'not_git' | 'invalid', worksheet?: object }>}
|
||||
* invalid: Push-Datum/-Uhrzeit im Arbeitsblatt unbrauchbar (nichts gespeichert).
|
||||
* Andere Appwrite-Fehler als 404 werden weitergeworfen.
|
||||
*/
|
||||
export async function applyPushTime(worksheetId, minutes) {
|
||||
return withLocks([`ws-time:${worksheetId}`], async () => {
|
||||
let ws
|
||||
try {
|
||||
ws = await getDocument(config.collections.worksheets, worksheetId)
|
||||
} catch (err) {
|
||||
if (err?.status === 404) return { status: 'missing' }
|
||||
throw err
|
||||
}
|
||||
if (ws.serviceType !== 'GIT') return { status: 'not_git', worksheet: ws }
|
||||
if (!needsTimeEntry(ws)) return { status: 'already', worksheet: ws }
|
||||
|
||||
const start = startFromEnd(ws.endDate, ws.endTime, minutes)
|
||||
if (!start) return { status: 'invalid', worksheet: ws }
|
||||
const { startDate, startTime } = start
|
||||
const worksheet = await updateDocument(config.collections.worksheets, worksheetId, {
|
||||
startDate,
|
||||
startTime,
|
||||
totalTime: minutes,
|
||||
isComment: false,
|
||||
})
|
||||
return { status: 'saved', worksheet }
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { config } from '../config.js'
|
||||
import { berlinDateTime } from './berlinTime.js'
|
||||
import { listDocuments, Query } from './appwriteAdmin.js'
|
||||
import { sendStaffMail } from './mailer.js'
|
||||
import { createPushTimeToken } from './pushTimeToken.js'
|
||||
|
||||
/**
|
||||
* Push-Zeitmail (Vorlage T15): nach einem neuen Git-Push-Arbeitsblatt bekommt die pushende Person
|
||||
* eine Mail mit Ticket- und Push-Infos und Schnell-Knoepfen zum Eintragen der Zeit (/zeit/<token>).
|
||||
*/
|
||||
|
||||
const QUICK_MINUTES = [15, 30, 45, 60, 90, 120, 180]
|
||||
const MAX_COMMITS = 10
|
||||
const DESCRIPTION_MAX = 300
|
||||
|
||||
// Deutsche Bezeichnungen wie in WOMS (lib/ticketStatus.js)
|
||||
const STATUS_LABELS = {
|
||||
Open: 'Offen',
|
||||
Assigned: 'Eingeplant',
|
||||
Occupied: 'In Arbeit',
|
||||
Awaiting: 'Wartet',
|
||||
'In Test': 'Prüfung',
|
||||
Closed: 'Erledigt',
|
||||
Cancelled: 'Abgebrochen',
|
||||
}
|
||||
|
||||
const norm = (value) => String(value || '').trim().toLowerCase()
|
||||
|
||||
/**
|
||||
* Gitea-Pusher einem Mitarbeiter zuordnen - dieselbe Regel wie canEditWorksheetTimes in WOMS:
|
||||
* Gitea-Name gegen Kuerzel oder Anzeigenamen, ohne Gross-/Kleinschreibung. Nur mit E-Mail-Adresse.
|
||||
*/
|
||||
export function findEmployeeForPusher(pusher, employees) {
|
||||
const names = [pusher?.username, pusher?.login, pusher?.full_name].map(norm).filter(Boolean)
|
||||
if (!names.length) return null
|
||||
const sorted = [...(employees || [])].sort((a, b) => String(a.$createdAt || '').localeCompare(String(b.$createdAt || '')))
|
||||
return (
|
||||
sorted.find(
|
||||
(emp) => String(emp.email || '').trim() && names.some((n) => n === norm(emp.shortcode) || n === norm(emp.displayName))
|
||||
) || null
|
||||
)
|
||||
}
|
||||
|
||||
function commitLines(commits) {
|
||||
const list = Array.isArray(commits) ? commits : []
|
||||
const lines = list.slice(0, MAX_COMMITS).map((commit) => {
|
||||
const sha = String(commit.id || commit.sha || '').slice(0, 7)
|
||||
const title = String(commit.message || '').trim().split('\n')[0]
|
||||
return `${sha} ${title}`.trim()
|
||||
})
|
||||
if (list.length > MAX_COMMITS) lines.push(`… und ${list.length - MAX_COMMITS} weitere`)
|
||||
return lines.join('\n') || '(keine Commit-Details)'
|
||||
}
|
||||
|
||||
function shorten(text, max) {
|
||||
const value = String(text || '').trim()
|
||||
return value.length > max ? `${value.slice(0, max)}…` : value
|
||||
}
|
||||
|
||||
/** Werte fuer Vorlage T15 */
|
||||
export function buildPushTimeMail({ employee, ticket, worksheet, repoFullName, branch, commits, pushedAt = new Date() }) {
|
||||
const token = createPushTimeToken({ worksheetId: worksheet.$id, employeeId: employee.$id })
|
||||
const base = `${config.publicBaseUrl}/zeit/${token}`
|
||||
const { date, time } = berlinDateTime(pushedAt)
|
||||
const woid = String(ticket?.woid || worksheet?.woid || '')
|
||||
const links = Object.fromEntries(QUICK_MINUTES.map((m) => [`link_${m}`, `${base}?min=${m}`]))
|
||||
|
||||
return {
|
||||
template: 'T15',
|
||||
values: {
|
||||
vorname: String(employee.displayName || '').trim().split(/\s+/)[0] || 'du',
|
||||
woid,
|
||||
kunde: ticket?.customerName || 'ohne Kunde',
|
||||
ticket_titel: ticket?.topic || '',
|
||||
ticket_beschreibung: shorten(ticket?.details, DESCRIPTION_MAX),
|
||||
ticket_status: STATUS_LABELS[ticket?.status] || ticket?.status || '',
|
||||
repo: repoFullName,
|
||||
branch,
|
||||
push_zeit: `${date} ${time.slice(0, 2)}:${time.slice(2)}`,
|
||||
anzahl_commits: Array.isArray(commits) ? commits.length : 0,
|
||||
commits: commitLines(commits),
|
||||
...links,
|
||||
link_andere: base,
|
||||
ticket_link: `${config.womsBaseUrl}/tickets?woid=${encodeURIComponent(woid)}`,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Mail verschicken; wirft nie (Webhook darf daran nicht scheitern). */
|
||||
export async function sendPushTimeMail({ ticket, worksheet, repoFullName, branch, commits, pusher, pushedAt }) {
|
||||
try {
|
||||
const employees = await listDocuments(config.collections.employees, [Query.limit(100)])
|
||||
const employee = findEmployeeForPusher(pusher, employees)
|
||||
if (!employee) {
|
||||
const name = pusher?.username || pusher?.login || pusher?.full_name || '?'
|
||||
console.warn(`[pushTimeMail] Pusher "${name}" keinem Mitarbeiter mit E-Mail zugeordnet – keine Zeitmail`)
|
||||
return { sent: false, reason: 'no_employee' }
|
||||
}
|
||||
const mail = buildPushTimeMail({ employee, ticket, worksheet, repoFullName, branch, commits, pushedAt })
|
||||
await sendStaffMail(employee.email, mail)
|
||||
return { sent: true }
|
||||
} catch (err) {
|
||||
console.error('[pushTimeMail] Versand fehlgeschlagen:', err.message)
|
||||
return { sent: false, reason: err.message }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { createHmac, timingSafeEqual } from 'node:crypto'
|
||||
import { config } from '../config.js'
|
||||
|
||||
/**
|
||||
* Signierte Links fuer die Push-Zeitmail: <nutzlast>.<signatur>, beides base64url.
|
||||
* Nutzlast { w: Arbeitsblatt-ID, e: Mitarbeiter-Dokument-ID, x: Ablauf in Unix-Sekunden }.
|
||||
* Zustandslos, damit Links einen Neustart ueberleben; der Link darf nur die Zeit genau dieses
|
||||
* Arbeitsblatts eintragen (siehe routes/pushTime.js).
|
||||
*/
|
||||
|
||||
const sign = (payload) => createHmac('sha256', config.pushTime.secret).update(payload).digest('base64url')
|
||||
|
||||
export function createPushTimeToken({ worksheetId, employeeId }, now = Date.now()) {
|
||||
if (!config.pushTime.secret) throw new Error('PUSH_TIME_SECRET fehlt')
|
||||
const expires = Math.floor(now / 1000) + config.pushTime.ttlDays * 86400
|
||||
const payload = Buffer.from(JSON.stringify({ w: worksheetId, e: employeeId, x: expires })).toString('base64url')
|
||||
return `${payload}.${sign(payload)}`
|
||||
}
|
||||
|
||||
export function verifyPushTimeToken(token, now = Date.now()) {
|
||||
const invalid = { error: 'invalid' }
|
||||
if (!config.pushTime.secret || typeof token !== 'string') return invalid
|
||||
const parts = token.split('.')
|
||||
if (parts.length !== 2 || !parts[0] || !parts[1]) return invalid
|
||||
const [payload, signature] = parts
|
||||
|
||||
const expected = Buffer.from(sign(payload))
|
||||
const given = Buffer.from(signature)
|
||||
if (given.length !== expected.length || !timingSafeEqual(given, expected)) return invalid
|
||||
|
||||
let data
|
||||
try {
|
||||
data = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'))
|
||||
} catch {
|
||||
return invalid
|
||||
}
|
||||
if (typeof data?.w !== 'string' || typeof data?.e !== 'string' || !Number.isFinite(data?.x)) return invalid
|
||||
if (data.x * 1000 <= now) return { error: 'expired' }
|
||||
return { worksheetId: data.w, employeeId: data.e }
|
||||
}
|
||||
@@ -39,6 +39,9 @@ test('Push-Zeit in deutscher Zeit', async () => {
|
||||
assert.deepEqual([ws.startDate, ws.startTime, ws.endDate, ws.endTime], ['27.09.2026', '', '27.09.2026', '1646'])
|
||||
assert.equal(ws.totalTime, 0); assert.equal(ws.isComment, true); assert.equal(ws.serviceType, 'GIT')
|
||||
assert.match(ws.details, /Commit: 26bc22e/)
|
||||
// Push-Zeitmail: Ticket und Arbeitsblatt kommen mit zurück (nicht doppelt laden)
|
||||
assert.equal(r.ticket.woid, '100115')
|
||||
assert.equal(r.worksheet.$id, r.worksheetId)
|
||||
})
|
||||
test('zweiter Aufruf: Duplikat', async () => {
|
||||
assert.deepEqual(await createGitPushWorksheet({ ...base }), { skipped: true, reason: 'duplicate_commit' })
|
||||
|
||||
@@ -6,7 +6,7 @@ import { contractAttachmentFor, termsDocFor } from '../server/services/products.
|
||||
|
||||
const DIR = new URL('../server/mail-templates/', import.meta.url)
|
||||
// K12, K14 und T12 (Wechsel ins Wartungs-Abo) aus Task 9, K13 und T13 (Zusatzspeicher) aus Task 8 (Fix 1);
|
||||
// Endfassung der Texte in Task 12; T14 „Löschfristen“ aus Task 21 (F39)
|
||||
// Endfassung der Texte in Task 12; T14 „Löschfristen“ aus Task 21 (F39); T15 Push-Zeitmail
|
||||
const EXPECTED = [
|
||||
...Array.from({ length: 11 }, (_, i) => `K${String(i + 1).padStart(2, '0')}`),
|
||||
'K12',
|
||||
@@ -16,6 +16,7 @@ const EXPECTED = [
|
||||
'T12',
|
||||
'T13',
|
||||
'T14',
|
||||
'T15',
|
||||
]
|
||||
|
||||
const sampleValues = (tpl) => Object.fromEntries(tpl.placeholders.map((p) => [p, `Wert-${p}`]))
|
||||
@@ -31,8 +32,8 @@ for (const id of EXPECTED) {
|
||||
const source = readFileSync(new URL(tpl.file, DIR), 'utf8')
|
||||
const title = source.match(/<title>([\s\S]*?)<\/title>/)[1]
|
||||
assert.equal(title, tpl.subject, 'Betreff im Kopf und <title> sind gleich')
|
||||
// F34: Team-Hinweise tragen [Abo], [Speicher] (Zusatzspeicher-Aufgaben) oder [Datenschutz] (Loeschfristen)
|
||||
assert.equal(id.startsWith('T'), /^\[(Abo|Speicher|Datenschutz)\]/.test(tpl.subject) || id === 'T01', 'Team-Hinweise ab T02 mit [Abo], [Speicher] oder [Datenschutz]')
|
||||
// F34: Team-Hinweise tragen [Abo], [Speicher] (Zusatzspeicher-Aufgaben), [Datenschutz] (Loeschfristen) oder [Zeit] (Push-Zeitmail)
|
||||
assert.equal(id.startsWith('T'), /^\[(Abo|Speicher|Datenschutz|Zeit)\]/.test(tpl.subject) || id === 'T01', 'Team-Hinweise ab T02 mit [Abo], [Speicher], [Datenschutz] oder [Zeit]')
|
||||
|
||||
const used = new Set([...tpl.body.matchAll(/\{\{(\w+)\}\}/g), ...tpl.subject.matchAll(/\{\{(\w+)\}\}/g)].map((m) => m[1]))
|
||||
assert.deepEqual([...used].sort(), [...tpl.placeholders].sort(), 'deklarierte = verwendete Platzhalter')
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { test, before, after } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { startFakeAppwrite } from './helpers/fakeAppwrite.js'
|
||||
|
||||
let fake
|
||||
let startFromEnd, needsTimeEntry, parseMinutes, formatMinutes, applyPushTime
|
||||
|
||||
const openGit = (id) => ({
|
||||
$id: id, wsid: '100200', woid: '100118', serviceType: 'GIT',
|
||||
endDate: '30.09.2026', endTime: '1646', startDate: '30.09.2026', startTime: '', totalTime: 0, isComment: true,
|
||||
})
|
||||
|
||||
before(async () => {
|
||||
fake = await startFakeAppwrite({
|
||||
seed: {
|
||||
worksheets: [
|
||||
openGit('ws1'),
|
||||
openGit('ws-race'),
|
||||
{ $id: 'remote1', serviceType: 'Remote', startTime: '', totalTime: 0 },
|
||||
{ ...openGit('broken'), endDate: '', endTime: 'xx' },
|
||||
],
|
||||
},
|
||||
})
|
||||
Object.assign(process.env, { APPWRITE_ENDPOINT: fake.endpoint, APPWRITE_API_KEY: 'test-key' })
|
||||
;({ startFromEnd, needsTimeEntry, parseMinutes, formatMinutes, applyPushTime } = await import(
|
||||
'../server/services/pushTimeEntry.js'
|
||||
))
|
||||
})
|
||||
|
||||
after(async () => {
|
||||
await fake.close()
|
||||
})
|
||||
|
||||
test('startFromEnd: normal, über Mitternacht, Jahres- und Schalttag-Grenze', () => {
|
||||
assert.deepEqual(startFromEnd('30.09.2026', '1646', 60), { startDate: '30.09.2026', startTime: '1546' })
|
||||
assert.deepEqual(startFromEnd('01.10.2026', '0020', 45), { startDate: '30.09.2026', startTime: '2335' })
|
||||
assert.deepEqual(startFromEnd('01.01.2027', '0010', 30), { startDate: '31.12.2026', startTime: '2340' })
|
||||
assert.deepEqual(startFromEnd('01.03.2028', '0000', 1), { startDate: '29.02.2028', startTime: '2359' })
|
||||
})
|
||||
|
||||
test('needsTimeEntry', () => {
|
||||
assert.equal(needsTimeEntry({ serviceType: 'GIT', startTime: '', totalTime: 0 }), true)
|
||||
assert.equal(needsTimeEntry({ serviceType: 'GIT', startTime: '1500', totalTime: 45 }), false)
|
||||
assert.equal(needsTimeEntry({ serviceType: 'Remote', startTime: '', totalTime: 0 }), false)
|
||||
})
|
||||
|
||||
test('parseMinutes: ganze Minuten 1–720', () => {
|
||||
assert.equal(parseMinutes('45'), 45)
|
||||
assert.equal(parseMinutes(720), 720)
|
||||
for (const bad of ['1.5', '0', '721', '', 'abc', undefined, null, '-5', '45abc']) {
|
||||
assert.equal(parseMinutes(bad), null, String(bad))
|
||||
}
|
||||
})
|
||||
|
||||
test('formatMinutes', () => {
|
||||
assert.equal(formatMinutes(15), '15 Min')
|
||||
assert.equal(formatMinutes(60), '1 Std')
|
||||
assert.equal(formatMinutes(90), '1,5 Std')
|
||||
assert.equal(formatMinutes(130), '2 Std 10 Min')
|
||||
})
|
||||
|
||||
test('applyPushTime: speichert einmal, überschreibt nie', async () => {
|
||||
assert.equal((await applyPushTime('ws1', 60)).status, 'saved')
|
||||
const ws = fake.get('worksheets', 'ws1')
|
||||
assert.deepEqual([ws.startDate, ws.startTime, ws.totalTime, ws.isComment], ['30.09.2026', '1546', 60, false])
|
||||
assert.equal((await applyPushTime('ws1', 30)).status, 'already')
|
||||
assert.equal(fake.get('worksheets', 'ws1').totalTime, 60)
|
||||
assert.equal((await applyPushTime('gibtsnicht', 30)).status, 'missing')
|
||||
assert.equal((await applyPushTime('remote1', 30)).status, 'not_git')
|
||||
})
|
||||
|
||||
test('gleichzeitig: nur einer speichert', async () => {
|
||||
const results = await Promise.all([applyPushTime('ws-race', 30), applyPushTime('ws-race', 90)])
|
||||
assert.deepEqual(results.map((r) => r.status).sort(), ['already', 'saved'])
|
||||
})
|
||||
|
||||
test('kaputtes Push-Datum: nichts speichern (Review-Fix)', async () => {
|
||||
assert.equal(startFromEnd('', '', 30), null)
|
||||
assert.equal(startFromEnd('30.09.2026', '16:46', 30), null)
|
||||
assert.equal(startFromEnd('31.02.2026', '1646', 30), null)
|
||||
assert.equal((await applyPushTime('broken', 30)).status, 'invalid')
|
||||
const ws = fake.get('worksheets', 'broken')
|
||||
assert.deepEqual([ws.startTime, ws.totalTime, ws.isComment], ['', 0, true])
|
||||
})
|
||||
|
||||
test('Appwrite-Störung ist nicht „missing“ (Review-Fix)', async () => {
|
||||
fake.failNext({ method: 'GET', pathIncludes: '/documents/ws-race', status: 500 })
|
||||
await assert.rejects(applyPushTime('ws-race', 30))
|
||||
})
|
||||
@@ -0,0 +1,117 @@
|
||||
import { test, before, after } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { startFakeAppwrite } from './helpers/fakeAppwrite.js'
|
||||
import { mailSink } from './support/mailSink.js'
|
||||
|
||||
let fake
|
||||
let findEmployeeForPusher, buildPushTimeMail, sendPushTimeMail
|
||||
let renderMail, verifyPushTimeToken, setMailTransportForTests
|
||||
|
||||
const KENSO = { $id: 'k', displayName: 'Kenso Grimm', shortcode: 'KNSO', email: 'kenso@webklar.com', $createdAt: '2026-01-01T00:00:00.000Z' }
|
||||
const ANDREJ = { $id: 'a', displayName: 'Andrej Stevanovski', shortcode: 'ANDJ', email: '', $createdAt: '2026-01-02T00:00:00.000Z' }
|
||||
|
||||
const ticket = { $id: 't1', woid: '100118', customerName: 'Amin Friseur', topic: 'Website Amin Friseur', details: 'Neue Seite', status: 'Occupied' }
|
||||
const worksheet = { $id: 'ws9', woid: '100118', serviceType: 'GIT' }
|
||||
const commits = [
|
||||
{ id: 'abc1234def', message: 'Startseite: Hero neu\n\nlanger Text' },
|
||||
{ id: 'def5678abc', message: '<script>x</script> & Co' },
|
||||
]
|
||||
const input = (extra = {}) => ({
|
||||
ticket, worksheet, repoFullName: 'WEBklar/amin-friseur', branch: 'main', commits,
|
||||
pushedAt: new Date('2026-09-30T14:46:00Z'), ...extra,
|
||||
})
|
||||
|
||||
before(async () => {
|
||||
fake = await startFakeAppwrite({ seed: { employees: [ANDREJ, KENSO] } })
|
||||
Object.assign(process.env, {
|
||||
APPWRITE_ENDPOINT: fake.endpoint,
|
||||
APPWRITE_API_KEY: 'test-key',
|
||||
PUSH_TIME_SECRET: 'z'.repeat(40),
|
||||
})
|
||||
;({ findEmployeeForPusher, buildPushTimeMail, sendPushTimeMail } = await import('../server/services/pushTimeMail.js'))
|
||||
;({ renderMail } = await import('../server/services/mailTemplates.js'))
|
||||
;({ verifyPushTimeToken } = await import('../server/services/pushTimeToken.js'))
|
||||
;({ setMailTransportForTests } = await import('../server/services/mailer.js'))
|
||||
})
|
||||
|
||||
after(async () => {
|
||||
setMailTransportForTests?.(null)
|
||||
await fake.close()
|
||||
})
|
||||
|
||||
test('findEmployeeForPusher: Kürzel, voller Name, ohne E-Mail, unbekannt', () => {
|
||||
const employees = [ANDREJ, KENSO]
|
||||
assert.equal(findEmployeeForPusher({ username: 'knso' }, employees)?.$id, 'k')
|
||||
assert.equal(findEmployeeForPusher({ full_name: ' kenso grimm ' }, employees)?.$id, 'k')
|
||||
assert.equal(findEmployeeForPusher({ login: 'ANDJ' }, employees), null)
|
||||
assert.equal(findEmployeeForPusher({ username: 'fremd' }, employees), null)
|
||||
assert.equal(findEmployeeForPusher({}, employees), null)
|
||||
})
|
||||
|
||||
test('buildPushTimeMail: Betreff, Links, Escaping, gültiger Token', () => {
|
||||
const mail = buildPushTimeMail({ employee: KENSO, ...input() })
|
||||
assert.equal(mail.template, 'T15')
|
||||
const out = renderMail('T15', mail.values)
|
||||
assert.deepEqual(out.problems, [])
|
||||
assert.equal(out.subject, '[Zeit] #100118 Amin Friseur – 2 Commit(s)')
|
||||
assert.match(out.html, /Hallo Kenso,/)
|
||||
assert.match(out.html, /In Arbeit/)
|
||||
assert.match(out.html, /30\.09\.2026 16:46/)
|
||||
for (const m of [15, 30, 45, 60, 90, 120, 180]) {
|
||||
assert.match(out.html, new RegExp(`/zeit/[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+\\?min=${m}"`))
|
||||
}
|
||||
assert.match(out.html, /\/zeit\/[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+"/, 'Andere Zeit ohne min')
|
||||
assert.match(out.html, /https:\/\/ticket\.webklar\.com\/tickets\?woid=100118/)
|
||||
assert.match(out.html, /abc1234 Startseite: Hero neu/)
|
||||
assert.doesNotMatch(out.html, /langer Text/)
|
||||
assert.match(out.html, /<script>x<\/script> & Co/)
|
||||
assert.doesNotMatch(out.html, /<script>/)
|
||||
|
||||
const token = mail.values.link_andere.split('/zeit/')[1]
|
||||
assert.deepEqual(verifyPushTimeToken(token), { worksheetId: 'ws9', employeeId: 'k' })
|
||||
})
|
||||
|
||||
test('buildPushTimeMail: 12 Commits → 10 Zeilen + Rest', () => {
|
||||
const many = Array.from({ length: 12 }, (_, i) => ({ id: `${String(i).padStart(7, '0')}ffff`, message: `Commit ${i}` }))
|
||||
const { values } = buildPushTimeMail({ employee: KENSO, ...input({ commits: many }) })
|
||||
const lines = values.commits.split('\n')
|
||||
assert.equal(lines.length, 11)
|
||||
assert.equal(lines.at(-1), '… und 2 weitere')
|
||||
assert.equal(values.anzahl_commits, 12)
|
||||
})
|
||||
|
||||
test('buildPushTimeMail: lange Beschreibung gekürzt, ohne Kunde', () => {
|
||||
const { values } = buildPushTimeMail({
|
||||
employee: KENSO,
|
||||
...input({ ticket: { ...ticket, customerName: '', details: 'x'.repeat(400), status: 'Weird' } }),
|
||||
})
|
||||
assert.equal(values.ticket_beschreibung.length, 301)
|
||||
assert.ok(values.ticket_beschreibung.endsWith('…'))
|
||||
assert.equal(values.kunde, 'ohne Kunde')
|
||||
assert.equal(values.ticket_status, 'Weird')
|
||||
})
|
||||
|
||||
test('sendPushTimeMail: an den Pusher, ohne BCC', async () => {
|
||||
const sink = mailSink()
|
||||
setMailTransportForTests(sink)
|
||||
const r = await sendPushTimeMail({ ...input(), pusher: { username: 'knso' } })
|
||||
assert.deepEqual(r, { sent: true })
|
||||
assert.equal(sink.sent.length, 1)
|
||||
assert.deepEqual(sink.sent[0].to.map((t) => t.address), ['kenso@webklar.com'])
|
||||
assert.equal(sink.sent[0].from.name, 'WEBklar Kundenportal')
|
||||
assert.equal(sink.sent[0].bcc, undefined)
|
||||
})
|
||||
|
||||
test('sendPushTimeMail: unbekannter Pusher → keine Mail', async () => {
|
||||
const sink = mailSink()
|
||||
setMailTransportForTests(sink)
|
||||
const r = await sendPushTimeMail({ ...input(), pusher: { username: 'fremd' } })
|
||||
assert.equal(r.sent, false)
|
||||
assert.equal(sink.sent.length, 0)
|
||||
})
|
||||
|
||||
test('sendPushTimeMail: Versandfehler wird geschluckt', async () => {
|
||||
setMailTransportForTests({ sendMail: async () => { throw new Error('SMTP kaputt') } })
|
||||
const r = await sendPushTimeMail({ ...input(), pusher: { username: 'knso' } })
|
||||
assert.equal(r.sent, false)
|
||||
})
|
||||
@@ -0,0 +1,184 @@
|
||||
import { test, before, after, beforeEach } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import express from 'express'
|
||||
import { startFakeAppwrite } from './helpers/fakeAppwrite.js'
|
||||
|
||||
let fake
|
||||
let server
|
||||
let baseUrl
|
||||
let createPushTimeToken
|
||||
let resetPushTimeRateLimitForTests
|
||||
let isRateLimited
|
||||
let rateLimitSize
|
||||
|
||||
const gitSheet = (id, extra = {}) => ({
|
||||
$id: id, wsid: '100300', woid: '100118', workorderId: 't1', serviceType: 'GIT',
|
||||
employeeName: 'knso', endDate: '30.09.2026', endTime: '1646', startDate: '30.09.2026', startTime: '',
|
||||
totalTime: 0, isComment: true,
|
||||
details: 'Git Push auf `WEBklar/amin-friseur` (Branch: main)\nCommit: abc1234\n\n- [abc1234](https://git/x): Startseite neu (knso)',
|
||||
...extra,
|
||||
})
|
||||
|
||||
before(async () => {
|
||||
fake = await startFakeAppwrite({
|
||||
seed: {
|
||||
workorders: [{ $id: 't1', woid: '100118', customerName: 'Amin Friseur' }],
|
||||
worksheets: [
|
||||
gitSheet('open1'),
|
||||
gitSheet('open2'),
|
||||
gitSheet('open3'),
|
||||
gitSheet('done1', { startTime: '1500', totalTime: 45, isComment: false }),
|
||||
gitSheet('evil', { details: 'Git Push auf `X` (Branch: main)\n\n- [abc1234](u): <b>fett</b>' }),
|
||||
{ $id: 'remote1', serviceType: 'Remote', startTime: '', totalTime: 0 },
|
||||
],
|
||||
},
|
||||
})
|
||||
Object.assign(process.env, {
|
||||
APPWRITE_ENDPOINT: fake.endpoint,
|
||||
APPWRITE_API_KEY: 'test-key',
|
||||
PUSH_TIME_SECRET: 'r'.repeat(40),
|
||||
})
|
||||
const routes = await import('../server/routes/pushTime.js')
|
||||
resetPushTimeRateLimitForTests = routes.resetPushTimeRateLimitForTests
|
||||
isRateLimited = routes.isRateLimited
|
||||
rateLimitSize = routes.pushTimeRateLimitSizeForTests
|
||||
;({ createPushTimeToken } = await import('../server/services/pushTimeToken.js'))
|
||||
|
||||
const app = express()
|
||||
app.use('/zeit', express.urlencoded({ extended: false, limit: '4kb' }), routes.default)
|
||||
server = await new Promise((resolve) => {
|
||||
const s = app.listen(0, '127.0.0.1', () => resolve(s))
|
||||
})
|
||||
baseUrl = `http://127.0.0.1:${server.address().port}`
|
||||
})
|
||||
|
||||
after(async () => {
|
||||
if (server) await new Promise((resolve) => server.close(resolve))
|
||||
await fake.close()
|
||||
})
|
||||
|
||||
beforeEach(() => resetPushTimeRateLimitForTests?.())
|
||||
|
||||
const token = (worksheetId, now) => createPushTimeToken({ worksheetId, employeeId: 'k' }, now)
|
||||
|
||||
async function get(path) {
|
||||
const res = await fetch(`${baseUrl}${path}`)
|
||||
return { status: res.status, html: await res.text(), headers: res.headers }
|
||||
}
|
||||
|
||||
async function post(path, form) {
|
||||
const res = await fetch(`${baseUrl}${path}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams(form).toString(),
|
||||
})
|
||||
return { status: res.status, html: await res.text(), headers: res.headers }
|
||||
}
|
||||
|
||||
test('GET speichert nie', async () => {
|
||||
const t = token('open1')
|
||||
for (const m of ['15', '30', '45', '60', '90', '120', '180', '']) {
|
||||
const r = await get(`/zeit/${t}${m ? `?min=${m}` : ''}`)
|
||||
assert.equal(r.status, 200)
|
||||
assert.match(r.html, /#100118/)
|
||||
assert.match(r.html, /Amin Friseur/)
|
||||
assert.match(r.html, /WEBklar\/amin-friseur/)
|
||||
assert.match(r.html, /Startseite neu/)
|
||||
}
|
||||
const ws = fake.get('worksheets', 'open1')
|
||||
assert.equal(ws.totalTime, 0)
|
||||
assert.equal(ws.startTime, '')
|
||||
})
|
||||
|
||||
test('GET mit min=60 füllt das Feld vor, Unsinn nicht', async () => {
|
||||
const t = token('open1')
|
||||
assert.match((await get(`/zeit/${t}?min=60`)).html, /name="minutes"[^>]*value="60"/)
|
||||
assert.match((await get(`/zeit/${t}?min=abc`)).html, /name="minutes"[^>]*value=""/)
|
||||
})
|
||||
|
||||
test('POST speichert', async () => {
|
||||
const r = await post(`/zeit/${token('open2')}`, { minutes: '45' })
|
||||
assert.equal(r.status, 200)
|
||||
assert.match(r.html, /Eingetragen: 45 Min auf #100118/)
|
||||
const ws = fake.get('worksheets', 'open2')
|
||||
assert.equal(ws.totalTime, 45)
|
||||
assert.equal(ws.isComment, false)
|
||||
assert.equal(ws.startTime, '1601')
|
||||
})
|
||||
|
||||
test('POST mit ungültigen Minuten', async () => {
|
||||
for (const minutes of ['0', '721', '1.5', '']) {
|
||||
const r = await post(`/zeit/${token('open3')}`, { minutes })
|
||||
assert.equal(r.status, 400, minutes)
|
||||
assert.match(r.html, /Bitte ganze Minuten zwischen 1 und 720 eingeben\./)
|
||||
assert.match(r.html, /name="minutes"/)
|
||||
}
|
||||
assert.equal(fake.get('worksheets', 'open3').totalTime, 0)
|
||||
})
|
||||
|
||||
test('schon eingetragen: nichts wird überschrieben', async () => {
|
||||
const t = token('done1')
|
||||
const g = await get(`/zeit/${t}?min=30`)
|
||||
assert.equal(g.status, 200)
|
||||
assert.match(g.html, /Schon eingetragen: 45 Min/)
|
||||
assert.doesNotMatch(g.html, /name="minutes"/)
|
||||
const p = await post(`/zeit/${t}`, { minutes: '30' })
|
||||
assert.match(p.html, /Schon eingetragen: 45 Min/)
|
||||
assert.equal(fake.get('worksheets', 'done1').totalTime, 45)
|
||||
})
|
||||
|
||||
test('Token-Fehler und fehlende Blätter', async () => {
|
||||
assert.equal((await get('/zeit/muell')).status, 400)
|
||||
const old = token('open1', Date.now() - 15 * 86400 * 1000)
|
||||
const expired = await get(`/zeit/${old}`)
|
||||
assert.equal(expired.status, 410)
|
||||
assert.match(expired.html, /Link abgelaufen/)
|
||||
assert.equal((await post(`/zeit/${old}`, { minutes: '30' })).status, 410)
|
||||
const missing = await get(`/zeit/${token('gibtsnicht')}`)
|
||||
assert.equal(missing.status, 404)
|
||||
assert.match(missing.html, /Nicht mehr vorhanden/)
|
||||
assert.equal((await get(`/zeit/${token('remote1')}`)).status, 400)
|
||||
assert.equal((await post(`/zeit/${token('remote1')}`, { minutes: '30' })).status, 400)
|
||||
})
|
||||
|
||||
test('Escaping', async () => {
|
||||
const r = await get(`/zeit/${token('evil')}`)
|
||||
assert.match(r.html, /<b>fett<\/b>/)
|
||||
assert.doesNotMatch(r.html, /<b>fett/)
|
||||
})
|
||||
|
||||
test('Sicherheits-Header und Ticket-Link', async () => {
|
||||
const r = await get(`/zeit/${token('open1')}`)
|
||||
assert.equal(r.headers.get('cache-control'), 'no-store')
|
||||
assert.equal(r.headers.get('x-robots-tag'), 'noindex')
|
||||
assert.equal(r.headers.get('referrer-policy'), 'no-referrer')
|
||||
assert.match(r.headers.get('content-type'), /text\/html; charset=utf-8/)
|
||||
assert.match(r.html, /https:\/\/ticket\.webklar\.com\/tickets\?woid=100118/)
|
||||
})
|
||||
|
||||
test('Rate-Limit: 31. Anfrage → 429', async () => {
|
||||
const t = token('open1')
|
||||
for (let i = 0; i < 30; i += 1) assert.equal((await get(`/zeit/${t}`)).status, 200)
|
||||
const r = await get(`/zeit/${t}`)
|
||||
assert.equal(r.status, 429)
|
||||
assert.match(r.html, /Zu viele Anfragen/)
|
||||
})
|
||||
|
||||
test('Rate-Limit-Speicher bleibt begrenzt (Review-Fix)', () => {
|
||||
const now = Date.now()
|
||||
for (let i = 0; i < 12000; i += 1) isRateLimited(`10.0.${Math.floor(i / 250)}.${i % 250}`, now)
|
||||
assert.ok(rateLimitSize() <= 10000, `Größe ${rateLimitSize()}`)
|
||||
// nach Ablauf des Fensters wird aufgeräumt
|
||||
isRateLimited('10.9.9.9', now + 16 * 60 * 1000)
|
||||
assert.equal(rateLimitSize(), 1)
|
||||
})
|
||||
|
||||
test('Appwrite-Störung: neutrale HTML-Seite statt „Nicht mehr vorhanden“ (Review-Fix)', async () => {
|
||||
const t = token('open1')
|
||||
fake.failNext({ method: 'GET', pathIncludes: '/documents/open1', status: 500 })
|
||||
const r = await get(`/zeit/${t}`)
|
||||
assert.equal(r.status, 503)
|
||||
assert.match(r.headers.get('content-type'), /text\/html/)
|
||||
assert.match(r.html, /Bitte versuch es gleich noch einmal/)
|
||||
assert.doesNotMatch(r.html, /Nicht mehr vorhanden|fakeAppwrite/)
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
import { test, before } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { createHmac } from 'node:crypto'
|
||||
|
||||
let createPushTimeToken
|
||||
let verifyPushTimeToken
|
||||
|
||||
before(async () => {
|
||||
process.env.PUSH_TIME_SECRET = 'x'.repeat(40)
|
||||
// config.js liest die Umgebung beim Import
|
||||
;({ createPushTimeToken, verifyPushTimeToken } = await import('../server/services/pushTimeToken.js'))
|
||||
})
|
||||
|
||||
const DAY = 86400 * 1000
|
||||
const b64 = (value) => Buffer.from(value).toString('base64url')
|
||||
|
||||
test('Token rundet sauber', () => {
|
||||
const token = createPushTimeToken({ worksheetId: 'ws1', employeeId: 'e1' })
|
||||
assert.deepEqual(verifyPushTimeToken(token), { worksheetId: 'ws1', employeeId: 'e1' })
|
||||
})
|
||||
|
||||
test('nach 14 Tagen abgelaufen', () => {
|
||||
const token = createPushTimeToken({ worksheetId: 'ws1', employeeId: 'e1' }, 0)
|
||||
assert.deepEqual(verifyPushTimeToken(token, 14 * DAY + 1000), { error: 'expired' })
|
||||
assert.deepEqual(verifyPushTimeToken(token, 14 * DAY - 1000), { worksheetId: 'ws1', employeeId: 'e1' })
|
||||
})
|
||||
|
||||
test('manipulierte Nutzlast oder Signatur', () => {
|
||||
const token = createPushTimeToken({ worksheetId: 'ws1', employeeId: 'e1' })
|
||||
const [payload, sig] = token.split('.')
|
||||
const data = JSON.parse(Buffer.from(payload, 'base64url').toString())
|
||||
const forged = `${b64(JSON.stringify({ ...data, w: 'ws2' }))}.${sig}`
|
||||
assert.deepEqual(verifyPushTimeToken(forged), { error: 'invalid' })
|
||||
const lastChar = sig.at(-1) === 'A' ? 'B' : 'A'
|
||||
assert.deepEqual(verifyPushTimeToken(`${payload}.${sig.slice(0, -1)}${lastChar}`), { error: 'invalid' })
|
||||
for (const bad of ['', 'abc', 'a.b.c', undefined, null]) {
|
||||
assert.deepEqual(verifyPushTimeToken(bad), { error: 'invalid' })
|
||||
}
|
||||
})
|
||||
|
||||
test('anderes Secret', () => {
|
||||
const payload = b64(JSON.stringify({ w: 'ws1', e: 'e1', x: Math.floor(Date.now() / 1000) + 3600 }))
|
||||
const sig = createHmac('sha256', 'y'.repeat(40)).update(payload).digest('base64url')
|
||||
assert.deepEqual(verifyPushTimeToken(`${payload}.${sig}`), { error: 'invalid' })
|
||||
})
|
||||
@@ -0,0 +1,143 @@
|
||||
import { test, before, after } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { createHmac } from 'node:crypto'
|
||||
import fs from 'node:fs/promises'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import express from 'express'
|
||||
import { startFakeGitea } from './helpers/fakeGitea.js'
|
||||
import { startFakeAppwrite } from './helpers/fakeAppwrite.js'
|
||||
import { mailSink } from './support/mailSink.js'
|
||||
|
||||
const WEBHOOK_TOKEN = 's3cret'
|
||||
const REPO = 'WEBklar/amin-friseur'
|
||||
|
||||
let gitea
|
||||
let aw
|
||||
let tmp
|
||||
let originalCwd
|
||||
let server
|
||||
let baseUrl
|
||||
let setMailTransportForTests
|
||||
|
||||
function pushPayload({ sha, pusher }) {
|
||||
return {
|
||||
ref: 'refs/heads/main',
|
||||
repository: { full_name: REPO, name: 'amin-friseur', html_url: `${gitea.baseUrl}/${REPO}` },
|
||||
commits: [{ id: sha, message: `Commit ${sha}`, author: { name: pusher }, url: `${gitea.baseUrl}/${REPO}/commit/${sha}` }],
|
||||
pusher: { username: pusher },
|
||||
after: sha,
|
||||
}
|
||||
}
|
||||
|
||||
async function webhook(payload) {
|
||||
const bodyText = JSON.stringify(payload)
|
||||
const signature = createHmac('sha256', WEBHOOK_TOKEN).update(bodyText).digest('hex')
|
||||
const res = await fetch(`${baseUrl}/webhook/gitea`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-Gitea-Signature': signature },
|
||||
body: bodyText,
|
||||
})
|
||||
return { status: res.status, body: await res.json().catch(() => null) }
|
||||
}
|
||||
|
||||
/** Die Mail geht ohne await auf die Webhook-Antwort raus - kurz warten, bis sie da ist. */
|
||||
async function waitFor(check, ms = 1000) {
|
||||
const end = Date.now() + ms
|
||||
while (Date.now() < end) {
|
||||
if (check()) return true
|
||||
await new Promise((r) => setTimeout(r, 10))
|
||||
}
|
||||
return check()
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
gitea = await startFakeGitea({ repos: [{ full_name: REPO }] })
|
||||
aw = await startFakeAppwrite({
|
||||
seed: {
|
||||
websiteProjects: [{ $id: 'p1', repoFullName: REPO, projectName: 'amin-friseur', customerId: 'c1', ticketId: 't1' }],
|
||||
workorders: [{ $id: 't1', woid: '100118', customerName: 'Amin Friseur', topic: 'Website', status: 'Open' }],
|
||||
customers: [{ $id: 'c1', name: 'Amin Friseur' }],
|
||||
employees: [{ $id: 'k', displayName: 'Kenso Grimm', shortcode: 'KNSO', email: 'kenso@webklar.com' }],
|
||||
},
|
||||
})
|
||||
|
||||
originalCwd = process.cwd()
|
||||
tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'push-zeitmail-'))
|
||||
process.chdir(tmp)
|
||||
|
||||
Object.assign(process.env, {
|
||||
GITEA_BASE_URL: gitea.baseUrl,
|
||||
GITEA_API_TOKEN: 'test-token',
|
||||
GITEA_WEBHOOK_TOKEN: WEBHOOK_TOKEN,
|
||||
APPWRITE_ENDPOINT: aw.endpoint,
|
||||
APPWRITE_API_KEY: 'test-key',
|
||||
PREVIEW_DEPLOY_ROOT: '',
|
||||
TRAEFIK_DYNAMIC_DIR: '',
|
||||
PUSH_TIME_SECRET: 'w'.repeat(40),
|
||||
})
|
||||
|
||||
const [{ default: giteaWebhookRoutes }, mailer] = await Promise.all([
|
||||
import('../server/routes/webhook/gitea.js'),
|
||||
import('../server/services/mailer.js'),
|
||||
])
|
||||
setMailTransportForTests = mailer.setMailTransportForTests
|
||||
|
||||
const app = express()
|
||||
app.use(express.json({ limit: '2mb', verify: (req, _res, buf) => { req.rawBody = buf } }))
|
||||
app.use('/webhook', giteaWebhookRoutes)
|
||||
server = await new Promise((resolve) => {
|
||||
const s = app.listen(0, '127.0.0.1', () => resolve(s))
|
||||
})
|
||||
baseUrl = `http://127.0.0.1:${server.address().port}`
|
||||
})
|
||||
|
||||
after(async () => {
|
||||
setMailTransportForTests?.(null)
|
||||
if (server) await new Promise((resolve) => server.close(resolve))
|
||||
if (originalCwd) process.chdir(originalCwd)
|
||||
await gitea?.close()
|
||||
await aw?.close()
|
||||
if (tmp) await fs.rm(tmp, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test('Push von knso → eine Zeitmail, Antwort ohne Dokumente', async () => {
|
||||
const sink = mailSink()
|
||||
setMailTransportForTests(sink)
|
||||
const r = await webhook(pushPayload({ sha: 'aaaa1111', pusher: 'knso' }))
|
||||
assert.equal(r.status, 200)
|
||||
assert.equal(r.body.gitWorksheet.created, true)
|
||||
assert.equal(r.body.gitWorksheet.ticket, undefined)
|
||||
assert.equal(r.body.gitWorksheet.worksheet, undefined)
|
||||
assert.ok(r.body.gitWorksheet.worksheetId)
|
||||
assert.ok(await waitFor(() => sink.sent.length === 1))
|
||||
assert.deepEqual(sink.sent[0].to.map((t) => t.address), ['kenso@webklar.com'])
|
||||
assert.match(sink.sent[0].subject, /^\[Zeit\] #100118 Amin Friseur/)
|
||||
assert.match(sink.sent[0].html, new RegExp(`/zeit/[^"?]+\\?min=60`))
|
||||
})
|
||||
|
||||
test('derselbe Push erneut → keine zweite Mail', async () => {
|
||||
const sink = mailSink()
|
||||
setMailTransportForTests(sink)
|
||||
const r = await webhook(pushPayload({ sha: 'aaaa1111', pusher: 'knso' }))
|
||||
assert.equal(r.status, 200)
|
||||
assert.equal(r.body.gitWorksheet.reason, 'duplicate_commit')
|
||||
await new Promise((res) => setTimeout(res, 100))
|
||||
assert.equal(sink.sent.length, 0)
|
||||
})
|
||||
|
||||
test('System-Konto → keine Mail', async () => {
|
||||
const sink = mailSink()
|
||||
setMailTransportForTests(sink)
|
||||
const r = await webhook(pushPayload({ sha: 'bbbb2222', pusher: 'webklar-system' }))
|
||||
assert.equal(r.status, 200)
|
||||
await new Promise((res) => setTimeout(res, 100))
|
||||
assert.equal(sink.sent.length, 0)
|
||||
})
|
||||
|
||||
test('Mailfehler ändert die Webhook-Antwort nicht', async () => {
|
||||
setMailTransportForTests({ sendMail: async () => { throw new Error('SMTP kaputt') } })
|
||||
const r = await webhook(pushPayload({ sha: 'cccc3333', pusher: 'knso' }))
|
||||
assert.equal(r.status, 200)
|
||||
assert.equal(r.body.gitWorksheet.created, true)
|
||||
})
|
||||
Reference in new issue
Block a user