Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6ac4510cd | ||
|
|
48dd19b75a | ||
|
|
5f5df7ba1a | ||
|
|
acf6ff9ee1 |
No files matched your search
@@ -0,0 +1,271 @@
|
||||
# Admin-Panel ins Ticketsystem – Etappe 2 (Chat) 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:** Mitarbeiter chatten mit Kunden im WOMS – Messenger unter Kunden → Chat, Reiter „Chat“ auf der Kundenseite, Zahl der ungelesenen Nachrichten in der Sidebar – mit Anhängen wie heute; das Admin-Panel bleibt nutzbar.
|
||||
|
||||
**Architecture:** Die Chat-Routen des Admin-Panels werden ein eigener Router, den das Portal unter `/api/portal-admin` (Cookie, alt) und `/api/team` (JWT, neu) einhängt. Anhänge bekommen signierte Links (15 min), damit `<img>`/`<audio>`/Download im WOMS ohne Header funktionieren. WOMS lädt `chat-composer.js/.css` zur Laufzeit vom Portal (eine Quelle) und bettet Eingabezeile und Sprechblasen in React ein; der Composer bekommt dafür die Option `requestHeaders`.
|
||||
|
||||
**Tech Stack:** Portal: Express 4 (ESM, Node 20), node:test + `test/support/*`, Playwright-core für UI-Checks. WOMS: React 18, react-router 6, appwrite 13, node:test + `tests/unit/load.mjs`.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-10-01-admin-panel-ins-ticketsystem-design.md` (Abschnitt 1 Sidebar, Abschnitt 2 „Chat“, Abschnitt 4 Tabelle `/api/team` Zeile Chat + „Dateien ohne Header“ + CORS, Etappe 2 in Abschnitt 5)
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Arbeitsorte: Portal `/home/webklar/apps/kundenbereich.webklar.com/dev-admin-umzug` (Branch `feature/admin-umzug`, Stand `6718018` = origin/main). WOMS `/home/webklar/apps/ticket.webklar.com/dev-admin-umzug` (Branch `feature/admin-umzug`, Stand `fce2f41` = origin/test). Vor Beginn je `git fetch` und `git merge --ff-only origin/main` bzw. `origin/test`.
|
||||
- Nie `git add -A`; nichts pushen (WOMS deployt jeden gepushten Branch). Livegang macht der Nutzer, Portal zuerst.
|
||||
- `command grep -a` statt `grep`. UI-Texte deutsch mit Umlauten.
|
||||
- Funktionsgleich zum Admin-Panel, keine neuen Chat-Funktionen. Kunden-Chat im Dashboard bleibt unverändert.
|
||||
- Signierte Links: HMAC-SHA256 (hex) mit `config.sessionSecret` über `chat:<customerId>:<attachmentId>:<exp>`, Gültigkeit **15 min**, Query `exp` (ms seit Epoch) und `sig`.
|
||||
- Polling: offenes Gespräch **15 s**, Gesprächsliste **30 s**, Sidebar-Zahl **60 s** – jeweils nur bei sichtbarem Tab. Gesprächsverlauf komplett neu laden (frische Links) nach **10 min**.
|
||||
- K17-Hinweistexte wortgleich zum Admin-Panel (`public/admin.js` `showAdminChatNotice`).
|
||||
- Speicherbremse für Builds/Playwright: `systemd-run --scope -p MemoryMax=… -p MemorySwapMax=0`.
|
||||
|
||||
## Review Focus
|
||||
|
||||
1. **Gespräch länger als 15 min offen:** alte Anhang-Links laufen ab. Erwartung: Verlauf wird nach 10 min bzw. beim Zurückkehren in den Tab neu geladen, Download/Lightbox funktionieren weiter (Task 5: `needsFullRefresh`).
|
||||
2. **Schneller Kundenwechsel im Messenger:** Antwort für Kunde A kommt an, nachdem B gewählt wurde. Erwartung: nichts von A erscheint im Gespräch von B (Task 6, Playwright).
|
||||
3. **JWT nicht holbar beim Hochladen:** Erwartung: Anhang zeigt Fehler, kein hängender Fortschritt (Task 3, Playwright).
|
||||
4. **Kunden-Chat im Dashboard:** ohne `requestHeaders` exakt wie bisher, kein Authorization-Header (Task 3, Playwright).
|
||||
5. **Handy (390 px):** Messenger zeigt Liste oder Gespräch mit Zurück-Knopf, kein waagerechtes Scrollen (Task 6, Playwright).
|
||||
|
||||
---
|
||||
|
||||
## Portal
|
||||
|
||||
### Task 1: Signierte Datei-Links
|
||||
|
||||
**Files:**
|
||||
- Create: `server/services/signedLinks.js`
|
||||
- Test: `test/signedLinks.test.js`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `FILE_LINK_TTL_MS = 900000`; `signFileLink({ kind, customerId, id, now = Date.now() }) -> { exp: number, sig: string }`; `verifyFileLink({ kind, customerId, id, exp, sig, now = Date.now() }) -> boolean` (falsch bei `now >= exp`, fehlender/kaputter Signatur, anderer Länge – `timingSafeEqual` nur bei gleicher Länge).
|
||||
|
||||
- [ ] **Step 1: Failing tests**
|
||||
|
||||
```js
|
||||
test('Signatur passt nur fuer dieselbe Datei, denselben Kunden und dieselbe Art', () => {
|
||||
const { exp, sig } = signFileLink({ kind: 'chat', customerId: 'c1', id: 'a1', now: 1000 })
|
||||
assert.equal(exp, 1000 + 900000)
|
||||
assert.match(sig, /^[0-9a-f]{64}$/)
|
||||
assert.equal(verifyFileLink({ kind: 'chat', customerId: 'c1', id: 'a1', exp, sig, now: 2000 }), true)
|
||||
for (const other of [{ customerId: 'c2' }, { id: 'a2' }, { kind: 'takeover' }]) {
|
||||
assert.equal(verifyFileLink({ kind: 'chat', customerId: 'c1', id: 'a1', exp, sig, now: 2000, ...other }), false)
|
||||
}
|
||||
})
|
||||
test('abgelaufen genau bei exp, manipuliertes exp, kaputte Signatur', () => {
|
||||
const { exp, sig } = signFileLink({ kind: 'chat', customerId: 'c1', id: 'a1', now: 0 })
|
||||
assert.equal(verifyFileLink({ kind: 'chat', customerId: 'c1', id: 'a1', exp, sig, now: exp }), false)
|
||||
assert.equal(verifyFileLink({ kind: 'chat', customerId: 'c1', id: 'a1', exp: exp + 1, sig, now: 0 }), false)
|
||||
for (const bad of ['', 'abc', 'x'.repeat(64), undefined]) {
|
||||
assert.equal(verifyFileLink({ kind: 'chat', customerId: 'c1', id: 'a1', exp, sig: bad, now: 0 }), false)
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2:** Run `node --test test/signedLinks.test.js` → FAIL (Modul fehlt)
|
||||
- [ ] **Step 3:** Implementieren (`createHmac`, `timingSafeEqual` aus `node:crypto`; `exp` als Zahl prüfen)
|
||||
- [ ] **Step 4:** Run `node --test test/signedLinks.test.js` → PASS
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add server/services/signedLinks.js test/signedLinks.test.js
|
||||
git commit -m "Signierte Datei-Links (15 min) fuer Anhaenge im Ticketsystem"
|
||||
```
|
||||
|
||||
### Task 2: Chat-Router für Admin-Panel und Team-Eingang, signierte Anhang-Links
|
||||
|
||||
**Files:**
|
||||
- Create: `server/routes/chatAdmin.js` (Chat-Teil aus `server/routes/portalAdmin.js:309-474` verschoben, Hilfsfunktion `customerNameMap` mitnehmen bzw. dort exportieren)
|
||||
- Modify: `server/routes/portalAdmin.js` (Chat-Routen entfernen, `router.use(chatAdminRouter)`)
|
||||
- Create: `server/routes/team/files.js`
|
||||
- Modify: `server/routes/team/index.js` (`router.use(teamFilesRouter)` **vor** `requireStaffJwt`, `router.use(chatAdminRouter)` danach)
|
||||
- Test: `test/teamChat.test.js`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `signFileLink`, `verifyFileLink` (Task 1); `requireStaffJwt` (Etappe 1, setzt `req.session` mit `appwriteUserId`, `name`).
|
||||
- Produces (HTTP, unter `/api/portal-admin` und `/api/team` gleich): `GET /chats` → `{ chats: [{ customerId, customerName, lastText, lastSender, lastAt, unreadFromCustomer }] }`; `GET /chats/unread-count` → `{ unread }`; `GET /chats/:id/messages?since=` → `{ messages }`; `POST /chats/:id/messages` `{ text, attachmentIds }` → 201 `{ message, notice }`; `POST /chats/:id/attachments` (roh, Header `X-File-Name` …) → 201 `{ attachment }`; `DELETE /chats/:id/attachments/:att` → 204; `POST /chats/:id/read` → `{ updated }`.
|
||||
- Produces: in jeder Nachricht der Antworten von `GET …/messages` und `POST …/messages` trägt jeder Anhang zusätzlich `url` = `${config.publicBaseUrl}/api/team/chats/<customerId>/attachments/<id>?exp=<exp>&sig=<sig>`.
|
||||
- Produces: `GET /api/team/chats/:customerId/attachments/:id` liefert die Datei mit gültiger Signatur **ohne** JWT; ungültige/abgelaufene Signatur → 403 `{ error: 'Link abgelaufen. Bitte die Unterhaltung neu laden.' }`; ohne `sig` weiter zur JWT-Prüfung (ohne JWT 401, mit JWT 200).
|
||||
|
||||
- [ ] **Step 1: Failing tests** (Aufbau wie `test/teamRoutes.test.js`; `CHAT_UPLOAD_DIR` auf `mkdtempSync`; `setMailTransportForTests(mailSink())`; Router `/api/team` und `/api/portal-admin`; Fixtures `seedFixtures`, `adminCookie`)
|
||||
|
||||
```js
|
||||
test('Liste und ungelesene Zahl ueber den Team-Eingang', async () => { /* 2 Kundennachrichten c1 unread -> chats[0].unreadFromCustomer 2, unread-count 2 */ })
|
||||
test('Senden als Mitarbeiter: Absendername aus dem JWT, K17-Mail an den Kunden', async () => {
|
||||
const res = await post('/api/team/chats/c1/messages', { text: 'Hallo' }, staff)
|
||||
assert.equal(res.status, 201)
|
||||
assert.equal(res.body.message.senderName, 'Andrej')
|
||||
assert.equal(res.body.notice.sent, true)
|
||||
assert.equal(sink.sent.at(-1).to[0].address, 'k@example.invalid')
|
||||
})
|
||||
test('Anhang hochladen, mit Nachricht senden -> signierte url', async () => {
|
||||
// POST /api/team/chats/c1/attachments, Body Buffer 'hallo', Header content-type application/octet-stream, x-file-name notiz.txt
|
||||
// POST messages { text: '', attachmentIds: [id] } -> message.attachments[0].url passt auf
|
||||
// /^https:\/\/project\.webklar\.com\/api\/team\/chats\/c1\/attachments\/[^?]+\?exp=\d+&sig=[0-9a-f]{64}$/
|
||||
})
|
||||
test('signierter Link: ohne JWT 200 mit Inhalt, manipuliert/fremder Kunde/abgelaufen 403, ohne sig 401, JWT ohne sig 200', async (t) => {})
|
||||
test('Lesen markiert Kundennachrichten', async () => { /* POST /api/team/chats/c1/read -> updated 2, danach unread-count 0 */ })
|
||||
test('Admin-Panel unveraendert: /api/portal-admin/chats mit Admin-Cookie 200, ohne 401', async () => {})
|
||||
```
|
||||
|
||||
- [ ] **Step 2:** Run `node --test test/teamChat.test.js` → FAIL (404 unter `/api/team/chats`)
|
||||
- [ ] **Step 3:** Implementieren. In `chatAdmin.js` eine Hilfe `withSignedUrls(message, now)` für die Antworten; `files.js` nutzt `openAttachment({ viewer: 'admin' })` und `sendAttachment` aus `services/chatAttachments.js`.
|
||||
- [ ] **Step 4:** Run `npm test` → PASS (alle bisherigen Tests bleiben grün, insbesondere `test/support.test.js`, `test/chatNotice.test.js`)
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add server/routes/chatAdmin.js server/routes/portalAdmin.js server/routes/team/files.js server/routes/team/index.js test/teamChat.test.js
|
||||
git commit -m "Chat ueber den Team-Eingang: gemeinsamer Router fuer Admin-Panel und Ticketsystem, signierte Anhang-Links"
|
||||
```
|
||||
|
||||
### Task 3: Composer-Option `requestHeaders`
|
||||
|
||||
**Files:**
|
||||
- Modify: `public/chat-composer.js` (Optionsdoku Zeile ~536; `deleteOnServer` ~711; `upload` ~734)
|
||||
- Create: `test/ui/composer-headers.ui.mjs` (Playwright, nicht Teil von `npm test`)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `ChatComposer.create({ …, requestHeaders?: () => object | Promise<object> })`. Die Header gehen an den Upload-XHR (zusätzlich zu den bestehenden) und an das DELETE beim Entfernen. Wirft/verwirft `requestHeaders`, wird der Anhang `status: 'error'` mit `error = err.message || 'Anmeldung abgelaufen. Bitte Seite neu laden.'`. Ohne Option: Verhalten unverändert.
|
||||
|
||||
- [ ] **Step 1: UI-Check schreiben** (Muster `test/ui/kundenansicht.ui.mjs`; statischer Server für `public/` plus Testseite `/composer-test.html`, die `chat-composer.css/.js` lädt und `ChatComposer.create` mit `uploadUrl: '/upload'` und einem per Query wählbaren `requestHeaders` aufruft; `/upload` per `page.route`)
|
||||
|
||||
Checks:
|
||||
- `mit_header_upload`: async `requestHeaders` → POST `/upload` trägt `authorization: Bearer test`.
|
||||
- `mit_header_delete`: Anhang entfernen → DELETE `/upload/a1` trägt `authorization: Bearer test`.
|
||||
- `ohne_option_kein_header`: ohne `requestHeaders` → POST ohne `authorization`.
|
||||
- `header_fehler`: `requestHeaders` verwirft mit `new Error('JWT weg')` → Anhang zeigt „JWT weg“, kein POST.
|
||||
|
||||
- [ ] **Step 2:** Run `node test/ui/composer-headers.ui.mjs` → FAIL (`mit_header_upload`, `mit_header_delete`, `header_fehler`)
|
||||
- [ ] **Step 3:** Implementieren (Header vor `xhr.open` asynchron holen; zwischendurch entfernte oder zerstörte Anhänge nicht mehr hochladen)
|
||||
- [ ] **Step 4:** Run `node test/ui/composer-headers.ui.mjs` → „alle Checks ok“; Run `node test/ui/kundenansicht.ui.mjs` → „alle Checks ok“
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add public/chat-composer.js test/ui/composer-headers.ui.mjs
|
||||
git commit -m "Chat-Baustein: Option requestHeaders fuer Hochladen und Entfernen (Ticketsystem mit JWT)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## WOMS
|
||||
|
||||
### Task 4: Team-API für den Chat und Laden des Chat-Bausteins
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/lib/teamApi.js`
|
||||
- Create: `src/lib/loadChatComposer.js`
|
||||
- Test: `tests/unit/loadChatComposer.test.mjs`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `createJwtCache` aus `src/lib/serverpulsAuth.js`; HTTP aus Task 2.
|
||||
- Produces in `teamApi.js`: `PORTAL_URL` (export); `listChats() -> Promise<{ chats }>`; `getChatUnreadCount() -> Promise<{ unread }>`; `getChatMessages(customerId, since = '') -> Promise<{ messages }>`; `sendChatMessage(customerId, { text, attachmentIds }) -> Promise<{ message, notice }>`; `markChatRead(customerId) -> Promise<{ updated }>`; `chatUploadUrl(customerId) -> string` (`${PORTAL_URL}/api/team/chats/<id>/attachments`); `teamAuthHeaders() -> Promise<{ Authorization: string }>` (eigener `createJwtCache` über `account.createJWT`).
|
||||
- Produces: `loadChatComposer({ baseUrl, doc = globalThis.document, win = globalThis.window }) -> Promise<ChatComposer>`: hängt einmal `<link rel="stylesheet" href="<baseUrl>/chat-composer.css">` und `<script src="<baseUrl>/chat-composer.js">` an `doc.head`; löst mit `win.ChatComposer` auf; Fehler → `Error('Der Chat-Baustein konnte nicht geladen werden.')`; je `doc` nur ein Ladevorgang (Promise wird wiederverwendet, nach Fehler neu versucht).
|
||||
|
||||
- [ ] **Step 1: Failing tests** (Fake-`doc` mit `head.appendChild`, `createElement` → Objekt mit `setAttribute`, `onload`/`onerror`)
|
||||
|
||||
```js
|
||||
test('haengt CSS und Skript einmal an und liefert window.ChatComposer', async () => { /* zwei Aufrufe -> 2 Elemente, gleicher Rueckgabewert */ })
|
||||
test('Skript laedt nicht -> deutsche Meldung, naechster Aufruf versucht es neu', async () => {})
|
||||
test('schon geladen (win.ChatComposer vorhanden) -> nichts anhaengen', async () => {})
|
||||
```
|
||||
|
||||
- [ ] **Step 2:** Run `node --test tests/unit/loadChatComposer.test.mjs` → FAIL
|
||||
- [ ] **Step 3:** Implementieren
|
||||
- [ ] **Step 4:** Run `npm test` → PASS
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/lib/teamApi.js src/lib/loadChatComposer.js tests/unit/loadChatComposer.test.mjs
|
||||
git commit -m "Chat: Team-API und Laden des Chat-Bausteins vom Kundenportal"
|
||||
```
|
||||
|
||||
### Task 5: Sidebar mit Unterpunkt „Chat“ und Zahl, Chat-Hilfen
|
||||
|
||||
**Files:**
|
||||
- Create: `src/lib/navLinks.js`, `src/lib/chatFormat.js`, `src/hooks/useChatUnread.js`
|
||||
- Modify: `src/components/Navbar.jsx:57-113` (Liste aus `buildNavLinks`, Unterpunkte rendern), `src/styles/global.css` (Unterpunkt-Stil `.side-sublink`, Zahl `.side-count`)
|
||||
- Test: `tests/unit/navLinks.test.mjs`, `tests/unit/chatFormat.test.mjs`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `getChatUnreadCount` (Task 4); `formatViewLogTime` aus `src/lib/viewLog.js`.
|
||||
- Produces: `buildNavLinks({ isAdmin, pulseLevel, chatUnread }) -> Array<{ key, label, href, also?, paused?, dot?, count?, children? }>`; Kunden-Eintrag: `{ key: 'customers', label: 'Kunden', href: '/customers', children: [{ key: 'chat', label: 'Chat', href: '/customers/chat', count: chatUnread }] }`; übrige Einträge und Reihenfolge wie heute (Navbar ordnet Icons über `key` zu).
|
||||
- Produces: `useChatUnread() -> number` (alle 60 s bei sichtbarem Tab, sofort beim Sichtbarwerden; Fehler behalten den letzten Wert).
|
||||
- Produces in `chatFormat.js`: `sortChats(chats)` (ungelesene zuerst, sonst `lastAt` absteigend); `noticeText(notice, time)` mit den drei Texten aus dem Admin-Panel (`E-Mail-Hinweis an ${to} gesendet (${time})` / `Kein neuer E-Mail-Hinweis: Der Kunde wurde in den letzten 15 Minuten schon benachrichtigt.` / `Kein E-Mail-Hinweis: ${reason}`; ohne notice `''`); `lastLine(chat)` (`Du: ` vor eigenen); `messageMeta(msg, customerName)` (`<Absender> · dd.mm.yyyy hh:mm`, eigener Absender `senderName || 'Team'`); `needsFullRefresh(lastFullAt, now) -> boolean` (ab 600000 ms).
|
||||
|
||||
- [ ] **Step 1: Failing tests**
|
||||
|
||||
```js
|
||||
test('buildNavLinks: Chat unter Kunden mit Zahl, Admin-Eintraege nur fuer Admins', () => {
|
||||
const links = buildNavLinks({ isAdmin: false, pulseLevel: 'ok', chatUnread: 3 })
|
||||
const kunden = links.find((l) => l.key === 'customers')
|
||||
assert.deepEqual(kunden.children, [{ key: 'chat', label: 'Chat', href: '/customers/chat', count: 3 }])
|
||||
assert.deepEqual(links.map((l) => l.key), ['tickets', 'planboard', 'projects', 'services', 'customers', 'leads', 'legal'])
|
||||
assert.ok(buildNavLinks({ isAdmin: true, pulseLevel: 'warn', chatUnread: 0 }).some((l) => l.key === 'serverpuls' && l.dot === 'warn'))
|
||||
})
|
||||
test('sortChats: ungelesene zuerst, dann neueste', () => {})
|
||||
test('noticeText: gesendet, gedrosselt, Grund, leer', () => {
|
||||
assert.equal(noticeText({ sent: true, to: 'k@x.de' }, '14:05'), 'E-Mail-Hinweis an k@x.de gesendet (14:05)')
|
||||
assert.equal(noticeText({ sent: false, reason: 'throttled' }, '14:05'), 'Kein neuer E-Mail-Hinweis: Der Kunde wurde in den letzten 15 Minuten schon benachrichtigt.')
|
||||
assert.equal(noticeText({ sent: false, reason: 'SMTP weg' }, '14:05'), 'Kein E-Mail-Hinweis: SMTP weg')
|
||||
assert.equal(noticeText(null, '14:05'), '')
|
||||
})
|
||||
test('messageMeta und lastLine', () => {})
|
||||
test('needsFullRefresh ab 10 Minuten', () => {
|
||||
assert.equal(needsFullRefresh(0, 599999), false)
|
||||
assert.equal(needsFullRefresh(0, 600000), true)
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2:** Run `node --test tests/unit/navLinks.test.mjs tests/unit/chatFormat.test.mjs` → FAIL
|
||||
- [ ] **Step 3:** Implementieren; Navbar: Unterpunkte eingerückt (`.side-sublink`), eingeklappt nur Icon (`IconMessages`), Zahl als `.side-count` nur bei `count > 0`; „Kunden“ bleibt markiert, solange ein Unterpunkt aktiv ist. Knopf „Projekt-Admin“ bleibt (fällt erst in Etappe 4).
|
||||
- [ ] **Step 4:** Run `npm test` → PASS
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/lib/navLinks.js src/lib/chatFormat.js src/hooks/useChatUnread.js src/components/Navbar.jsx src/styles/global.css tests/unit/navLinks.test.mjs tests/unit/chatFormat.test.mjs
|
||||
git commit -m "Sidebar: Chat als Unterpunkt von Kunden mit Zahl der ungelesenen Nachrichten"
|
||||
```
|
||||
|
||||
### Task 6: Gespräch, Messenger und Reiter „Chat“
|
||||
|
||||
**Files:**
|
||||
- Create: `src/components/chat/ChatThread.jsx`, `src/pages/CustomerChatPage.jsx`, `src/styles/chat.css`
|
||||
- Modify: `src/App.jsx:51` (Route `/customers/chat` vor `/customers/:id`), `src/lib/customerTabs.js` (Reiter `{ id: 'chat', label: 'Chat' }` vor `protokoll`), `src/pages/CustomerDetailPage.jsx` (Reiter rendert `ChatThread`)
|
||||
- Test: `tests/unit/customerTabs.test.mjs` (anpassen); Playwright-Harness im Scratchpad (nicht committen)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Tasks 4 und 5.
|
||||
- Produces: `ChatThread({ customerId, customerName })`: lädt `loadChatComposer`, rendert Nachrichten mit `ChatComposer.renderMessage(msg, { mine: msg.sender === 'admin', meta: messageMeta(msg, customerName), fileUrl: (att) => att.url })` in einen scrollenden Verlauf, Eingabezeile mit `ChatComposer.create({ root, dropZone, uploadUrl: chatUploadUrl(id), requestHeaders: teamAuthHeaders, placeholder: 'Antwort an <Name>…', placeholderShort: 'Antwort…', send, onSent })`; Hinweiszeile mit `noticeText`; Abfrage alle 15 s mit `since`; neue Kundennachrichten → `markChatRead`; beim Öffnen `markChatRead`; vollständiges Neuladen nach `needsFullRefresh` (Intervall und beim Sichtbarwerden); Antworten für einen inzwischen gewechselten Kunden werden verworfen; `composer.destroy()` beim Wechsel/Verlassen; Ladefehler → Zeile mit „Erneut versuchen“.
|
||||
- Produces: `CustomerChatPage` unter `/customers/chat`: links Feld „Neue Nachricht an Kunde…“ (`AutocompleteInput` über `useCustomers`, Treffer per Name → `?kunde=<id>`) und Liste (`sortChats`, Zahl ungelesen, `lastLine`, Zeit), Abfrage alle 30 s; rechts `ChatThread` zu `?kunde` oder „Unterhaltung links auswählen“. Unter 720 px: ohne `kunde` nur Liste, mit `kunde` nur Gespräch mit Knopf „← Alle Unterhaltungen“.
|
||||
- Produces: Reihenfolge der Kundenreiter `Übersicht · Tickets · Rechnungen · Projekte · Chat · Protokoll` (Rechnungen nur Admins).
|
||||
|
||||
- [ ] **Step 1: Test anpassen**
|
||||
|
||||
```js
|
||||
assert.deepEqual(customerTabs(false).map((t) => t.id), ['overview', 'tickets', 'projects', 'chat', 'protokoll'])
|
||||
assert.equal(resolveCustomerTab('chat', false), 'chat')
|
||||
```
|
||||
|
||||
- [ ] **Step 2:** Run `node --test tests/unit/customerTabs.test.mjs` → FAIL
|
||||
- [ ] **Step 3:** Implementieren; `chat.css` setzt `--chat-accent: var(--accent)`, `--chat-accent-hover: var(--accent-2)`, `--chat-accent-soft: var(--accent-soft)` und die Sprechblasen/Verlauf-Stile (`.chat-messages`, `.chat-bubble.mine/.theirs`, `.chat-meta`) innerhalb `.woms-chat`.
|
||||
- [ ] **Step 4:** Run `npm test` → PASS; Vite-Build mit `systemd-run --scope -q -p MemoryMax=1500M -p MemorySwapMax=0 env NODE_OPTIONS=--max-old-space-size=1200 node node_modules/vite/bin/vite.js build --outDir <scratchpad>/woms-out --emptyOutDir` → „built in“
|
||||
- [ ] **Step 5: Playwright-Harness** (wie Etappe 1: esbuild-Bündel mit AuthContext-Stub, Appwrite-REST und `https://project.webklar.com/api/team/*` per `ctx.route`, `chat-composer.js/.css` aus dem Portal-Worktree ausgeliefert). Checks: Messenger-Liste sortiert, Zahl sichtbar; Klick öffnet Gespräch, Adresse `?kunde=c1`; Nachricht senden → POST mit Bearer, Hinweiszeile „E-Mail-Hinweis an … gesendet (hh:mm)“; Bild-Anhang in der Nachricht → `<img>` mit signierter `url`; Datei hochladen → POST an `/api/team/chats/c1/attachments` mit Bearer; Kunde wechseln, während die Antwort für A verzögert ist → keine Blase von A im Gespräch von B; Reiter „Chat“ auf der Kundenseite zeigt dasselbe Gespräch; Sidebar-Zahl; 390 px: Liste ↔ Gespräch mit Zurück-Knopf, kein waagerechtes Scrollen; keine JS-Fehler. Screenshots nach `/root/reports/admin-umzug-ui/woms-chat-*.png`.
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/components/chat/ChatThread.jsx src/pages/CustomerChatPage.jsx src/styles/chat.css src/App.jsx src/lib/customerTabs.js src/pages/CustomerDetailPage.jsx tests/unit/customerTabs.test.mjs
|
||||
git commit -m "Chat im Ticketsystem: Messenger unter Kunden, Reiter Chat auf der Kundenseite"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Abschluss
|
||||
|
||||
### Task 7: Gesamtprüfung und Übergabe
|
||||
|
||||
- [ ] Portal `npm test` → grün; neue Tests unter Node 20 (`docker run --rm … node:20-alpine node --test test/signedLinks.test.js test/teamChat.test.js`) → grün.
|
||||
- [ ] WOMS `npm test` → grün; Build aus Task 6 erfolgreich.
|
||||
- [ ] Abschlussreview beider Branches (frischer Reviewer, Modell sonnet).
|
||||
- [ ] Übergabe mit Livegang: 1. Portal `git -C /home/webklar/apps/kundenbereich.webklar.com/dev-admin-umzug push origin feature/admin-umzug:main`; 2. Live-Check (ich): `/api/team/chats` ohne JWT 401, `chat-composer.js` enthält `requestHeaders`, Admin-Panel-Chat lädt; 3. WOMS `git -C /home/webklar/apps/ticket.webklar.com/dev-admin-umzug push origin feature/admin-umzug:test`; 4. Live-Check (ich): Bundle enthält „Neue Nachricht an Kunde“; 5. Nutzer-Test: Nachricht aus dem WOMS senden, Antwort des Kunden mit Bild ansehen.
|
||||
+27
-2
@@ -535,7 +535,9 @@
|
||||
/**
|
||||
* Eingabezeile mit Anhaengen und Sprachaufnahme.
|
||||
* options: root, dropZone, uploadUrl (POST roh, DELETE uploadUrl/id), send({ text, attachmentIds }) -> message,
|
||||
* onSent(message), placeholder, placeholderShort (schmale Bildschirme), disabled, disabledText
|
||||
* onSent(message), placeholder, placeholderShort (schmale Bildschirme), disabled, disabledText,
|
||||
* requestHeaders() -> Objekt oder Promise (zusaetzliche Header fuer Hochladen und Entfernen, z. B. das
|
||||
* Bearer-JWT des Ticketsystems; ohne die Option wie bisher nur das Cookie)
|
||||
*/
|
||||
function create(options) {
|
||||
const opts = { placeholder: 'Nachricht schreiben…', disabled: false, disabledText: '', onSent() {}, ...options }
|
||||
@@ -708,9 +710,15 @@
|
||||
return item
|
||||
}
|
||||
|
||||
function extraHeaders() {
|
||||
return Promise.resolve(opts.requestHeaders ? opts.requestHeaders() : {}).then((headers) => headers || {})
|
||||
}
|
||||
|
||||
function deleteOnServer(item) {
|
||||
if (!item.meta || !item.meta.id) return
|
||||
fetch(`${opts.uploadUrl}/${encodeURIComponent(item.meta.id)}`, { method: 'DELETE', credentials: 'same-origin' }).catch(() => {})
|
||||
extraHeaders()
|
||||
.then((headers) => fetch(`${opts.uploadUrl}/${encodeURIComponent(item.meta.id)}`, { method: 'DELETE', credentials: 'same-origin', headers }))
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
function dropItem(item) {
|
||||
@@ -743,9 +751,26 @@
|
||||
item.status = 'uploading'
|
||||
item.progress = 0
|
||||
item.error = ''
|
||||
renderTray()
|
||||
extraHeaders().then(
|
||||
(headers) => {
|
||||
// Waehrend des Wartens entfernt oder Eingabezeile weg: nicht mehr hochladen
|
||||
if (destroyed || !items.includes(item)) return
|
||||
sendUpload(item, headers)
|
||||
},
|
||||
(err) => {
|
||||
item.status = 'error'
|
||||
item.error = (err && err.message) || 'Anmeldung abgelaufen. Bitte Seite neu laden.'
|
||||
afterUpload(item)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function sendUpload(item, headers) {
|
||||
const xhr = new XMLHttpRequest()
|
||||
item.xhr = xhr
|
||||
xhr.open('POST', opts.uploadUrl)
|
||||
for (const [name, value] of Object.entries(headers)) xhr.setRequestHeader(name, value)
|
||||
xhr.setRequestHeader('Content-Type', 'application/octet-stream')
|
||||
xhr.setRequestHeader('X-File-Name', encodeURIComponent(item.name))
|
||||
if (item.hint) xhr.setRequestHeader('X-Attachment-Kind', item.hint)
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
import { Router } from 'express'
|
||||
import { config } from '../config.js'
|
||||
import { createDocument, getDocument, listDocuments, updateDocument, Query, ID } from '../services/appwriteAdmin.js'
|
||||
import { prepareMessage, rawUpload, sanitizeMessage, uploadErrors } from './chat.js'
|
||||
import { sendCustomerChatNotice } from '../services/mailer.js'
|
||||
import {
|
||||
attachmentLines,
|
||||
attachmentSummary,
|
||||
deletePendingAttachment,
|
||||
messageAttachments,
|
||||
openAttachment,
|
||||
sendAttachment,
|
||||
storeChatUpload,
|
||||
uploadRateLimited,
|
||||
} from '../services/chatAttachments.js'
|
||||
import { signFileLink } from '../services/signedLinks.js'
|
||||
|
||||
/**
|
||||
* Chat des Teams mit den Kunden. Eingehaengt im Admin-Panel (/api/portal-admin, Cookie) und im Team-Eingang des
|
||||
* Ticketsystems (/api/team, JWT); beide liefern req.session mit appwriteUserId und Namen des Mitarbeiters.
|
||||
*/
|
||||
export const chatAdminRouter = Router()
|
||||
const router = chatAdminRouter
|
||||
|
||||
export function customerNameMap(customers) {
|
||||
const map = {}
|
||||
for (const c of customers) map[c.$id] = c.name || c.code || c.$id
|
||||
return map
|
||||
}
|
||||
|
||||
// Anhaenge bekommen einen signierten Link (15 min) auf den Team-Eingang: das Ticketsystem zeigt Bilder,
|
||||
// Sprachnachrichten und Downloads ohne Authorization-Header an (routes/team/files.js)
|
||||
function withSignedUrls(message, customerId, now = Date.now()) {
|
||||
const base = `${config.publicBaseUrl}/api/team/chats/${encodeURIComponent(customerId)}/attachments`
|
||||
return {
|
||||
...message,
|
||||
attachments: message.attachments.map((att) => {
|
||||
const { exp, sig } = signFileLink({ kind: 'chat', customerId, id: att.id, now })
|
||||
return { ...att, url: `${base}/${encodeURIComponent(att.id)}?exp=${exp}&sig=${sig}` }
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
router.get('/chats', async (_req, res) => {
|
||||
try {
|
||||
const [messages, customers] = await Promise.all([
|
||||
listDocuments(config.collections.portalMessages, [
|
||||
Query.orderDesc('createdAt'),
|
||||
Query.limit(500),
|
||||
]),
|
||||
listDocuments(config.collections.customers, [Query.limit(500)]),
|
||||
])
|
||||
const nameById = customerNameMap(customers)
|
||||
|
||||
const byCustomer = new Map()
|
||||
for (const msg of messages) {
|
||||
const entry = byCustomer.get(msg.customerId) || {
|
||||
customerId: msg.customerId,
|
||||
customerName: nameById[msg.customerId] || msg.customerId,
|
||||
lastText: msg.text || attachmentSummary(messageAttachments(msg)),
|
||||
lastSender: msg.sender || '',
|
||||
lastAt: msg.createdAt || '',
|
||||
unreadFromCustomer: 0,
|
||||
}
|
||||
if (msg.sender === 'customer' && !msg.readByAdmin) entry.unreadFromCustomer += 1
|
||||
byCustomer.set(msg.customerId, entry)
|
||||
}
|
||||
|
||||
const chats = [...byCustomer.values()].sort((a, b) =>
|
||||
String(b.lastAt).localeCompare(String(a.lastAt))
|
||||
)
|
||||
return res.json({ chats })
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: err.message || 'Chats konnten nicht geladen werden' })
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/chats/unread-count', async (_req, res) => {
|
||||
try {
|
||||
const docs = await listDocuments(config.collections.portalMessages, [
|
||||
Query.equal('readByAdmin', false),
|
||||
Query.equal('sender', 'customer'),
|
||||
Query.limit(100),
|
||||
])
|
||||
return res.json({ unread: docs.length })
|
||||
} catch {
|
||||
return res.json({ unread: 0 })
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/chats/:customerId/messages', async (req, res) => {
|
||||
try {
|
||||
const queries = [
|
||||
Query.equal('customerId', req.params.customerId),
|
||||
Query.orderAsc('createdAt'),
|
||||
Query.limit(200),
|
||||
]
|
||||
const since = String(req.query.since || '').trim()
|
||||
if (since) queries.push(Query.greaterThan('createdAt', since))
|
||||
|
||||
const docs = await listDocuments(config.collections.portalMessages, queries)
|
||||
return res.json({ messages: docs.map((doc) => withSignedUrls(sanitizeMessage(doc), req.params.customerId)) })
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: err.message || 'Nachrichten konnten nicht geladen werden' })
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/chats/:customerId/messages', async (req, res) => {
|
||||
let prepared = null
|
||||
try {
|
||||
const customer = await getDocument(config.collections.customers, req.params.customerId).catch(() => null)
|
||||
if (!customer) {
|
||||
return res.status(404).json({ error: 'Kunde nicht gefunden' })
|
||||
}
|
||||
|
||||
prepared = await prepareMessage(req.body, { customerId: customer.$id, uploadedBy: 'admin' })
|
||||
const session = req.session
|
||||
const doc = await createDocument(
|
||||
config.collections.portalMessages,
|
||||
{
|
||||
customerId: customer.$id,
|
||||
sender: 'admin',
|
||||
senderName: session.adminName || session.name || 'Webklar-Team',
|
||||
...prepared.fields,
|
||||
readByAdmin: true,
|
||||
readByCustomer: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
ID.unique()
|
||||
)
|
||||
await prepared.claim.commit(doc.$id)
|
||||
|
||||
// Kunden per Mail hinweisen (K17, gedrosselt); Fehler lassen die Nachricht bestehen
|
||||
const lines = attachmentLines(prepared.claim.attachments)
|
||||
const notice = await sendCustomerChatNotice(customer, {
|
||||
sender: doc.senderName,
|
||||
text: [prepared.text, lines.length ? `Anhänge:\n${lines.join('\n')}` : ''].filter(Boolean).join('\n\n'),
|
||||
})
|
||||
return res.status(201).json({ message: withSignedUrls(sanitizeMessage(doc), customer.$id), notice })
|
||||
} catch (err) {
|
||||
await prepared?.claim.release()
|
||||
return res.status(err.status || 500).json({ error: err.message || 'Nachricht konnte nicht gesendet werden' })
|
||||
}
|
||||
})
|
||||
|
||||
// Anhaenge des Teams an einen Kunden (Ablage wie beim Kunden, siehe services/chatAttachments.js)
|
||||
router.post(
|
||||
'/chats/:customerId/attachments',
|
||||
async (req, res, next) => {
|
||||
if (uploadRateLimited(`admin:${req.session.appwriteUserId}`)) {
|
||||
return res.status(429).json({ error: 'Zu viele Anhänge in kurzer Zeit. Bitte später erneut versuchen.' })
|
||||
}
|
||||
const customer = await getDocument(config.collections.customers, req.params.customerId).catch(() => null)
|
||||
if (!customer) return res.status(404).json({ error: 'Kunde nicht gefunden' })
|
||||
return next()
|
||||
},
|
||||
rawUpload,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const attachment = await storeChatUpload({
|
||||
customerId: req.params.customerId,
|
||||
uploadedBy: 'admin',
|
||||
buffer: req.body,
|
||||
headers: req.headers,
|
||||
})
|
||||
return res.status(201).json({ attachment })
|
||||
} catch (err) {
|
||||
if ((err.status || 500) >= 500) console.error('[chat] Upload (Admin) fehlgeschlagen:', err.message)
|
||||
return res.status(err.status || 500).json({ error: err.status ? err.message : 'Datei konnte nicht gespeichert werden' })
|
||||
}
|
||||
},
|
||||
uploadErrors
|
||||
)
|
||||
|
||||
router.get('/chats/:customerId/attachments/:id', async (req, res) => {
|
||||
try {
|
||||
const found = await openAttachment({ customerId: req.params.customerId, id: req.params.id, viewer: 'admin' })
|
||||
return sendAttachment(req, res, found)
|
||||
} catch (err) {
|
||||
return res.status(err.status || 500).json({ error: err.status ? err.message : 'Datei nicht verfügbar' })
|
||||
}
|
||||
})
|
||||
|
||||
router.delete('/chats/:customerId/attachments/:id', async (req, res) => {
|
||||
try {
|
||||
await deletePendingAttachment({ customerId: req.params.customerId, id: req.params.id, uploadedBy: 'admin' })
|
||||
return res.status(204).end()
|
||||
} catch (err) {
|
||||
return res.status(err.status || 500).json({ error: err.status ? err.message : 'Anhang konnte nicht entfernt werden' })
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/chats/:customerId/read', async (req, res) => {
|
||||
try {
|
||||
const docs = await listDocuments(config.collections.portalMessages, [
|
||||
Query.equal('customerId', req.params.customerId),
|
||||
Query.equal('readByAdmin', false),
|
||||
Query.equal('sender', 'customer'),
|
||||
Query.limit(100),
|
||||
])
|
||||
for (const doc of docs) {
|
||||
await updateDocument(config.collections.portalMessages, doc.$id, { readByAdmin: true })
|
||||
}
|
||||
return res.json({ updated: docs.length })
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: err.message || 'Fehler beim Markieren' })
|
||||
}
|
||||
})
|
||||
@@ -6,28 +6,15 @@ import {
|
||||
setPortalSession,
|
||||
} from '../middleware/session.js'
|
||||
import {
|
||||
createDocument,
|
||||
getDocument,
|
||||
listDocuments,
|
||||
updateDocument,
|
||||
Query,
|
||||
ID,
|
||||
} from '../services/appwriteAdmin.js'
|
||||
import { listAppointments, saveAppointment } from '../services/appointments.js'
|
||||
import { getCustomerActivity } from '../services/customerActivity.js'
|
||||
import { listInvoicesForClient } from '../services/invoiceNinja.js'
|
||||
import { prepareMessage, rawUpload, sanitizeMessage, uploadErrors } from './chat.js'
|
||||
import { sendCustomerChatNotice } from '../services/mailer.js'
|
||||
import {
|
||||
attachmentLines,
|
||||
attachmentSummary,
|
||||
deletePendingAttachment,
|
||||
messageAttachments,
|
||||
openAttachment,
|
||||
sendAttachment,
|
||||
storeChatUpload,
|
||||
uploadRateLimited,
|
||||
} from '../services/chatAttachments.js'
|
||||
import { chatAdminRouter, customerNameMap } from './chatAdmin.js'
|
||||
import { funktionenAdminRouter } from './admin/funktionenAdmin.js'
|
||||
import { offersAdminRouter } from './offers.js'
|
||||
import { storageTasksAdminRouter } from './storageTasks.js'
|
||||
@@ -54,12 +41,6 @@ function isDeployed(project) {
|
||||
return ['ready', 'deployed'].includes(norm(project.provisioningStatus || project.status))
|
||||
}
|
||||
|
||||
function customerNameMap(customers) {
|
||||
const map = {}
|
||||
for (const c of customers) map[c.$id] = c.name || c.code || c.$id
|
||||
return map
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Übersicht --
|
||||
router.get('/overview', async (_req, res) => {
|
||||
try {
|
||||
@@ -306,172 +287,8 @@ router.get('/customers/:customerId/invoices', async (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------------- Chat --
|
||||
router.get('/chats', async (_req, res) => {
|
||||
try {
|
||||
const [messages, customers] = await Promise.all([
|
||||
listDocuments(config.collections.portalMessages, [
|
||||
Query.orderDesc('createdAt'),
|
||||
Query.limit(500),
|
||||
]),
|
||||
listDocuments(config.collections.customers, [Query.limit(500)]),
|
||||
])
|
||||
const nameById = customerNameMap(customers)
|
||||
|
||||
const byCustomer = new Map()
|
||||
for (const msg of messages) {
|
||||
const entry = byCustomer.get(msg.customerId) || {
|
||||
customerId: msg.customerId,
|
||||
customerName: nameById[msg.customerId] || msg.customerId,
|
||||
lastText: msg.text || attachmentSummary(messageAttachments(msg)),
|
||||
lastSender: msg.sender || '',
|
||||
lastAt: msg.createdAt || '',
|
||||
unreadFromCustomer: 0,
|
||||
}
|
||||
if (msg.sender === 'customer' && !msg.readByAdmin) entry.unreadFromCustomer += 1
|
||||
byCustomer.set(msg.customerId, entry)
|
||||
}
|
||||
|
||||
const chats = [...byCustomer.values()].sort((a, b) =>
|
||||
String(b.lastAt).localeCompare(String(a.lastAt))
|
||||
)
|
||||
return res.json({ chats })
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: err.message || 'Chats konnten nicht geladen werden' })
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/chats/unread-count', async (_req, res) => {
|
||||
try {
|
||||
const docs = await listDocuments(config.collections.portalMessages, [
|
||||
Query.equal('readByAdmin', false),
|
||||
Query.equal('sender', 'customer'),
|
||||
Query.limit(100),
|
||||
])
|
||||
return res.json({ unread: docs.length })
|
||||
} catch {
|
||||
return res.json({ unread: 0 })
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/chats/:customerId/messages', async (req, res) => {
|
||||
try {
|
||||
const queries = [
|
||||
Query.equal('customerId', req.params.customerId),
|
||||
Query.orderAsc('createdAt'),
|
||||
Query.limit(200),
|
||||
]
|
||||
const since = String(req.query.since || '').trim()
|
||||
if (since) queries.push(Query.greaterThan('createdAt', since))
|
||||
|
||||
const docs = await listDocuments(config.collections.portalMessages, queries)
|
||||
return res.json({ messages: docs.map(sanitizeMessage) })
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: err.message || 'Nachrichten konnten nicht geladen werden' })
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/chats/:customerId/messages', async (req, res) => {
|
||||
let prepared = null
|
||||
try {
|
||||
const customer = await getDocument(config.collections.customers, req.params.customerId).catch(() => null)
|
||||
if (!customer) {
|
||||
return res.status(404).json({ error: 'Kunde nicht gefunden' })
|
||||
}
|
||||
|
||||
prepared = await prepareMessage(req.body, { customerId: customer.$id, uploadedBy: 'admin' })
|
||||
const session = req.session
|
||||
const doc = await createDocument(
|
||||
config.collections.portalMessages,
|
||||
{
|
||||
customerId: customer.$id,
|
||||
sender: 'admin',
|
||||
senderName: session.adminName || session.name || 'Webklar-Team',
|
||||
...prepared.fields,
|
||||
readByAdmin: true,
|
||||
readByCustomer: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
ID.unique()
|
||||
)
|
||||
await prepared.claim.commit(doc.$id)
|
||||
|
||||
// Kunden per Mail hinweisen (K17, gedrosselt); Fehler lassen die Nachricht bestehen
|
||||
const lines = attachmentLines(prepared.claim.attachments)
|
||||
const notice = await sendCustomerChatNotice(customer, {
|
||||
sender: doc.senderName,
|
||||
text: [prepared.text, lines.length ? `Anhänge:\n${lines.join('\n')}` : ''].filter(Boolean).join('\n\n'),
|
||||
})
|
||||
return res.status(201).json({ message: sanitizeMessage(doc), notice })
|
||||
} catch (err) {
|
||||
await prepared?.claim.release()
|
||||
return res.status(err.status || 500).json({ error: err.message || 'Nachricht konnte nicht gesendet werden' })
|
||||
}
|
||||
})
|
||||
|
||||
// Anhaenge des Teams an einen Kunden (Ablage wie beim Kunden, siehe services/chatAttachments.js)
|
||||
router.post(
|
||||
'/chats/:customerId/attachments',
|
||||
async (req, res, next) => {
|
||||
if (uploadRateLimited(`admin:${req.session.appwriteUserId}`)) {
|
||||
return res.status(429).json({ error: 'Zu viele Anhänge in kurzer Zeit. Bitte später erneut versuchen.' })
|
||||
}
|
||||
const customer = await getDocument(config.collections.customers, req.params.customerId).catch(() => null)
|
||||
if (!customer) return res.status(404).json({ error: 'Kunde nicht gefunden' })
|
||||
return next()
|
||||
},
|
||||
rawUpload,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const attachment = await storeChatUpload({
|
||||
customerId: req.params.customerId,
|
||||
uploadedBy: 'admin',
|
||||
buffer: req.body,
|
||||
headers: req.headers,
|
||||
})
|
||||
return res.status(201).json({ attachment })
|
||||
} catch (err) {
|
||||
if ((err.status || 500) >= 500) console.error('[chat] Upload (Admin) fehlgeschlagen:', err.message)
|
||||
return res.status(err.status || 500).json({ error: err.status ? err.message : 'Datei konnte nicht gespeichert werden' })
|
||||
}
|
||||
},
|
||||
uploadErrors
|
||||
)
|
||||
|
||||
router.get('/chats/:customerId/attachments/:id', async (req, res) => {
|
||||
try {
|
||||
const found = await openAttachment({ customerId: req.params.customerId, id: req.params.id, viewer: 'admin' })
|
||||
return sendAttachment(req, res, found)
|
||||
} catch (err) {
|
||||
return res.status(err.status || 500).json({ error: err.status ? err.message : 'Datei nicht verfügbar' })
|
||||
}
|
||||
})
|
||||
|
||||
router.delete('/chats/:customerId/attachments/:id', async (req, res) => {
|
||||
try {
|
||||
await deletePendingAttachment({ customerId: req.params.customerId, id: req.params.id, uploadedBy: 'admin' })
|
||||
return res.status(204).end()
|
||||
} catch (err) {
|
||||
return res.status(err.status || 500).json({ error: err.status ? err.message : 'Anhang konnte nicht entfernt werden' })
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/chats/:customerId/read', async (req, res) => {
|
||||
try {
|
||||
const docs = await listDocuments(config.collections.portalMessages, [
|
||||
Query.equal('customerId', req.params.customerId),
|
||||
Query.equal('readByAdmin', false),
|
||||
Query.equal('sender', 'customer'),
|
||||
Query.limit(100),
|
||||
])
|
||||
for (const doc of docs) {
|
||||
await updateDocument(config.collections.portalMessages, doc.$id, { readByAdmin: true })
|
||||
}
|
||||
return res.json({ updated: docs.length })
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: err.message || 'Fehler beim Markieren' })
|
||||
}
|
||||
})
|
||||
// Chat (Tab "Chat"): eigener Router, auch im Team-Eingang des Ticketsystems eingehaengt
|
||||
router.use(chatAdminRouter)
|
||||
|
||||
// ------------------------------------------------------- Als Kunde ansehen --
|
||||
router.post('/view-as/:customerId', async (req, res) => {
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Router } from 'express'
|
||||
import { openAttachment, sendAttachment } from '../../services/chatAttachments.js'
|
||||
import { verifyFileLink } from '../../services/signedLinks.js'
|
||||
|
||||
/**
|
||||
* Anhaenge mit signiertem Link (?exp=&sig=, 15 min) ohne JWT ausliefern: <img>, <audio> und Downloads im
|
||||
* Ticketsystem koennen keinen Authorization-Header tragen. Ohne sig geht es weiter zur JWT-Pruefung.
|
||||
* Eingehaengt in routes/team/index.js VOR requireStaffJwt.
|
||||
*/
|
||||
const router = Router()
|
||||
|
||||
router.get('/chats/:customerId/attachments/:id', async (req, res, next) => {
|
||||
if (req.query.sig === undefined) return next()
|
||||
const { customerId, id } = req.params
|
||||
if (!verifyFileLink({ kind: 'chat', customerId, id, exp: req.query.exp, sig: String(req.query.sig) })) {
|
||||
return res.status(403).json({ error: 'Link abgelaufen. Bitte die Unterhaltung neu laden.' })
|
||||
}
|
||||
try {
|
||||
const found = await openAttachment({ customerId, id, viewer: 'admin' })
|
||||
return sendAttachment(req, res, found)
|
||||
} catch (err) {
|
||||
return res.status(err.status || 500).json({ error: err.status ? err.message : 'Datei nicht verfügbar' })
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -1,12 +1,17 @@
|
||||
import { Router } from 'express'
|
||||
import { requireStaffJwt } from '../../middleware/staffJwt.js'
|
||||
import { chatAdminRouter } from '../chatAdmin.js'
|
||||
import customersRoutes from './customers.js'
|
||||
import filesRoutes from './files.js'
|
||||
import previewRoutes from './preview.js'
|
||||
|
||||
// Team-Eingang fuer das Ticketsystem: alle Mitarbeiter, Appwrite-JWT (Spec 2026-10-01 Admin-Panel ins Ticketsystem)
|
||||
const router = Router()
|
||||
// Anhaenge mit signiertem Link brauchen kein JWT (siehe files.js)
|
||||
router.use(filesRoutes)
|
||||
router.use(requireStaffJwt)
|
||||
router.use('/customers', customersRoutes)
|
||||
router.use('/preview-link', previewRoutes)
|
||||
router.use(chatAdminRouter)
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,27 @@
|
||||
import { createHmac, timingSafeEqual } from 'node:crypto'
|
||||
import { config } from '../config.js'
|
||||
|
||||
/**
|
||||
* Signierte Datei-Links fuer das Ticketsystem (Spec 2026-10-01, Abschnitt 4 "Dateien ohne Header"): <img>, <audio>
|
||||
* und Downloads koennen keinen Authorization-Header tragen. Der Link gilt 15 Minuten und nur fuer genau diese
|
||||
* Datei dieses Kunden; signiert mit dem Sitzungs-Geheimnis des Portals.
|
||||
*/
|
||||
export const FILE_LINK_TTL_MS = 15 * 60 * 1000
|
||||
|
||||
const digest = (kind, customerId, id, exp) =>
|
||||
createHmac('sha256', config.sessionSecret).update(`${kind}:${customerId}:${id}:${exp}`).digest('hex')
|
||||
|
||||
export function signFileLink({ kind, customerId, id, now = Date.now() }) {
|
||||
const exp = now + FILE_LINK_TTL_MS
|
||||
return { exp, sig: digest(kind, customerId, id, exp) }
|
||||
}
|
||||
|
||||
/** exp kommt aus der Adresse (Text oder Zahl); falsch bei Ablauf, fremder Datei oder kaputter Signatur. */
|
||||
export function verifyFileLink({ kind, customerId, id, exp, sig, now = Date.now() }) {
|
||||
const expiry = typeof exp === 'number' ? exp : /^\d+$/.test(String(exp ?? '')) ? Number(exp) : NaN
|
||||
if (!Number.isFinite(expiry) || now >= expiry) return false
|
||||
if (typeof sig !== 'string' || !/^[0-9a-f]{64}$/.test(sig)) return false
|
||||
const expected = Buffer.from(digest(kind, customerId, id, expiry), 'hex')
|
||||
const given = Buffer.from(sig, 'hex')
|
||||
return given.length === expected.length && timingSafeEqual(given, expected)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { useTestEnv } from './support/testEnv.js'
|
||||
|
||||
// Signierte Datei-Links (15 min) fuer Anhaenge im Ticketsystem: <img>/<audio>/Download ohne Authorization-Header
|
||||
useTestEnv({ appwriteUrl: 'http://127.0.0.1:9/v1', stripeUrl: 'http://127.0.0.1:9' })
|
||||
const { FILE_LINK_TTL_MS, signFileLink, verifyFileLink } = await import('../server/services/signedLinks.js')
|
||||
|
||||
test('Signatur passt nur fuer dieselbe Datei, denselben Kunden und dieselbe Art', () => {
|
||||
assert.equal(FILE_LINK_TTL_MS, 900000)
|
||||
const { exp, sig } = signFileLink({ kind: 'chat', customerId: 'c1', id: 'a1', now: 1000 })
|
||||
assert.equal(exp, 1000 + 900000)
|
||||
assert.match(sig, /^[0-9a-f]{64}$/)
|
||||
assert.equal(verifyFileLink({ kind: 'chat', customerId: 'c1', id: 'a1', exp, sig, now: 2000 }), true)
|
||||
for (const other of [{ customerId: 'c2' }, { id: 'a2' }, { kind: 'takeover' }]) {
|
||||
assert.equal(verifyFileLink({ kind: 'chat', customerId: 'c1', id: 'a1', exp, sig, now: 2000, ...other }), false, JSON.stringify(other))
|
||||
}
|
||||
})
|
||||
|
||||
test('abgelaufen genau bei exp, manipuliertes exp, kaputte Signatur', () => {
|
||||
const { exp, sig } = signFileLink({ kind: 'chat', customerId: 'c1', id: 'a1', now: 0 })
|
||||
assert.equal(verifyFileLink({ kind: 'chat', customerId: 'c1', id: 'a1', exp, sig, now: exp }), false)
|
||||
assert.equal(verifyFileLink({ kind: 'chat', customerId: 'c1', id: 'a1', exp: exp + 1, sig, now: 0 }), false)
|
||||
assert.equal(verifyFileLink({ kind: 'chat', customerId: 'c1', id: 'a1', exp: String(exp), sig, now: 0 }), true, 'exp aus der Query ist ein String')
|
||||
for (const bad of ['', 'abc', 'x'.repeat(64), undefined]) {
|
||||
assert.equal(verifyFileLink({ kind: 'chat', customerId: 'c1', id: 'a1', exp, sig: bad, now: 0 }), false, String(bad))
|
||||
}
|
||||
for (const badExp of ['', 'abc', undefined, NaN]) {
|
||||
assert.equal(verifyFileLink({ kind: 'chat', customerId: 'c1', id: 'a1', exp: badExp, sig, now: 0 }), false, String(badExp))
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,104 @@
|
||||
import { test, after } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { mkdtempSync } from 'node:fs'
|
||||
import { startAppwriteMock } from './support/appwriteMock.js'
|
||||
import { startFakeStripe } from './support/fakeStripe.js'
|
||||
import { useTestEnv } from './support/testEnv.js'
|
||||
import { mailSink } from './support/mailSink.js'
|
||||
|
||||
// Chat ueber den Team-Eingang (/api/team, JWT) mit signierten Anhang-Links; Admin-Panel (/api/portal-admin) unveraendert
|
||||
process.env.CHAT_UPLOAD_DIR = mkdtempSync(path.join(os.tmpdir(), 'team-chat-'))
|
||||
const aw = await startAppwriteMock()
|
||||
const st = await startFakeStripe()
|
||||
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
|
||||
const { startTestApp, rawGet, post } = await import('./support/testApp.js')
|
||||
const { seedFixtures, adminCookie } = await import('./support/fixtures.js')
|
||||
const { setMailTransportForTests } = await import('../server/services/mailer.js')
|
||||
const { default: teamRoutes } = await import('../server/routes/team/index.js')
|
||||
const { default: portalAdminRoutes } = await import('../server/routes/portalAdmin.js')
|
||||
const app = await startTestApp({ '/api/team': teamRoutes, '/api/portal-admin': portalAdminRoutes })
|
||||
const sink = mailSink()
|
||||
setMailTransportForTests(sink)
|
||||
after(() => Promise.all([app.close(), aw.close(), st.close()]))
|
||||
|
||||
seedFixtures(aw)
|
||||
aw.seed('employees', { $id: 'e_staff2', userId: 'u_staff2', displayName: 'Andrej', email: 'andrej@webklar.com' })
|
||||
aw.seed('portalMessages', { $id: 'm1', customerId: 'c1', sender: 'customer', senderName: 'Kunde Eins', text: 'Frage 1', readByAdmin: false, readByCustomer: true, createdAt: '2026-10-01T08:00:00.000Z' })
|
||||
aw.seed('portalMessages', { $id: 'm2', customerId: 'c1', sender: 'customer', senderName: 'Kunde Eins', text: 'Frage 2', readByAdmin: false, readByCustomer: true, createdAt: '2026-10-01T08:01:00.000Z' })
|
||||
aw.seed('portalMessages', { $id: 'm3', customerId: 'c2', sender: 'admin', senderName: 'Kenso', text: 'Hallo c2', readByAdmin: true, readByCustomer: false, createdAt: '2026-10-01T09:00:00.000Z' })
|
||||
|
||||
const staff = { headers: { authorization: 'Bearer jwt-staff' } }
|
||||
const upload = (customerId, body, headers = {}) =>
|
||||
fetch(`${app.url}/api/team/chats/${customerId}/attachments`, {
|
||||
method: 'POST',
|
||||
headers: { authorization: 'Bearer jwt-staff', 'content-type': 'application/octet-stream', 'x-file-name': 'notiz.txt', ...headers },
|
||||
body,
|
||||
}).then(async (res) => ({ status: res.status, body: await res.json() }))
|
||||
const URL_RE = /^https:\/\/project\.webklar\.com\/api\/team\/chats\/c1\/attachments\/([^?]+)\?exp=(\d+)&sig=([0-9a-f]{64})$/
|
||||
|
||||
test('Liste und ungelesene Zahl ueber den Team-Eingang', async () => {
|
||||
const list = await rawGet('/api/team/chats', staff)
|
||||
assert.equal(list.status, 200)
|
||||
const c1 = list.body.chats.find((c) => c.customerId === 'c1')
|
||||
assert.equal(c1.unreadFromCustomer, 2)
|
||||
assert.equal(c1.customerName, 'Kunde Eins')
|
||||
assert.equal((await rawGet('/api/team/chats/unread-count', staff)).body.unread, 2)
|
||||
})
|
||||
|
||||
test('Senden als Mitarbeiter: Absendername aus dem JWT, K17-Mail an den Kunden', async () => {
|
||||
const res = await post('/api/team/chats/c1/messages', { text: 'Hallo' }, staff)
|
||||
assert.equal(res.status, 201)
|
||||
assert.equal(res.body.message.senderName, 'Andrej')
|
||||
assert.equal(res.body.message.sender, 'admin')
|
||||
assert.equal(res.body.notice.sent, true)
|
||||
assert.equal(sink.sent.at(-1).to[0].address, 'k@example.invalid')
|
||||
})
|
||||
|
||||
let fileUrl = null
|
||||
test('Anhang hochladen, mit Nachricht senden -> signierte url', async () => {
|
||||
const up = await upload('c1', Buffer.from('hallo welt'))
|
||||
assert.equal(up.status, 201)
|
||||
const sent = await post('/api/team/chats/c1/messages', { text: '', attachmentIds: [up.body.attachment.id] }, staff)
|
||||
assert.equal(sent.status, 201)
|
||||
fileUrl = sent.body.message.attachments[0].url
|
||||
assert.match(fileUrl, URL_RE)
|
||||
assert.equal(URL_RE.exec(fileUrl)[1], up.body.attachment.id)
|
||||
const listed = await rawGet('/api/team/chats/c1/messages', staff)
|
||||
assert.match(listed.body.messages.at(-1).attachments[0].url, URL_RE)
|
||||
})
|
||||
|
||||
test('signierter Link: ohne JWT 200, manipuliert/fremder Kunde/abgelaufen 403, ohne sig 401, JWT ohne sig 200', async (t) => {
|
||||
const u = new URL(fileUrl)
|
||||
const rel = u.pathname + u.search
|
||||
const ok = await fetch(app.url + rel)
|
||||
assert.equal(ok.status, 200)
|
||||
assert.equal(await ok.text(), 'hallo welt')
|
||||
const tampered = rel.replace(/sig=([0-9a-f])/, (m, c) => `sig=${c === 'a' ? 'b' : 'a'}`)
|
||||
assert.equal((await rawGet(tampered)).status, 403)
|
||||
assert.equal((await rawGet(rel.replace('/chats/c1/', '/chats/c2/'))).status, 403)
|
||||
assert.equal((await rawGet(u.pathname)).status, 401)
|
||||
assert.equal((await rawGet(u.pathname, staff)).status, 200)
|
||||
const now = Date.now()
|
||||
t.mock.method(Date, 'now', () => now + 16 * 60 * 1000)
|
||||
const expired = await rawGet(rel)
|
||||
assert.equal(expired.status, 403)
|
||||
assert.equal(expired.body.error, 'Link abgelaufen. Bitte die Unterhaltung neu laden.')
|
||||
})
|
||||
|
||||
test('Lesen markiert Kundennachrichten', async () => {
|
||||
const res = await post('/api/team/chats/c1/read', {}, staff)
|
||||
assert.equal(res.body.updated, 2)
|
||||
assert.equal((await rawGet('/api/team/chats/unread-count', staff)).body.unread, 0)
|
||||
})
|
||||
|
||||
test('Admin-Panel unveraendert: /api/portal-admin/chats mit Admin-Cookie 200, ohne 401', async () => {
|
||||
assert.equal((await rawGet('/api/portal-admin/chats', { cookie: adminCookie() })).status, 200)
|
||||
assert.equal((await rawGet('/api/portal-admin/chats')).status, 401)
|
||||
})
|
||||
|
||||
test('Team-Chat ohne JWT -> 401', async () => {
|
||||
assert.equal((await rawGet('/api/team/chats')).status, 401)
|
||||
assert.equal((await post('/api/team/chats/c1/messages', { text: 'x' })).status, 401)
|
||||
})
|
||||
@@ -0,0 +1,84 @@
|
||||
// Playwright-Checks Chat-Baustein: Option requestHeaders (Ticketsystem mit JWT) fuer Hochladen und Entfernen.
|
||||
// Nicht Teil von npm test. Aufruf: node test/ui/composer-headers.ui.mjs
|
||||
import http from 'node:http'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { chromium } from '/root/.npm/_npx/db89d7302a373f10/node_modules/playwright-core/index.mjs'
|
||||
|
||||
const pub = path.join(path.dirname(fileURLToPath(import.meta.url)), '../../public')
|
||||
// Testseite: ?mode=ok (async Header), none (ohne Option), fail (Header verworfen)
|
||||
const PAGE = `<!doctype html><html lang="de"><head><meta charset="utf-8"><link rel="stylesheet" href="/chat-composer.css"></head>
|
||||
<body style="background:#111;color:#fff"><div id="zone"><div id="root"></div></div><script src="/chat-composer.js"></script><script>
|
||||
const mode = new URLSearchParams(location.search).get('mode')
|
||||
const opts = { root: document.getElementById('root'), dropZone: document.getElementById('zone'), uploadUrl: '/upload',
|
||||
send: async () => ({ id: 'm1', text: '', attachments: [] }) }
|
||||
if (mode === 'ok') opts.requestHeaders = async () => { await new Promise((r) => setTimeout(r, 20)); return { Authorization: 'Bearer test' } }
|
||||
if (mode === 'fail') opts.requestHeaders = () => Promise.reject(new Error('JWT weg'))
|
||||
window.composer = ChatComposer.create(opts)
|
||||
</script></body></html>`
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const url = new URL(req.url, 'http://x')
|
||||
if (url.pathname === '/composer-test.html') {
|
||||
res.writeHead(200, { 'content-type': 'text/html' })
|
||||
return res.end(PAGE)
|
||||
}
|
||||
try {
|
||||
const file = path.join(pub, url.pathname)
|
||||
const data = await readFile(file)
|
||||
res.writeHead(200, { 'content-type': file.endsWith('.js') ? 'text/javascript' : 'text/css' })
|
||||
res.end(data)
|
||||
} catch {
|
||||
res.writeHead(404)
|
||||
res.end()
|
||||
}
|
||||
})
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
|
||||
const base = `http://127.0.0.1:${server.address().port}`
|
||||
|
||||
let failed = 0
|
||||
const check = (name, ok, detail = '') => {
|
||||
console.log(`${ok ? 'OK ' : 'FAIL'} ${name}${detail ? ` (${detail})` : ''}`)
|
||||
if (!ok) failed += 1
|
||||
}
|
||||
|
||||
const browser = await chromium.launch()
|
||||
try {
|
||||
const run = async (mode) => {
|
||||
const page = await browser.newPage()
|
||||
page.setDefaultTimeout(30000)
|
||||
const calls = []
|
||||
await page.route('**/upload**', (route) => {
|
||||
const req = route.request()
|
||||
calls.push({ method: req.method(), path: new URL(req.url()).pathname, auth: req.headers().authorization || null })
|
||||
if (req.method() === 'DELETE') return route.fulfill({ status: 204, body: '' })
|
||||
return route.fulfill({ status: 201, contentType: 'application/json', body: JSON.stringify({ attachment: { id: 'a1', name: 'notiz.txt', type: 'text/plain', size: 5, kind: 'file' } }) })
|
||||
})
|
||||
await page.goto(`${base}/composer-test.html?mode=${mode}`)
|
||||
await page.locator('#root input[type=file]:not([accept])').setInputFiles({ name: 'notiz.txt', mimeType: 'text/plain', buffer: Buffer.from('hallo') })
|
||||
await page.waitForTimeout(800)
|
||||
return { page, calls }
|
||||
}
|
||||
|
||||
const ok = await run('ok')
|
||||
const up = ok.calls.find((c) => c.method === 'POST')
|
||||
check('mit_header_upload', up?.auth === 'Bearer test', JSON.stringify(up))
|
||||
await ok.page.click('.chat-tray-remove')
|
||||
await ok.page.waitForTimeout(500)
|
||||
const del = ok.calls.find((c) => c.method === 'DELETE')
|
||||
check('mit_header_delete', del?.path === '/upload/a1' && del?.auth === 'Bearer test', JSON.stringify(del))
|
||||
|
||||
const none = await run('none')
|
||||
const up2 = none.calls.find((c) => c.method === 'POST')
|
||||
check('ohne_option_kein_header', Boolean(up2) && up2.auth === null, JSON.stringify(up2))
|
||||
|
||||
const fail = await run('fail')
|
||||
const text = await fail.page.textContent('#root')
|
||||
check('header_fehler', fail.calls.length === 0 && text.includes('JWT weg'), `${fail.calls.length} Aufrufe, Text: ${text.slice(0, 120)}`)
|
||||
} finally {
|
||||
await browser.close()
|
||||
server.close()
|
||||
}
|
||||
console.log(failed ? `${failed} Check(s) fehlgeschlagen` : 'alle Checks ok')
|
||||
process.exit(failed ? 1 : 0)
|
||||
Reference in new issue
Block a user