Plan: Admin-Umzug Etappe 1 (Fundament + Kundenansicht)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5fb39f1a1b
commit
be65baff01
1 file changed
+649
@@ -0,0 +1,649 @@
|
||||
# Admin-Panel ins Ticketsystem – Etappe 1 (Fundament + Kundenansicht) 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 öffnen aus dem WOMS mit einem Klick die Kundenansicht im Portal (nur News schreibbar, 2 h, Verwechslungsschutz, protokolliert); das Portal bekommt den JWT-Eingang `/api/team` für alle Mitarbeiter; Previews laufen über Einmal-Links.
|
||||
|
||||
**Architecture:** Portal-Server (Express, ESM, Node 20) bekommt `requireStaffJwt` + Router `/api/team`, einen zentralen Kundenansicht-Wächter vor allen Kunden-Routen und eine Protokoll-Collection `customerViewLog`. Das Dashboard markiert jede API-Anfrage mit der Ansichts-Kennung. WOMS (React 18, react-router 6) bekommt einen `teamApi`-Client, den Knopf „Kundenansicht öffnen“, Tab in der URL, Protokoll-Tab und Preview-Knöpfe per Einmal-Link. Das Admin-Panel bleibt unverändert in Betrieb.
|
||||
|
||||
**Tech Stack:** Portal: Express 4, cookie-parser (signierte Cookies), jose, node:test mit `test/support/*` (Appwrite-Nachbau, Stripe-Attrappe). WOMS: Vite 5, React 18, appwrite 13, node:test + esbuild-Loader `tests/unit/load.mjs`.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-10-01-admin-panel-ins-ticketsystem-design.md` (Abschnitte 1, 3, 4 und Etappe 1 in Abschnitt 5)
|
||||
|
||||
**Abweichung von der Spec (bewusst):** Die Route `GET /api/team/customers/:id/abo` wandert nach Etappe 3, wo ihr einziger Nutzer (Tab „Abo & Angebote“) entsteht. In Etappe 1 hätte sie keinen Aufrufer.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Arbeitsorte: Portal `/home/webklar/apps/kundenbereich.webklar.com/dev-admin-umzug` (Branch `feature/admin-umzug`, `node_modules` ist ein Symlink auf `../build/node_modules`). WOMS `/home/webklar/apps/ticket.webklar.com/dev-admin-umzug` (Branch `feature/admin-umzug` von `origin/test`, wird in Task 9 angelegt).
|
||||
- **Nie `git add -A`** (Portal: `node_modules`-Symlink ist untracked; WOMS: `node_modules` ist getrackt). Immer einzelne Dateien adden.
|
||||
- **Nichts pushen.** WOMS deployt jeden gepushten Branch. Livegang macht der Nutzer.
|
||||
- `grep` in dieser Shell ist ein Wrapper, der Dateien still überspringt: `command grep -a` benutzen.
|
||||
- UI-Texte deutsch mit Umlauten; Code-Kommentare im Portal wie im Bestand (ASCII, „ae/oe/ue“).
|
||||
- Kundenansicht-Laufzeit: **2 Stunden** (`7200000` ms). Einmal-Link: **60 s**, einmal einlösbar. Protokoll-Aufbewahrung: **12 Monate** (365 Tage).
|
||||
- Header-Name: `X-Kundenansicht`. Fehlercodes: `view_expired` (401), `view_replaced` (409), `view_blocked` (403), `no_portal_access` (409).
|
||||
- Protokoll-Aktionen (genau diese Strings): `geoeffnet`, `beendet`, `abgelaufen`, `news_angelegt`, `news_bild`, `news_geaendert`, `news_geloescht`.
|
||||
- Banner-Text: „Kundenansicht: *Kunde* · geöffnet von *Mitarbeiter*“ + Knopf „Beenden“.
|
||||
- Rückweg nach „Beenden“: `${config.womsBaseUrl}/customers/<customerId>` (`WOMS_PUBLIC_URL`, Standard `https://ticket.webklar.com`).
|
||||
- Portal-Tests: `npm test` im Portal-Worktree (Node 24 auf dem Host reicht für die Tests). WOMS-Tests: `npm test` im WOMS-Worktree.
|
||||
|
||||
## Review Focus
|
||||
|
||||
1. **Zwei Tabs, zwei Kunden:** Tab A (Kunde A) fragt weiter ab, nachdem im selben Browser Kunde B geöffnet wurde. Erwartung: Tab A bekommt 409 und zeigt nur den Ersetzt-Hinweis, nie Daten von B (Task 4 Server, Task 7 Client).
|
||||
2. **Kunden-JWT am Team-Eingang:** Kunden haben Appwrite-Konten im selben Projekt und können sich ein JWT holen. Erwartung: 403, auch wenn die (unbestätigte) Konto-Mail einer Mitarbeiter-Mail gleicht (Task 1).
|
||||
3. **Grenze der 2 Stunden:** Anfrage genau bei `viewExpiresAt`. Erwartung: abgelaufen (Task 4).
|
||||
4. **Alte Kundenansicht aus admin.html** (Sitzung ohne `viewId`) während Etappe 1–3. Erwartung: Lesen geht, jede Schreibaktion 403, „Zurück zur Admin-Übersicht“ funktioniert weiter (Task 4, Task 7).
|
||||
5. **Pop-up-Sperre:** Der neue Tab wird erst nach einer Netzwerkanfrage befüllt. Erwartung: `window.open` läuft synchron im Klick, Fehler schließen den leeren Tab wieder (Task 9).
|
||||
|
||||
---
|
||||
|
||||
## Portal
|
||||
|
||||
### Task 1: JWT-Eingang `/api/team` für alle Mitarbeiter + CORS-Header
|
||||
|
||||
**Files:**
|
||||
- Create: `server/middleware/staffJwt.js`
|
||||
- Create: `server/middleware/cors.js`
|
||||
- Create: `server/routes/team/index.js`
|
||||
- Modify: `server/index.js:55-69` (CORS-Block durch `corsMiddleware` ersetzen; `app.use('/api/team', teamRoutes)` nach `/api/portal-admin` einhängen)
|
||||
- Modify: `server/routes/admin/switchLink.js` (lokales `appwriteAccountFromJwt` durch Import aus `staffJwt.js` ersetzen)
|
||||
- Modify: `test/support/appwriteMock.js:167-178` (zwei neue Test-JWTs)
|
||||
- Test: `test/teamAuth.test.js`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `appwriteAccountFromJwt(req) -> Promise<object|null>` (Appwrite `/account` mit `X-Appwrite-JWT`; null bei fehlendem Header oder nicht-ok).
|
||||
- Produces: `resolveTeamStaff(account) -> Promise<{ isAdmin, name, email } | null>`.
|
||||
- Produces: `requireStaffJwt(req, res, next)`; setzt `req.session = { role: 'admin', isAdmin, appwriteUserId, name, email }` (gleiche Form wie die Admin-Sitzung, damit bestehende Handler unverändert laufen) und `req.staff` = dasselbe Objekt.
|
||||
- Produces: `corsMiddleware(allowedOrigins: string[])`.
|
||||
- Produces: `server/routes/team/index.js` default export `Router` mit `router.use(requireStaffJwt)`; spätere Tasks hängen Unter-Router ein.
|
||||
|
||||
- [ ] **Step 1: Test-JWTs im Appwrite-Nachbau ergänzen**
|
||||
|
||||
In `/v1/account` zwei Fälle vor dem Session-Fall:
|
||||
`'jwt-mail-ungeprueft'` → `[200, { $id: 'u_fremd', email: 'andrej@webklar.com', emailVerification: false, labels: [] }]`,
|
||||
`'jwt-mail-geprueft'` → `[200, { $id: 'u_neu', email: 'nico@webklar.com', emailVerification: true, name: 'Nico', labels: [] }]`.
|
||||
|
||||
- [ ] **Step 2: Failing tests schreiben** (`test/teamAuth.test.js`, Aufbau wie `test/staffSwitch.test.js`; Probe-Router `GET /probe` hinter `requireStaffJwt`, der `req.session` als JSON zurückgibt)
|
||||
|
||||
```js
|
||||
aw.seed('employees', { $id: 'e_staff2', userId: 'u_staff2', displayName: 'Andrej', email: 'andrej@webklar.com' })
|
||||
aw.seed('employees', { $id: 'e_nico', userId: '', displayName: 'Nico', email: 'nico@webklar.com' })
|
||||
|
||||
test('ohne, ungueltiges und halbes JWT -> 401', async () => {
|
||||
for (const jwt of [null, 'jwt-falsch', 'jwt-halb']) assert.equal((await probe(jwt)).status, 401, String(jwt))
|
||||
})
|
||||
test('Kunde (kein Mitarbeiter, kein Label) -> 403', async () => {
|
||||
assert.equal((await probe('jwt-kunde')).status, 403)
|
||||
})
|
||||
test('Mail gleicht Mitarbeiter, ist aber unbestaetigt -> 403', async () => {
|
||||
assert.equal((await probe('jwt-mail-ungeprueft')).status, 403)
|
||||
})
|
||||
test('bestaetigte Mail eines Mitarbeiters ohne userId -> erlaubt', async () => {
|
||||
const res = await probe('jwt-mail-geprueft')
|
||||
assert.equal(res.status, 200)
|
||||
assert.equal(res.body.name, 'Nico')
|
||||
})
|
||||
test('Mitarbeiter ohne Admin-Label -> erlaubt, Sitzung in Admin-Form', async () => {
|
||||
const res = await probe('jwt-staff')
|
||||
assert.deepEqual(res.body, { role: 'admin', isAdmin: false, appwriteUserId: 'u_staff2', name: 'Andrej', email: 'andrej@webklar.com' })
|
||||
})
|
||||
test('Label admin ohne employees-Eintrag -> erlaubt, isAdmin true', async () => {
|
||||
const res = await probe('jwt-admin')
|
||||
assert.equal(res.status, 200)
|
||||
assert.equal(res.body.isAdmin, true)
|
||||
})
|
||||
test('CORS: erlaubte Herkunft bekommt die Upload-Header, fremde nichts', async () => {
|
||||
// corsMiddleware(['https://ticket.webklar.com']) vor dem Probe-Router
|
||||
const ok = await rawGet('/probe', { headers: { origin: 'https://ticket.webklar.com' } })
|
||||
assert.equal(ok.headers.get('access-control-allow-headers'),
|
||||
'Authorization, Content-Type, X-File-Name, X-Attachment-Kind, X-Duration-Ms, X-Image-Width, X-Image-Height')
|
||||
const bad = await rawGet('/probe', { headers: { origin: 'https://evil.example' } })
|
||||
assert.equal(bad.headers.get('access-control-allow-origin'), null)
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Tests laufen lassen, Fehlschlag prüfen**
|
||||
|
||||
Run: `node --test test/teamAuth.test.js`
|
||||
Expected: FAIL (Modul `server/middleware/staffJwt.js` fehlt)
|
||||
|
||||
- [ ] **Step 4: Implementieren**
|
||||
|
||||
`resolveTeamStaff`: Mitarbeiter, wenn (a) `getEmployeeByUserId(account.$id)` trifft, oder (b) `account.labels` enthält `'admin'`, oder (c) `account.emailVerification === true` und `getEmployeeByEmail(account.email)` trifft. Name = `employee.displayName || account.name || 'Mitarbeiter'`, E-Mail = `account.email || employee.email`. Fehlender Header oder nicht-ok `/account` → 401 `{ error: 'Bitte im Ticketsystem neu anmelden.' }`; kein Mitarbeiter → 403 `{ error: 'Kein Mitarbeiterkonto.' }`; Ausnahme → 500. `corsMiddleware` übernimmt das bisherige Verhalten aus `index.js` (Methoden `GET, POST, PATCH, DELETE, OPTIONS`, OPTIONS → 204) mit der neuen Header-Liste aus dem Test. `switchLink.js` nutzt `appwriteAccountFromJwt` aus `staffJwt.js`; sein Verhalten bleibt (bestehende `test/staffSwitch.test.js` müssen grün bleiben).
|
||||
|
||||
- [ ] **Step 5: Tests laufen lassen**
|
||||
|
||||
Run: `node --test test/teamAuth.test.js test/staffSwitch.test.js`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add server/middleware/staffJwt.js server/middleware/cors.js server/routes/team/index.js server/index.js server/routes/admin/switchLink.js test/support/appwriteMock.js test/teamAuth.test.js
|
||||
git commit -m "Team-Eingang: /api/team mit Appwrite-JWT fuer alle Mitarbeiter, CORS-Header fuer Uploads"
|
||||
```
|
||||
|
||||
### Task 2: Protokoll-Collection `customerViewLog` und Dienst
|
||||
|
||||
**Files:**
|
||||
- Modify: `scripts/setup-abo-collections.mjs` (neue `ensureCollection` vor `console.log('Fertig.')`; Kopfkommentar ergänzen)
|
||||
- Modify: `server/config.js:141-161` (`customerViewLog: process.env.APPWRITE_COLLECTION_CUSTOMER_VIEW_LOG || 'customerViewLog'`)
|
||||
- Create: `server/services/customerViewLog.js`
|
||||
- Modify: `server/index.js` (täglicher Aufräumlauf neben den anderen Timern)
|
||||
- Test: `test/customerViewLog.test.js`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `VIEW_ACTIONS` (Array der 7 Aktions-Strings aus den Global Constraints).
|
||||
- Produces: `logViewAction({ customerId, staffUserId, staffName, action, detail = '' }) -> Promise<void>`; wirft nie (Fehler nur `console.warn('[kundenansicht] Protokoll ...')`); kürzt `detail` auf 300 Zeichen; setzt `createdAt` = jetzt (ISO).
|
||||
- Produces: `listViewLog(customerId, { limit = 200 } = {}) -> Promise<Array<{ id, at, staffName, action, detail }>>`, neueste zuerst.
|
||||
- Produces: `purgeViewLog({ now = new Date() } = {}) -> Promise<{ deleted: number }>`; löscht Einträge mit `createdAt` älter als 365 Tage, seitenweise (je 100) bis keine mehr übrig sind.
|
||||
|
||||
- [ ] **Step 1: Collection im Setup-Skript anlegen**
|
||||
|
||||
```js
|
||||
await ensureCollection(
|
||||
'customerViewLog',
|
||||
'Kundenansicht-Protokoll',
|
||||
[
|
||||
{ type: 'string', key: 'customerId', size: 64 },
|
||||
{ type: 'string', key: 'staffUserId', size: 64 },
|
||||
{ type: 'string', key: 'staffName', size: 128 },
|
||||
{ type: 'string', key: 'action', size: 32 },
|
||||
{ type: 'string', key: 'detail', size: 300 },
|
||||
{ type: 'datetime', key: 'createdAt' },
|
||||
],
|
||||
[
|
||||
{ key: 'idx_customer_created', type: 'key', attributes: ['customerId', 'createdAt'] },
|
||||
{ key: 'idx_created', type: 'key', attributes: ['createdAt'] },
|
||||
]
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Failing tests schreiben** (Nachbau mit `schema: { customerViewLog: schemaKeys('customerViewLog') }`)
|
||||
|
||||
```js
|
||||
test('logViewAction schreibt alle Felder und kuerzt detail auf 300', async () => {
|
||||
await logViewAction({ customerId: 'c1', staffUserId: 'u_staff2', staffName: 'Andrej', action: 'news_angelegt', detail: 'x'.repeat(400) })
|
||||
const [doc] = [...aw.docs('customerViewLog').values()]
|
||||
assert.equal(doc.detail.length, 300)
|
||||
assert.equal(doc.action, 'news_angelegt')
|
||||
assert.ok(Date.parse(doc.createdAt))
|
||||
})
|
||||
test('logViewAction wirft nicht, wenn Appwrite scheitert', async () => { /* Nachbau-Schema ohne Feld detail -> 400; Aufruf resolved */ })
|
||||
test('listViewLog: nur dieser Kunde, neueste zuerst, Form {id, at, staffName, action, detail}', async () => { /* 3 Eintraege c1, 1 Eintrag c2 */ })
|
||||
test('purgeViewLog loescht nur Eintraege aelter als 365 Tage', async () => {
|
||||
// seed: createdAt now-366d, now-364d, now-1d -> deleted 1, zwei bleiben
|
||||
})
|
||||
test('purgeViewLog arbeitet seitenweise (250 alte Eintraege -> deleted 250)', async () => {})
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Fehlschlag prüfen** — Run: `node --test test/customerViewLog.test.js` → FAIL (Modul fehlt)
|
||||
|
||||
- [ ] **Step 4: Implementieren** (`createDocument`, `listDocuments`, `deleteDocument`, `Query` aus `services/appwriteAdmin.js`; Liste mit `Query.equal('customerId')`, `Query.orderDesc('createdAt')`, `Query.limit(limit)`; Aufräumen mit `Query.lessThan('createdAt', grenze)`, `Query.limit(100)` in einer Schleife). Timer in `index.js` wie `purge` der Gutschriften: erster Lauf nach 150 s, dann alle 24 h, Log `[kundenansicht] n Protokolleinträge gelöscht`.
|
||||
|
||||
- [ ] **Step 5: Tests** — Run: `node --test test/customerViewLog.test.js` → PASS
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add scripts/setup-abo-collections.mjs server/config.js server/services/customerViewLog.js server/index.js test/customerViewLog.test.js
|
||||
git commit -m "Kundenansicht: Protokoll-Collection customerViewLog mit Schreiben, Lesen und Loeschen nach 12 Monaten"
|
||||
```
|
||||
|
||||
### Task 3: Kundenansicht öffnen (Einmal-Link → Sitzung 2 h)
|
||||
|
||||
**Files:**
|
||||
- Create: `server/services/portalAccessStatus.js`
|
||||
- Create: `server/services/kundenansicht.js`
|
||||
- Create: `server/routes/team/customers.js` (in `routes/team/index.js` unter `/customers` einhängen)
|
||||
- Modify: `server/middleware/session.js:14-22` (`setPortalSession(res, data, { maxAgeMs = SESSION_MAX_AGE_MS } = {})`)
|
||||
- Modify: `server/services/previewSession.js` (`createPreviewSessionToken(payload, { maxAgeSec } = {})`, `previewSessionCookieOptions({ maxAgeSec } = {})`; ohne Option unverändert `config.preview.sessionMaxAge`)
|
||||
- Modify: `server/services/staffPreviewTokens.js:24` (Kommentar: Zweck `kundenansicht`)
|
||||
- Modify: `server/routes/auth.js` (neue Route `GET /kundenansicht`)
|
||||
- Modify: `test/support/fixtures.js` (neuer Helfer `kundenansichtCookie`)
|
||||
- Test: `test/kundenansicht.test.js`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `requireStaffJwt` / `req.session` (Task 1), `logViewAction` (Task 2).
|
||||
- Produces: `getPortalAccessStatus(customerId) -> Promise<{ customer: object|null, hasAccess: boolean, lastLoginAt: string|null }>`; `hasAccess` = Kunde existiert && `customer.portalAccessEnabled` && `customerPortalAccess`-Dokument mit `enabled`.
|
||||
- Produces in `kundenansicht.js`: `KUNDENANSICHT_TTL_MS = 7200000`; `startKundenansicht(res, { staff, customer, now = Date.now() }) -> Promise<session>` (setzt Portal-Cookie mit `maxAgeMs` = TTL und Preview-Cookie für genau diesen Kunden: Payload `{ userId: staff.appwriteUserId, customerId, email: customer.email, name: customer.name, role: 'customer', labels: [], authSource: 'kundenansicht' }`, `maxAgeSec` = 7200); `isKundenansicht(session) -> boolean` (`viewAs && viewId`); `isExpired(session, now = Date.now()) -> boolean` (`now >= viewExpiresAt`); `endKundenansicht(res)` (beide Cookies löschen); `logOnce(session, action) -> Promise<void>` (protokolliert `abgelaufen`/`beendet` je `viewId` höchstens einmal, merkt sich IDs 3 h im Speicher).
|
||||
- Produces: Sitzungsform `{ role: 'admin', viewAs: true, viewId: <32 hex>, viewExpiresAt: <ms>, isAdmin, appwriteUserId, adminName, adminEmail, customerId, name, email }`.
|
||||
- Produces: Test-Helfer `kundenansichtCookie({ customerId = 'c1', viewId = 'a'.repeat(32), viewExpiresAt = Date.now() + 3600000 } = {})` (Mitarbeiter `u_staff2`/Andrej).
|
||||
|
||||
- [ ] **Step 1: Failing tests schreiben** (Router `/api/team` + `/api/auth`; `aw.seed('customerPortalAccess', { $id: 'pa1', customerId: 'c1', enabled: true, lastLoginAt: '2026-09-30T10:00:00.000Z' })`; c2 ohne Zugangs-Dokument)
|
||||
|
||||
```js
|
||||
test('Link fuer Mitarbeiter: Einmal-Link auf /api/auth/kundenansicht', async () => {
|
||||
const res = await post('/api/team/customers/c1/kundenansicht', {}, bearer('jwt-staff'))
|
||||
assert.equal(res.status, 200)
|
||||
assert.match(res.body.openUrl, /^https:\/\/project\.webklar\.com\/api\/auth\/kundenansicht\?token=[0-9a-f]{64}$/)
|
||||
})
|
||||
test('unbekannter Kunde 404, Kunde ohne Portal-Zugang 409 no_portal_access', async () => {
|
||||
assert.equal((await post('/api/team/customers/gibtsnicht/kundenansicht', {}, bearer('jwt-staff'))).status, 404)
|
||||
const res = await post('/api/team/customers/c2/kundenansicht', {}, bearer('jwt-staff'))
|
||||
assert.equal(res.status, 409)
|
||||
assert.equal(res.body.code, 'no_portal_access')
|
||||
})
|
||||
test('Einloesen: Sitzung 2 h, Preview-Cookie nur fuer den Kunden, Protokoll geoeffnet, Weiter zum Dashboard', async () => {
|
||||
const res = await rawGet(`/api/auth/kundenansicht?token=${await tokenFor('c1')}`)
|
||||
assert.equal(res.status, 302)
|
||||
assert.equal(res.headers.get('location'), '/dashboard.html')
|
||||
const s = portalSession(res)
|
||||
assert.equal(s.viewAs, true)
|
||||
assert.match(s.viewId, /^[0-9a-f]{32}$/)
|
||||
assert.ok(Math.abs(s.viewExpiresAt - (Date.now() + 7200000)) < 5000)
|
||||
assert.deepEqual([s.customerId, s.adminName, s.appwriteUserId], ['c1', 'Andrej', 'u_staff2'])
|
||||
assert.match(cookieHeader(res, config.cookieName), /Max-Age=7200/)
|
||||
const preview = await verifyPreviewSessionToken(cookieValue(res, 'preview_session'))
|
||||
assert.deepEqual([preview.role, preview.customerId], ['customer', 'c1'])
|
||||
assert.ok(preview.exp - preview.iat <= 7200)
|
||||
const log = [...aw.docs('customerViewLog').values()].at(-1)
|
||||
assert.deepEqual([log.customerId, log.action, log.staffName], ['c1', 'geoeffnet', 'Andrej'])
|
||||
})
|
||||
test('Token zweimal oder nach 61 s -> 410 mit Hinweis, Preview-Token gilt hier nicht', async () => {
|
||||
// zweites Einloesen 410, Text enthaelt 'im Ticketsystem erneut öffnen'
|
||||
// createStaffPreviewToken({...}) ohne purpose -> 410
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Fehlschlag prüfen** — Run: `node --test test/kundenansicht.test.js` → FAIL
|
||||
|
||||
- [ ] **Step 3: Implementieren**
|
||||
|
||||
`POST /api/team/customers/:id/kundenansicht`: `getPortalAccessStatus` → 404 `{ error: 'Kunde nicht gefunden' }` / 409 `{ code: 'no_portal_access', error: 'Kein Portal-Zugang' }`; sonst `createStaffPreviewToken({ purpose: 'kundenansicht', customerId, staff: req.staff })` und `openUrl` mit `config.publicBaseUrl`. `GET /api/auth/kundenansicht`: `consumeStaffPreviewToken(token, 'kundenansicht')`; null → 410 `text/plain; charset=utf-8` „Der Link ist abgelaufen. Bitte die Kundenansicht im Ticketsystem erneut öffnen.“; Kunde inzwischen ohne Zugang → ebenfalls 410 mit „Kein Portal-Zugang“. Sonst `startKundenansicht`, `logViewAction({ action: 'geoeffnet' })`, Redirect `/dashboard.html`. `viewId` aus `randomBytes(16).toString('hex')`.
|
||||
|
||||
- [ ] **Step 4: Tests** — Run: `node --test test/kundenansicht.test.js test/staffSwitch.test.js test/previewAdmin.test.js` → PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add server/services/portalAccessStatus.js server/services/kundenansicht.js server/routes/team/customers.js server/routes/team/index.js server/middleware/session.js server/services/previewSession.js server/services/staffPreviewTokens.js server/routes/auth.js test/support/fixtures.js test/kundenansicht.test.js
|
||||
git commit -m "Kundenansicht: Einmal-Link aus dem Ticketsystem, Sitzung und Preview-Zugang fuer 2 Stunden, Protokoll"
|
||||
```
|
||||
|
||||
### Task 4: Kundenansicht-Wächter (Ablauf, Verwechslung, Schreibsperre)
|
||||
|
||||
**Files:**
|
||||
- Create: `server/middleware/kundenansichtGuard.js`
|
||||
- Modify: `server/index.js` (`app.use(kundenansichtGuard)` direkt nach `sessionMiddleware()`, also vor dem 15-MB-Parser der Gutschriften)
|
||||
- Modify: `server/routes/news.js` (`rejectViewAs` und seine vier Verwendungen entfernen; der Wächter übernimmt)
|
||||
- Test: `test/kundenansichtGuard.test.js`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `isKundenansicht`, `isExpired`, `endKundenansicht`, `logOnce` (Task 3); Fixtures `kundenansichtCookie`, `viewAsCookie`.
|
||||
- Produces: `kundenansichtGuard(req, res, next)`; exportiert zusätzlich `KUNDENANSICHT_ALLOWED` (Array `[method, RegExp]`) für Tests.
|
||||
|
||||
Regeln in dieser Reihenfolge (nur wenn `parsePortalSession(req)?.viewAs`; Pfad = `req.originalUrl` ohne Query; durchgelassen werden Pfade unter `/api/team`, `/api/admin`, `/api/portal-admin`, alles außerhalb `/api/` sowie `GET /api/auth/kundenansicht` und `GET /api/auth/me` – beide behandeln Ablauf und Sitzungswechsel selbst; sonst könnte ein abgelaufenes altes Cookie das Öffnen einer neuen Ansicht blockieren):
|
||||
1. Neue Ansicht (`viewId`) abgelaufen → Cookies löschen, `logOnce(session, 'abgelaufen')`, 401 `{ code: 'view_expired', error: 'Die Kundenansicht ist abgelaufen. Bitte im Ticketsystem erneut öffnen.' }`.
|
||||
2. Neue Ansicht, Header `X-Kundenansicht` vorhanden und ≠ `viewId` → 409 `{ code: 'view_replaced', kunde: session.name, error: 'Diese Ansicht wurde durch eine andere Kundenansicht ersetzt.' }`.
|
||||
3. Methode `GET`/`HEAD`/`OPTIONS` → weiter.
|
||||
4. Neue Ansicht und Header fehlt → 409 wie in 2.
|
||||
5. Pfad steht in `KUNDENANSICHT_ALLOWED` **und** es ist eine neue Ansicht → weiter. Erlaubt: `POST /api/news`, `POST /api/news/image`, `PATCH /api/news/:id`, `DELETE /api/news/:id`, `POST /api/funktionen/analyse/heatmap-link`, `POST /api/auth/kundenansicht/ende`, `POST /api/auth/logout`, `POST /api/auth/login`. Für alte Ansichten (ohne `viewId`) nur `logout` und `login`.
|
||||
6. Sonst 403 `{ code: 'view_blocked', error: 'In der Kundenansicht nicht möglich.' }`.
|
||||
|
||||
- [ ] **Step 1: Failing tests schreiben** (App wie `index.js`: `kundenansichtGuard` + alle Kunden-Router mit denselben Mount-Pfaden: `/api/auth`, `/api/projects`, `/api/features`, `/api/news`, `/api` (customerData), `/api/chat`, `/api/abo/takeover-credits`, `/api/abo`, `/api/funktionen`)
|
||||
|
||||
```js
|
||||
const VIEW = 'a'.repeat(32)
|
||||
const kv = (extra = {}) => ({ cookie: kundenansichtCookie(extra), headers: { 'x-kundenansicht': extra.viewId || VIEW } })
|
||||
|
||||
test('jede Schreib-Route ausserhalb der Erlaubnisliste -> 403 view_blocked', async () => {
|
||||
const routes = writeRoutes([['/api/auth', authRoutes], ['/api/news', newsRoutes], /* ... alle oben */])
|
||||
assert.ok(routes.length >= 25, `nur ${routes.length} Schreib-Routen gefunden`)
|
||||
for (const { method, path } of routes) {
|
||||
if (KUNDENANSICHT_ALLOWED.some(([m, re]) => m === method && re.test(path))) continue
|
||||
const res = await call(method, path, kv())
|
||||
assert.equal(res.status, 403, `${method} ${path}`)
|
||||
assert.equal(res.body.code, 'view_blocked', `${method} ${path}`)
|
||||
}
|
||||
})
|
||||
// writeRoutes liest router.stack (layer.route.path, layer.route.methods), ersetzt :param durch 'x'
|
||||
|
||||
test('POST /api/chat/read ist gesperrt (Lesestatus bleibt unberuehrt)', async () => {
|
||||
assert.equal((await call('POST', '/api/chat/read', kv())).status, 403)
|
||||
})
|
||||
test('News anlegen in der Kundenansicht erlaubt', async () => {
|
||||
const res = await call('POST', '/api/news', { ...kv(), body: note })
|
||||
assert.equal(res.status, 201)
|
||||
})
|
||||
test('fremde Kennung -> 409 view_replaced mit Kundennamen, auch beim Lesen', async () => {
|
||||
const res = await call('GET', '/api/projects', { cookie: kundenansichtCookie(), headers: { 'x-kundenansicht': 'b'.repeat(32) } })
|
||||
assert.equal(res.status, 409)
|
||||
assert.deepEqual([res.body.code, res.body.kunde], ['view_replaced', 'Kunde Eins'])
|
||||
})
|
||||
test('Schreiben ohne Kennung -> 409, Lesen ohne Kennung -> erlaubt', async () => {})
|
||||
test('genau bei viewExpiresAt abgelaufen: 401, Cookies geloescht, Protokoll abgelaufen genau einmal', async () => {
|
||||
const at = Date.now()
|
||||
// t.mock.method(Date, 'now', () => at); Cookie mit viewExpiresAt: at; zwei Anfragen
|
||||
// -> beide 401 view_expired, Set-Cookie leert webklar_portal_session und preview_session, 1 Protokolleintrag
|
||||
})
|
||||
test('alte Ansicht aus admin.html (viewAsCookie, ohne viewId): Lesen ok, News anlegen 403, Logout ok', async () => {})
|
||||
test('normale Kunden-Sitzung wird nicht beruehrt (News anlegen 201 ohne Header)', async () => {})
|
||||
test('abgelaufenes Cookie blockiert weder /api/auth/me noch das Einloesen eines neuen Links', async () => {
|
||||
// Cookie mit viewExpiresAt in der Vergangenheit: GET /api/auth/me -> 200 (der Waechter greift nicht ein;
|
||||
// den Inhalt "reason view_expired" prueft Task 6); GET /api/auth/kundenansicht?token=<neuer Token> -> 302 /dashboard.html
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Fehlschlag prüfen** — Run: `node --test test/kundenansichtGuard.test.js` → FAIL
|
||||
|
||||
- [ ] **Step 3: Implementieren** wie in den Regeln oben.
|
||||
|
||||
- [ ] **Step 4: Gesamte Suite** — Run: `npm test` → PASS (insbesondere `newsRoutes.test.js`: der dortige View-as-Test erwartet weiterhin 403, jetzt vom Wächter; ist der Wächter im Test-App-Aufbau von `newsRoutes.test.js` nicht eingehängt, den Wächter dort ergänzen, nicht die Erwartung ändern).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add server/middleware/kundenansichtGuard.js server/index.js server/routes/news.js test/kundenansichtGuard.test.js test/newsRoutes.test.js
|
||||
git commit -m "Kundenansicht: zentraler Waechter (2 h, Verwechslungsschutz, Schreibsperre ausser News)"
|
||||
```
|
||||
|
||||
### Task 5: News-Änderungen in der Kundenansicht protokollieren
|
||||
|
||||
**Files:**
|
||||
- Modify: `server/routes/news.js` (nach erfolgreichem `createPost`, `storeNewsImage`, `updatePost`, `deletePost`)
|
||||
- Modify: `server/services/kundenansicht.js` (`logIfKundenansicht(req, action, detail = '') -> Promise<void>`)
|
||||
- Test: `test/kundenansichtNews.test.js`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `logViewAction` (Task 2), `isKundenansicht` (Task 3).
|
||||
- Produces: `logIfKundenansicht(req, action, detail)`; tut nichts außerhalb einer neuen Kundenansicht; Mitarbeiter aus `req.session.appwriteUserId`/`adminName`, Kunde aus `req.session.customerId`.
|
||||
|
||||
Detail: anlegen/ändern → `post.title || erste 60 Zeichen von post.text`; löschen → Titel/Text des Beitrags vor dem Löschen (`listPosts` liefert ihn nicht einzeln: vorher per `getDocument(config.collections.newsPosts, id)` lesen); Foto → leer.
|
||||
|
||||
- [ ] **Step 1: Failing tests** (Aufbau aus `newsRoutes.test.js` übernehmen, Wächter einhängen)
|
||||
|
||||
```js
|
||||
test('anlegen, aendern, Foto, loeschen in der Kundenansicht -> vier Protokolleintraege', async () => {
|
||||
// POST note (title 'Hallo') -> news_angelegt 'Hallo'; PATCH title 'Neu' -> news_geaendert 'Neu';
|
||||
// POST /image -> news_bild ''; DELETE -> news_geloescht 'Neu'; staffName jeweils 'Andrej', customerId 'c1'
|
||||
})
|
||||
test('Kunde selbst schreibt -> kein Protokolleintrag', async () => {})
|
||||
test('Protokoll scheitert -> News-Aktion trotzdem 201', async () => {})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Fehlschlag prüfen** — Run: `node --test test/kundenansichtNews.test.js` → FAIL
|
||||
- [ ] **Step 3: Implementieren**
|
||||
- [ ] **Step 4: Tests** — Run: `node --test test/kundenansichtNews.test.js test/newsRoutes.test.js` → PASS
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add server/routes/news.js server/services/kundenansicht.js test/kundenansichtNews.test.js
|
||||
git commit -m "Kundenansicht: News-Aenderungen mit Mitarbeiter und Titel protokollieren"
|
||||
```
|
||||
|
||||
### Task 6: Ende, `/me` und Team-Leserouten (Portal-Status, Protokoll, Preview-Link)
|
||||
|
||||
**Files:**
|
||||
- Modify: `server/routes/auth.js` (`POST /kundenansicht/ende`; `/me`-Zweig für neue Ansichten)
|
||||
- Modify: `server/routes/team/customers.js` (`GET /:id/portal`, `GET /:id/protokoll`)
|
||||
- Create: `server/routes/team/preview.js` (in `routes/team/index.js` unter `/preview-link`)
|
||||
- Modify: `server/routes/admin/previewOpen.js` (Kern als `buildStaffPreviewLink` exportieren; alte Route behält ihre Admin-Prüfung)
|
||||
- Test: `test/kundenansichtEnde.test.js`, `test/teamRoutes.test.js`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Tasks 1–3.
|
||||
- Produces: `buildStaffPreviewLink({ userId, email, name, subdomain, previewUrl }) -> Promise<{ status: number, body: object }>` (Logik unverändert aus `previewOpen.js`: Projekt per Subdomain, `isPreviewProjectReady`, Token, `openUrl` auf `/api/auth/staff-preview`).
|
||||
- Produces (HTTP, von WOMS Task 9–11 genutzt):
|
||||
- `GET /api/team/customers/:id/portal` → `{ hasAccess: boolean, lastLoginAt: string|null }` (404 unbekannter Kunde)
|
||||
- `GET /api/team/customers/:id/protokoll` → `{ entries: [{ id, at, staffName, action, detail }] }`
|
||||
- `POST /api/team/customers/:id/kundenansicht` → `{ openUrl }` (Task 3)
|
||||
- `POST /api/team/preview-link` mit `{ subdomain, previewUrl }` → `{ openUrl }` / 404 / 409
|
||||
- `POST /api/auth/kundenansicht/ende` → `{ redirect }`
|
||||
- `GET /api/auth/me` in neuer Ansicht → zusätzlich `viewId`, `viewExpiresAt`; abgelaufen → `{ authenticated: false, reason: 'view_expired', message: 'Die Kundenansicht ist abgelaufen. Bitte im Ticketsystem erneut öffnen.' }`
|
||||
|
||||
- [ ] **Step 1: Failing tests**
|
||||
|
||||
```js
|
||||
// kundenansichtEnde.test.js
|
||||
test('Beenden: Protokoll beendet, beide Cookies geloescht, Rueckweg zur Kundenseite im WOMS', async () => {
|
||||
const res = await post('/api/auth/kundenansicht/ende', {}, kv())
|
||||
assert.equal(res.body.redirect, 'https://ticket.webklar.com/customers/c1')
|
||||
// Set-Cookie leert webklar_portal_session und preview_session; letzter Protokolleintrag action 'beendet'
|
||||
})
|
||||
test('Beenden ohne Kundenansicht -> Rueckweg /customers', async () => {})
|
||||
test('/me in neuer Ansicht: viewId, viewExpiresAt, admin.name, Preview-Cookie nur fuer den Kunden', async () => {
|
||||
// preview_session neu ausgestellt (ohne vorhandenes Cookie): role 'customer', customerId 'c1', exp <= viewExpiresAt/1000
|
||||
})
|
||||
test('/me abgelaufen -> authenticated false, reason view_expired, Protokoll abgelaufen einmal', async () => {})
|
||||
test('/me alte Ansicht (viewAsCookie) unveraendert: viewAs true, keine viewId', async () => {})
|
||||
|
||||
// teamRoutes.test.js
|
||||
test('portal: hasAccess und lastLoginAt; c2 ohne Zugang hasAccess false; unbekannt 404', async () => {})
|
||||
test('protokoll: Eintraege des Kunden neueste zuerst', async () => {})
|
||||
test('preview-link fuer Mitarbeiter ohne Admin-Label -> openUrl auf staff-preview; nicht bereit -> 409', async () => {})
|
||||
test('alle Team-Routen ohne JWT -> 401', async () => {})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Fehlschlag prüfen** — Run: `node --test test/kundenansichtEnde.test.js test/teamRoutes.test.js` → FAIL
|
||||
- [ ] **Step 3: Implementieren** (`/me`: Zweig für `isKundenansicht(session)` vor dem bisherigen Admin-Zweig; Preview-Refresh mit Kunden-Payload und `maxAgeSec = ceil((viewExpiresAt - now)/1000)`)
|
||||
- [ ] **Step 4: Tests** — Run: `npm test` → PASS
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add server/routes/auth.js server/routes/team/customers.js server/routes/team/preview.js server/routes/team/index.js server/routes/admin/previewOpen.js test/kundenansichtEnde.test.js test/teamRoutes.test.js
|
||||
git commit -m "Kundenansicht: Beenden mit Rueckweg ins Ticketsystem, /me mit Ansichts-Kennung, Team-Routen Portal-Status, Protokoll, Preview-Link"
|
||||
```
|
||||
|
||||
### Task 7: Dashboard: Kennung mitsenden, Banner, Ersetzt-/Abgelaufen-Hinweis
|
||||
|
||||
**Files:**
|
||||
- Create: `public/kundenansicht.js` (klassisches Skript, kein Modul)
|
||||
- Modify: `public/dashboard.html:288` (`<script src="/kundenansicht.js"></script>` vor `chat-composer.js`)
|
||||
- Modify: `public/app.js:632-645` (`renderViewAsBanner`), `public/app.js:2069-2092` (Init), Aufrufe von `/api/chat/read` (Zeilen ~775, ~812, ~2007: in der Kundenansicht nicht aufrufen)
|
||||
- Modify: `public/kundenbereich.css` (Overlay `.kb-view-overlay`)
|
||||
- Test: `test/kundenansichtClient.test.js` (lädt `public/kundenansicht.js` per `node:vm` mit Fake-`window`)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `/api/auth/me` (`viewId`), `POST /api/auth/kundenansicht/ende` (Task 6), Fehlercodes aus Task 4.
|
||||
- Produces: `window.KbView = { setId(id), id(), showReplaced(kunde), showExpired() }`. Das Skript ersetzt `window.fetch` und `XMLHttpRequest.prototype.open/send` so, dass bei gesetzter ID jede Anfrage an dieselbe Herkunft mit Pfad `/api/…` den Header `X-Kundenansicht` bekommt. Antworten 409 `view_replaced` / 401 `view_expired` (Body per `response.clone().json()`) blenden ein Vollbild-Overlay ein und lassen die Antwort sonst unverändert durch.
|
||||
|
||||
Texte (fest):
|
||||
- Banner: `Kundenansicht: <strong>{Kunde}</strong> · geöffnet von {Mitarbeiter}` + Knopf `Beenden`.
|
||||
- Ersetzt: „Diese Ansicht wurde durch die Kundenansicht von {kunde} ersetzt.“ + Knopf „Tab schließen“ (`window.close()`).
|
||||
- Abgelaufen: „Die Kundenansicht ist abgelaufen. Bitte im Ticketsystem erneut öffnen.“
|
||||
|
||||
Verhalten in `app.js`: Nach `/me` bei `me.viewId` → `KbView.setId(me.viewId)` und neues Banner; „Beenden“ → `POST /api/auth/kundenansicht/ende`, dann `location.href = redirect`. Ohne `viewId` bei `me.viewAs` → altes Banner unverändert (Rückweg `/admin.html`). `me.reason === 'view_expired'` → Weiterleitung auf Login mit `error=me.message` wie bei `password_changed`.
|
||||
|
||||
- [ ] **Step 1: Failing tests** (`vm.runInNewContext(src, ctx)` mit `ctx.window = ctx`, Fake-`fetch`, das Aufrufe mitschreibt, `location.origin = 'https://project.webklar.com'`, minimalem `document` für das Overlay)
|
||||
|
||||
```js
|
||||
test('ohne ID kein Header', async () => {})
|
||||
test('mit ID: Header bei /api/... gleicher Herkunft, nicht bei fremder Herkunft oder /img', async () => {
|
||||
ctx.KbView.setId('a'.repeat(32))
|
||||
await ctx.fetch('/api/projects')
|
||||
await ctx.fetch('https://example.org/api/x')
|
||||
await ctx.fetch('/img/logo.png')
|
||||
assert.equal(header(calls[0]), 'a'.repeat(32))
|
||||
assert.equal(header(calls[1]), undefined)
|
||||
assert.equal(header(calls[2]), undefined)
|
||||
})
|
||||
test('409 view_replaced zeigt Overlay mit Kundennamen, Antwort bleibt lesbar', async () => {})
|
||||
test('401 view_expired zeigt Abgelaufen-Overlay', async () => {})
|
||||
test('XHR bekommt den Header ebenfalls', async () => {})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Fehlschlag prüfen** — Run: `node --test test/kundenansichtClient.test.js` → FAIL
|
||||
- [ ] **Step 3: Implementieren** (`kundenansicht.js`, Banner/Init in `app.js`, CSS: Overlay fixiert über allem, Farben aus den vorhandenen Variablen von `kundenbereich.css`)
|
||||
- [ ] **Step 4: Tests** — Run: `node --test test/kundenansichtClient.test.js` → PASS
|
||||
- [ ] **Step 5: Sichtprüfung mit Playwright** nach dem Muster `test/ui/funktionen.ui.mjs` (statischer Server für `public/`, `page.route` für `/api/*`): neues Skript `test/ui/kundenansicht.ui.mjs` mit drei Szenarien (Banner + Beenden-Aufruf; 409 beim Nachladen → Overlay; `me.viewAs` ohne `viewId` → altes Banner). Screenshots nach `/root/reports/admin-umzug-ui/`. Run: `node test/ui/kundenansicht.ui.mjs` → alle Checks „ok“.
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add public/kundenansicht.js public/dashboard.html public/app.js public/kundenbereich.css test/kundenansichtClient.test.js test/ui/kundenansicht.ui.mjs
|
||||
git commit -m "Kundenansicht im Dashboard: Kennung bei jeder Anfrage, Banner mit Beenden, Hinweis bei ersetzter oder abgelaufener Ansicht"
|
||||
```
|
||||
|
||||
### Task 8: Datenschutzerklärung Abschnitt 6
|
||||
|
||||
**Files:**
|
||||
- Modify: `public/datenschutz.html:82` (Satz ersetzen), `:47` (`Stand 01.10.2026`)
|
||||
- Modify: `docs/WEBklar-Datenschutzerklaerung.pdf` (neu erzeugen)
|
||||
- Test: `test/legalTexts.test.js` (neuer Test)
|
||||
|
||||
- [ ] **Step 1: Failing test**
|
||||
|
||||
```js
|
||||
test('legal_datenschutz_kundenansicht: Abschnitt 6 nennt Kundenansicht, Sperren, Protokoll und 12 Monate', () => {
|
||||
const html = read('public/datenschutz.html')
|
||||
assert.ok(!html.includes('Eine Nachricht in Ihrem Namen senden können sie dabei nicht.'))
|
||||
assert.ok(html.includes('Um Sie zu unterstützen, können unsere Mitarbeiter Ihr Portal so öffnen, wie Sie es sehen, und dabei für Sie News-Beiträge anlegen, ändern oder löschen.'))
|
||||
assert.ok(html.includes('Bestellungen, Zahlungen, Änderungen an Ihrem Passwort sowie Nachrichten oder Anträge in Ihrem Namen sind dabei ausgeschlossen.'))
|
||||
assert.ok(html.includes('Jedes Öffnen und jede Änderung halten wir mit Mitarbeiter und Zeitpunkt fest und löschen diese Einträge nach 12 Monaten.'))
|
||||
assert.ok(html.includes('Rechtsgrundlage dafür ist Art. 6 Abs. 1 lit. f DSGVO; unser berechtigtes Interesse ist die Nachvollziehbarkeit von Zugriffen auf Ihr Konto.'))
|
||||
assert.ok(html.includes('Stand 01.10.2026'))
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Fehlschlag prüfen** — Run: `node --test test/legalTexts.test.js` → FAIL
|
||||
- [ ] **Step 3: Text einsetzen** (Wortlaut exakt aus der Spec, Abschnitt 3 „Datenschutzerklärung“), PDF erzeugen: `scripts/build-legal-pdfs.sh all` und danach `git status --short docs/` prüfen: nur `docs/WEBklar-Datenschutzerklaerung.pdf` darf sich geändert haben; andere geänderte PDFs mit `git checkout -- <datei>` zurücksetzen.
|
||||
- [ ] **Step 4: Tests** — Run: `node --test test/legalTexts.test.js` → PASS
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add public/datenschutz.html docs/WEBklar-Datenschutzerklaerung.pdf test/legalTexts.test.js
|
||||
git commit -m "Datenschutz Abschnitt 6: Kundenansicht mit News, Sperren und Protokoll (12 Monate)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## WOMS
|
||||
|
||||
### Task 9: WOMS-Worktree, `teamApi` und `openInNewTab`
|
||||
|
||||
**Files:**
|
||||
- Create (Worktree): `git -C /home/webklar/apps/ticket.webklar.com/build fetch origin && git -C /home/webklar/apps/ticket.webklar.com/build worktree add ../dev-admin-umzug -b feature/admin-umzug origin/test`, danach `rm -rf node_modules && ln -s ../build/node_modules node_modules` im neuen Worktree
|
||||
- Create: `src/lib/teamApi.js`
|
||||
- Create: `src/lib/openInNewTab.js`
|
||||
- Test: `tests/unit/openInNewTab.test.mjs`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `createFinanceRequest` aus `src/lib/financeRequest.js` (generischer Anfrage-Kern: JWT-Cache, Retry bei 401, deutsche Fehlertexte).
|
||||
- Produces in `teamApi.js` (Basis `${VITE_PROJECT_ADMIN_URL || 'https://project.webklar.com'}/api/team`):
|
||||
- `getCustomerPortalStatus(customerId) -> Promise<{ hasAccess, lastLoginAt }>`
|
||||
- `getCustomerViewLog(customerId) -> Promise<{ entries }>`
|
||||
- `createKundenansichtLink(customerId) -> Promise<string>` (gibt `openUrl` zurück)
|
||||
- `createPreviewLink({ subdomain, previewUrl }) -> Promise<string>`
|
||||
- Produces: `openInNewTab(getUrl: () => Promise<string>, { win = globalThis.window } = {}) -> Promise<void>`: öffnet **synchron** `win.open('', '_blank')`, setzt `tab.opener = null`, wartet auf `getUrl()`, setzt `tab.location.href`; bei Fehler `tab.close()` und Fehler weiterwerfen; liefert `win.open` null (Pop-up blockiert), wirft es `new Error('Neuer Tab wurde vom Browser blockiert.')`, ohne `getUrl` aufzurufen.
|
||||
|
||||
- [ ] **Step 1: Failing tests**
|
||||
|
||||
```js
|
||||
test('open laeuft vor getUrl, danach wird die Adresse gesetzt', async () => {
|
||||
const order = []
|
||||
const tab = { location: {}, close: () => order.push('close') }
|
||||
const win = { open: () => { order.push('open'); return tab } }
|
||||
await openInNewTab(async () => { order.push('getUrl'); return 'https://x/y' }, { win })
|
||||
assert.deepEqual(order, ['open', 'getUrl'])
|
||||
assert.equal(tab.location.href, 'https://x/y')
|
||||
assert.equal(tab.opener, null)
|
||||
})
|
||||
test('Fehler beim Holen -> Tab wird geschlossen, Fehler weitergereicht', async () => {})
|
||||
test('Pop-up blockiert -> Fehlermeldung, getUrl nicht aufgerufen', async () => {})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Fehlschlag prüfen** — Run: `node --test tests/unit/openInNewTab.test.mjs` → FAIL
|
||||
- [ ] **Step 3: Implementieren**
|
||||
- [ ] **Step 4: Tests** — Run: `npm test` → PASS
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/lib/teamApi.js src/lib/openInNewTab.js tests/unit/openInNewTab.test.mjs
|
||||
git commit -m "Team-API des Kundenportals und Oeffnen im neuen Tab ohne Pop-up-Sperre"
|
||||
```
|
||||
|
||||
### Task 10: Kundenseite: Tab in der URL, „Übersicht“, Protokoll-Tab
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/lib/customerTabs.js` (Label „Übersicht“, Tab `{ id: 'protokoll', label: 'Protokoll' }` nach `projects`)
|
||||
- Create: `src/lib/viewLog.js`
|
||||
- Create: `src/components/customer/CustomerViewLog.jsx`
|
||||
- Modify: `src/pages/CustomerDetailPage.jsx:15-96` (Tab aus `useSearchParams` statt `useState`; Wechsel per `setSearchParams({ tab }, { replace: true })`; Tab `protokoll` rendert `CustomerViewLog`)
|
||||
- Test: `tests/unit/customerTabs.test.mjs` (anpassen), `tests/unit/viewLog.test.mjs`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `getCustomerViewLog` (Task 9).
|
||||
- Produces: `viewLogLabel(action: string) -> string`; Zuordnung: `geoeffnet` → „Kundenansicht geöffnet“, `beendet` → „Kundenansicht beendet“, `abgelaufen` → „Kundenansicht abgelaufen“, `news_angelegt` → „News angelegt“, `news_bild` → „News-Foto hochgeladen“, `news_geaendert` → „News geändert“, `news_geloescht` → „News gelöscht“; unbekannt → der Rohwert.
|
||||
- Produces: `CustomerViewLog({ customerId })`: Tabelle Zeit (`dd.mm.yyyy hh:mm`, Europe/Berlin) · Mitarbeiter · Aktion · Detail; leer → `EmptyState` „Noch keine Einträge“; Fehler → Fehlerzeile mit Knopf „Erneut versuchen“.
|
||||
|
||||
- [ ] **Step 1: Tests anpassen/schreiben**
|
||||
|
||||
```js
|
||||
test('customerTabs: Reihenfolge mit Protokoll, Uebersicht mit Umlaut', () => {
|
||||
assert.deepEqual(customerTabs(true).map((t) => t.label), ['Übersicht', 'Tickets', 'Rechnungen', 'Projekte', 'Protokoll'])
|
||||
assert.deepEqual(customerTabs(false).map((t) => t.id), ['overview', 'tickets', 'projects', 'protokoll'])
|
||||
})
|
||||
test('resolveCustomerTab: protokoll fuer alle', () => {
|
||||
assert.equal(resolveCustomerTab('protokoll', false), 'protokoll')
|
||||
})
|
||||
test('viewLogLabel: alle sieben Aktionen und Rohwert', () => {
|
||||
assert.equal(viewLogLabel('news_bild'), 'News-Foto hochgeladen')
|
||||
assert.equal(viewLogLabel('neu_irgendwas'), 'neu_irgendwas')
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Fehlschlag prüfen** — Run: `node --test tests/unit/customerTabs.test.mjs tests/unit/viewLog.test.mjs` → FAIL
|
||||
- [ ] **Step 3: Implementieren**
|
||||
- [ ] **Step 4: Tests** — Run: `npm test` → PASS
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/lib/customerTabs.js src/lib/viewLog.js src/components/customer/CustomerViewLog.jsx src/pages/CustomerDetailPage.jsx tests/unit/customerTabs.test.mjs tests/unit/viewLog.test.mjs
|
||||
git commit -m "Kundenseite: Reiter in der Adresse, Uebersicht mit Umlaut, Protokoll der Kundenansicht"
|
||||
```
|
||||
|
||||
### Task 11: Knopf „Kundenansicht öffnen“, Letzter Login, Preview per Einmal-Link
|
||||
|
||||
**Files:**
|
||||
- Create: `src/components/customer/KundenansichtButton.jsx`
|
||||
- Create: `src/lib/lastLogin.js`
|
||||
- Modify: `src/pages/CustomerDetailPage.jsx` (Knopf in `PageHeader.actions` vor „Archivieren“; `PortalAccessCard`: Link „Portal öffnen“ durch `KundenansichtButton` ersetzen, Zeile „Letzter Login“ mit `getCustomerPortalStatus`)
|
||||
- Modify: `src/components/PreviewLinkButton.jsx` (Props `{ href, subdomain, title, size }`; Klick → `openInNewTab(() => createPreviewLink({ subdomain, previewUrl: href }))`; ohne `subdomain` bleibt der einfache Link)
|
||||
- Modify: `src/pages/ProjectsPage.jsx:577`, `src/components/AssignedProjectCard.jsx:94` (`subdomain={project.subdomain}` übergeben)
|
||||
- Test: `tests/unit/lastLogin.test.mjs`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `createKundenansichtLink`, `getCustomerPortalStatus`, `createPreviewLink` (Task 9), `openInNewTab` (Task 9).
|
||||
- Produces: `KundenansichtButton({ customer })`: aktiv nur bei `customer.portalAccessEnabled && customer.appwriteUserId`, sonst ausgegraut mit `title="Kein Portal-Zugang"`; Beschriftung „Kundenansicht öffnen“ mit Icon `FaArrowUpRightFromSquare`; Fehler → `window.alert(err.message)`.
|
||||
- Produces: `formatLastLogin(iso: string|null) -> string`: `null`/leer → „noch nie“, sonst `dd.mm.yyyy, hh:mm Uhr` in Europe/Berlin.
|
||||
|
||||
- [ ] **Step 1: Failing test**
|
||||
|
||||
```js
|
||||
test('formatLastLogin', () => {
|
||||
assert.equal(formatLastLogin(null), 'noch nie')
|
||||
assert.equal(formatLastLogin('2026-09-30T10:05:00.000Z'), '30.09.2026, 12:05 Uhr')
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Fehlschlag prüfen** — Run: `node --test tests/unit/lastLogin.test.mjs` → FAIL
|
||||
- [ ] **Step 3: Implementieren**
|
||||
- [ ] **Step 4: Tests + Build**
|
||||
|
||||
Run: `npm test` → PASS
|
||||
Run: `systemd-run --scope -p MemoryMax=1500M -p MemorySwapMax=0 env NODE_OPTIONS=--max-old-space-size=1200 npx vite build --outDir /tmp/claude-0/-root/58d3e37c-9aaf-40c4-9aa6-1a699f81c435/scratchpad/woms-out` → „built in“, keine Fehler
|
||||
|
||||
- [ ] **Step 5: Sichtprüfung mit Playwright** (Harness nach dem Rezept aus der Memory `woms-ticketsystem`: esbuild-Bündel der Kundenseite mit gestubbten Fremd-Panels, Appwrite-REST und `/api/team/*` per `page.route` nachgebaut, Speicherbremse `MemoryMax=700M`): Checks — Knopf aktiv/ausgegraut, Klick öffnet einen Tab und setzt die `openUrl`, Fehler schließt den Tab; `?tab=protokoll` öffnet direkt den Protokoll-Reiter; Reiterwechsel ändert die Adresse; „Letzter Login“ zeigt den formatierten Wert; Preview-Knopf ruft `/api/team/preview-link`. Harness im Scratchpad, nicht committen.
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/components/customer/KundenansichtButton.jsx src/lib/lastLogin.js src/pages/CustomerDetailPage.jsx src/components/PreviewLinkButton.jsx src/pages/ProjectsPage.jsx src/components/AssignedProjectCard.jsx tests/unit/lastLogin.test.mjs
|
||||
git commit -m "Kundenseite: Kundenansicht oeffnen, letzter Login, Preview per Einmal-Link"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Abschluss
|
||||
|
||||
### Task 12: Gesamtprüfung und Übergabe
|
||||
|
||||
- [ ] **Step 1:** Portal: `npm test` → alle grün (vorher bekannte Flakes: `repoMove`; bei Fehlschlag einzeln wiederholen und im Bericht nennen).
|
||||
- [ ] **Step 2:** Portal unter Node 20 (Produktion): `docker run --rm -v $PWD:$PWD:ro -v /home/webklar/apps/kundenbereich.webklar.com/build/node_modules:/home/webklar/apps/kundenbereich.webklar.com/build/node_modules:ro -w $PWD node:20-alpine node --test test/teamAuth.test.js test/kundenansicht*.test.js test/teamRoutes.test.js test/customerViewLog.test.js` → PASS.
|
||||
- [ ] **Step 3:** WOMS: `npm test` → grün; Build aus Task 11 Step 4 erfolgreich.
|
||||
- [ ] **Step 4:** Abschlussreview des ganzen Branches (beide Repos) durch einen frischen Reviewer (Modell sonnet).
|
||||
- [ ] **Step 5:** Übergabe an den Nutzer mit Livegang-Reihenfolge:
|
||||
1. Portal pushen: `git -C /home/webklar/apps/kundenbereich.webklar.com/dev-admin-umzug push origin feature/admin-umzug:main`
|
||||
2. Collection anlegen: `docker exec -w /app webklar-kundenbereich-server node scripts/setup-abo-collections.mjs` (scheitert es an Rechten: Felder aus Task 2 in der Appwrite-Konsole anlegen)
|
||||
3. Live-Check (ich): `/api/team/customers/x/portal` ohne JWT → 401; CORS-Preflight von `https://ticket.webklar.com` → 204 mit neuer Header-Liste; `datenschutz.html` enthält den neuen Satz.
|
||||
4. WOMS pushen: `git -C /home/webklar/apps/ticket.webklar.com/dev-admin-umzug push origin feature/admin-umzug:test`
|
||||
5. Live-Check (ich): ausgeliefertes Bundle enthält „Kundenansicht öffnen“ und `/api/team/`.
|
||||
6. Nutzer-Test: Kundenansicht bei einem Kunden öffnen, Banner prüfen, Beenden, Protokoll-Tab ansehen.
|
||||
Reference in new issue
Block a user