Plan: Admin-Umzug Etappe 2 (Chat)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-01 23:10:48 +00:00
1 parent 6718018211
commit acf6ff9ee1
1 file changed
+271
@@ -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.