Files
Webklar-Kundenbereich/docs/superpowers/plans/2026-10-01-admin-umzug-etappe-2.md
T
2026-10-01 23:10:48 +00:00

272 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.