Compare commits

..
4 Commits
10 changed files with 786 additions and 188 deletions

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
View File
@@ -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)
+208
View File
@@ -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' })
}
})
+3 -186
View File
@@ -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) => {
+26
View File
@@ -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
+5
View File
@@ -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
+27
View File
@@ -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)
}
+31
View File
@@ -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))
}
})
+104
View File
@@ -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)
})
+84
View File
@@ -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)