Compare commits
5
Commits
a6ac4510cd
...
40f0a04908
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
40f0a04908 | ||
|
|
45203284ee | ||
|
|
0fdd13246c | ||
|
|
7d93ae0cd9 | ||
|
|
a06c3bc8ef |
No files matched your search
@@ -0,0 +1,225 @@
|
||||
# Admin-Panel ins Ticketsystem – Etappe 3 (Gutschriften, Abo & Angebote, E-Mail-Speicher, Funktionen) 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:** Die restlichen Admin-Panel-Bereiche laufen im WOMS: Sammellisten Gutschriften, Abo-Angebote, E-Mail-Speicher unter Kunden; Kundenreiter „Abo & Angebote“, „Gutschriften“, „Funktionen“; Abo-Status für alle Mitarbeiter. Das Admin-Panel bleibt bis Etappe 4 nutzbar.
|
||||
|
||||
**Architecture:** Die vier bestehenden Admin-Router des Portals (`takeoverAdminRouter`, `offersAdminRouter`, `storageTasksAdminRouter`, `funktionenAdminRouter`) werden zusätzlich unter `/api/team` eingehängt (Logik unverändert, Sitzung aus dem JWT), ergänzt um Kundenfilter, Zähl-Routen ohne Stripe, Abo-Status und signierte Nachweis-Links. WOMS baut die Oberflächen in React nach; reine Logik (Texte, Rechnungen, Prüfungen) in testbaren `src/lib/*`-Modulen, portiert aus `public/admin.js`.
|
||||
|
||||
**Tech Stack:** Portal Express 4 (ESM, Node 20), node:test + `test/support/*` (Appwrite-Nachbau, Stripe-Attrappe, Mail-Senke). WOMS React 18, react-router 6, node:test + `tests/unit/load.mjs`, Playwright-Harness im Scratchpad.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-10-01-admin-panel-ins-ticketsystem-design.md` (Abschnitt 1 Sidebar/Kundenseite, Abschnitt 2 Gutschriften/Abo & Angebote/E-Mail-Speicher/Funktionen, Abschnitt 4 Tabelle `/api/team`, Etappe 3 in Abschnitt 5). Abo-Status-Route aus Etappe 1 (dort bewusst verschoben) gehört hierher.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Arbeitsorte: Portal `/home/webklar/apps/kundenbereich.webklar.com/dev-admin-umzug` (Branch `feature/admin-umzug`, Stand `a6ac451` = origin/main), WOMS `/home/webklar/apps/ticket.webklar.com/dev-admin-umzug` (Branch `feature/admin-umzug`, Stand `813c6a7` = origin/test). Vor Beginn je `git fetch` + `git merge --ff-only`.
|
||||
- Nie `git add -A`, nichts pushen; Livegang Portal zuerst, dann WOMS (Nutzer pusht).
|
||||
- `command grep -a`. UI-Texte deutsch mit Umlauten. **Funktionsgleich zum Admin-Panel:** Texte, Bestätigungsfragen, Prüfungen und Abläufe wortgleich aus `public/admin.js` (Zeilenangaben je Task); keine neuen Funktionen.
|
||||
- Server-Logik der vier Router bleibt unverändert; nur Einhängen, Filter, Zähl-Routen, Abo-Status, Signatur-Links kommen dazu.
|
||||
- Reiterfolge Kundenseite: `Übersicht · Tickets · Rechnungen · Projekte · Chat · Abo & Angebote · Gutschriften · Funktionen · Protokoll` (ids `overview, tickets, invoices, projects, chat, abo, gutschriften, funktionen, protokoll`; Rechnungen nur Admins).
|
||||
- Sidebar-Unterpunkte unter Kunden: `Chat` (Zahl ungelesen), `Gutschriften` (Zahl offen), `Abo-Angebote`, `E-Mail-Speicher` (Zahl offen) – Abfrage alle 60 s bei sichtbarem Tab.
|
||||
- Routen WOMS: `/customers/gutschriften` (`?antrag=<id>`), `/customers/angebote`, `/customers/email-speicher`; Kundenseite `?tab=gutschriften&antrag=<id>`.
|
||||
- Signierte Nachweis-Links: `signFileLink({ kind: 'takeover', customerId: <requestId>, id: <fileId> })`, 15 min.
|
||||
- Speicherbremse für Builds/Playwright (`systemd-run --scope … MemoryMax`).
|
||||
|
||||
## Review Focus
|
||||
|
||||
1. **Doppelklick oder zwei Mitarbeiter gleichzeitig auf „Genehmigen & gutschreiben“:** Erwartung: genau eine Gutschrift in Stripe, die zweite Aktion bekommt die Server-Meldung „Über diesen Antrag wurde bereits entschieden.“ (Task 6: Knopf während der Anfrage gesperrt, 409 sichtbar).
|
||||
2. **Kundenwechsel im Angebotsformular, während der Kontext noch lädt:** Erwartung: Vorschau und „Angebot anlegen“ zeigen nie Daten des vorigen Kunden; Speichern erst, wenn der Kontext zum aktuellen Kunden da ist (Task 7, Playwright).
|
||||
3. **Mitarbeiter ohne Admin-Label:** Erwartung: Gutschriften, Angebote, E-Mail-Speicher, Funktionen und Abo-Status funktionieren vollständig (Task 1/3 Server-Tests mit `jwt-staff`).
|
||||
4. **Nachweis länger als 15 min geöffnet:** Erwartung: erneutes Öffnen der Antragsdetails liefert frische Links (Task 2: Links werden bei jedem `GET /:id` neu signiert).
|
||||
5. **Handy (390 px):** Listen und Detail ohne waagerechtes Scrollen bedienbar (Task 6–8, Playwright).
|
||||
|
||||
---
|
||||
|
||||
## Portal
|
||||
|
||||
### Task 1: Admin-Router im Team-Eingang, Kundenfilter, Zähl-Route E-Mail-Speicher
|
||||
|
||||
**Files:**
|
||||
- Modify: `server/routes/team/index.js` (nach `requireStaffJwt`: `router.use('/takeover-credits', takeoverAdminRouter)`, `router.use('/offers', offersAdminRouter)`, `router.use('/storage-tasks', storageTasksAdminRouter)`, `router.use('/funktionen', funktionenAdminRouter)`)
|
||||
- Modify: `server/routes/takeoverCredits.js:119-153` (`?customerId=` filtert `requests`; `openCount` unverändert über alle)
|
||||
- Modify: `server/routes/offers.js:23-39` (`?customerId=` filtert `offers`)
|
||||
- Modify: `server/routes/storageTasks.js` (neu `GET /open-count` vor `/:id`-Routen: `{ open }` = offene Speicher-Aufgaben, **ohne** `reconcileOpenSwitches`)
|
||||
- Test: `test/teamAdminRoutes.test.js`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces (HTTP, gleiche Form wie unter `/api/portal-admin`): `/api/team/takeover-credits[?status=all][&customerId=]`, `/calc`, `/open-count`, `/:id`, `/:id/decision`, `/:id/apply`; `/api/team/offers[?customerId=]`, `/context/:customerId`, `POST /`, `/:id/withdraw`, `/:id/notify`; `/api/team/storage-tasks`, `/open-count`, `/:id/done`; `/api/team/funktionen/:customerId`, `POST /:customerId/news`.
|
||||
|
||||
- [ ] **Step 1: Failing tests** (Aufbau wie `test/teamChat.test.js`; `seedFixtures`, Mitarbeiter `e_staff2`; Gutschrift-Antraege `takeoverCredits` mit Schema aus `schemaKeys('takeoverCredits')`; Speicher-Aufgaben als `contractEvents` `type: 'speicher', status: 'offen'` wie in `test/storageTasks.test.js`)
|
||||
|
||||
```js
|
||||
test('alle vier Bereiche mit jwt-staff erreichbar (kein Admin-Label noetig)', async () => {
|
||||
for (const p of ['/api/team/takeover-credits', '/api/team/offers', '/api/team/storage-tasks', '/api/team/funktionen/c1']) {
|
||||
assert.equal((await rawGet(p, staff)).status, 200, p)
|
||||
}
|
||||
})
|
||||
test('Kundenfilter: Gutschriften und Angebote nur des Kunden, openCount weiter ueber alle', async () => {
|
||||
const credits = (await rawGet('/api/team/takeover-credits?status=all&customerId=c1', staff)).body
|
||||
assert.ok(credits.requests.length > 0 && credits.requests.every((r) => r.customerId === 'c1'))
|
||||
assert.equal(credits.openCount, /* offene Antraege aller Kunden */ 2)
|
||||
const offers = (await rawGet('/api/team/offers?customerId=c2', staff)).body.offers
|
||||
assert.ok(offers.every((o) => o.customerId === 'c2'))
|
||||
})
|
||||
test('E-Mail-Speicher open-count ohne Stripe-Abgleich', async () => {
|
||||
const before = st.calls.length
|
||||
assert.deepEqual((await rawGet('/api/team/storage-tasks/open-count', staff)).body, { open: 1 })
|
||||
assert.equal(st.calls.length, before)
|
||||
})
|
||||
test('Mitarbeiter aus dem JWT: Rueckfrage traegt "Andrej <andrej@webklar.com>", Erledigt traegt die Mail, Funktionen-Notiz den Namen', async () => {
|
||||
// POST /api/team/takeover-credits/<id>/decision { action: 'question', note: 'Bitte Rechnung schicken' } -> 200, Verlauf by 'Andrej <andrej@webklar.com>'
|
||||
// POST /api/team/storage-tasks/<id>/done -> task.doneBy 'andrej@webklar.com'
|
||||
// POST /api/team/funktionen/c1/news { projectId, action: 'activate', note: 'Pilot' } -> adminNote enthaelt 'Andrej: activate'
|
||||
})
|
||||
test('ohne JWT 401 fuer alle neuen Pfade', async () => {})
|
||||
```
|
||||
|
||||
- [ ] **Step 2:** Run `node --test test/teamAdminRoutes.test.js` → FAIL (404 unter `/api/team/...`)
|
||||
- [ ] **Step 3:** Implementieren
|
||||
- [ ] **Step 4:** Run `npm test` → PASS (bestehende `storageTasks.test.js`, `funktionenRoutes.test.js` bleiben grün)
|
||||
- [ ] **Step 5: Commit** — `git add server/routes/team/index.js server/routes/takeoverCredits.js server/routes/offers.js server/routes/storageTasks.js test/teamAdminRoutes.test.js` · Nachricht „Team-Eingang: Gutschriften, Angebote, E-Mail-Speicher und Funktionen fuer alle Mitarbeiter, Kundenfilter, Zaehlroute“
|
||||
|
||||
### Task 2: Signierte Links für Gutschrift-Nachweise
|
||||
|
||||
**Files:**
|
||||
- Modify: `server/routes/takeoverCredits.js:176-182` (Antwort von `GET /:id`: jedes `request.files[i]` bekommt `url` = `${config.publicBaseUrl}/api/team/takeover-credits/<id>/files/<fileId>?exp=…&sig=…`)
|
||||
- Modify: `server/routes/team/files.js` (`GET /takeover-credits/:id/files/:fileId` mit `sig` → wie `takeoverAdminRouter.get('/:id/files/:fileId')` über `getTakeoverRequest` + `readUpload`; ungültig → 403 `{ error: 'Link abgelaufen. Bitte den Antrag neu öffnen.' }`; ohne `sig` → `next()`)
|
||||
- Test: `test/teamAdminRoutes.test.js` (ergänzen)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `signFileLink`/`verifyFileLink` (`server/services/signedLinks.js`).
|
||||
- Produces: `request.files[].url` in `GET /api/team/takeover-credits/:id` (und unter `/api/portal-admin`, dort unbenutzt).
|
||||
|
||||
- [ ] **Step 1: Failing tests**
|
||||
|
||||
```js
|
||||
test('Nachweis: signierter Link ohne JWT 200 mit Inhalt, fremder Antrag/abgelaufen 403, ohne sig 401', async (t) => {
|
||||
// Antrag mit gespeicherter Datei (wie test/takeoverCredits.test.js bzw. services/takeoverCredits.js storeUpload) seeden
|
||||
// GET /api/team/takeover-credits/<id> (staff) -> request.files[0].url passt auf /\/api\/team\/takeover-credits\/<id>\/files\/<fid>\?exp=\d+&sig=[0-9a-f]{64}$/
|
||||
// fetch(url ohne Origin) -> 200, Content-Type application/pdf
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2:** Run → FAIL · **Step 3:** Implementieren · **Step 4:** `npm test` → PASS
|
||||
- [ ] **Step 5: Commit** — „Gutschriften: signierte Links fuer Nachweise im Ticketsystem“
|
||||
|
||||
### Task 3: Abo-Status für alle Mitarbeiter
|
||||
|
||||
**Files:**
|
||||
- Modify: `server/routes/team/customers.js` (`GET /:id/abo`)
|
||||
- Test: `test/teamRoutes.test.js` (ergänzen)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `getCurrentSubscription(customerId)` (`server/services/stripeBilling.js:178`, nur Appwrite), `PRODUCT_KEYS`/`ACTIVE_STATUSES` wie in `server/routes/admin/customers.js:49-61`.
|
||||
- Produces: `GET /api/team/customers/:id/abo` → `{ productKey: 'wartung'|'hosting'|null, active: boolean, status: string|null, storageUnits: number, startedAt: string|null, serviceStart: string|null, currentPeriodEnd: string|null, cancelAt: string|null }` (`productKey`/`active` exakt wie die Admin-Route, damit `useCustomerAbo` umsteigen kann).
|
||||
|
||||
- [ ] **Step 1: Failing test**
|
||||
|
||||
```js
|
||||
test('abo: Mitarbeiter ohne Admin-Label sieht den Abo-Status', async () => {
|
||||
aw.seed('subscriptions', { $id: 'sub_t', customerId: 'c3', status: 'active', productKey: 'wartung', storageUnits: 1, startedAt: '2026-09-01T10:00:00.000Z', serviceStart: '2026-09-15T00:00:00.000Z', currentPeriodEnd: '2026-11-01T00:00:00.000Z' })
|
||||
const res = await rawGet('/api/team/customers/c3/abo', auth)
|
||||
assert.deepEqual(res.body, { productKey: 'wartung', active: true, status: 'active', storageUnits: 1, startedAt: '2026-09-01T10:00:00.000Z', serviceStart: '2026-09-15T00:00:00.000Z', currentPeriodEnd: '2026-11-01T00:00:00.000Z', cancelAt: null })
|
||||
assert.deepEqual((await rawGet('/api/team/customers/c4/abo', auth)).body, { productKey: null, active: false, status: null, storageUnits: 0, startedAt: null, serviceStart: null, currentPeriodEnd: null, cancelAt: null })
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2:** Run → FAIL · **Step 3:** Implementieren · **Step 4:** `npm test` → PASS
|
||||
- [ ] **Step 5: Commit** — „Abo-Status eines Kunden fuer alle Mitarbeiter im Team-Eingang“
|
||||
|
||||
---
|
||||
|
||||
## WOMS
|
||||
|
||||
### Task 4: Team-API, Zähler und Format-Hilfen
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/lib/teamApi.js`, `src/lib/navLinks.js` (+ Test), `src/components/Navbar.jsx`, `src/hooks/useCustomerAbo.js` (auf `getCustomerAboStatus` umstellen, Rückgabe unverändert)
|
||||
- Create: `src/hooks/useSidebarCounts.js` (ersetzt `useChatUnread.js`, Datei entfernen), `src/lib/money.js`, `src/lib/creditFormat.js`, `src/lib/offerFormat.js`, `src/lib/storageFormat.js`
|
||||
- Test: `tests/unit/navLinks.test.mjs` (anpassen), `tests/unit/creditFormat.test.mjs`, `tests/unit/offerFormat.test.mjs`, `tests/unit/storageFormat.test.mjs`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces in `teamApi.js`: `listCredits({ status, customerId })`, `getCreditOpenCount()`, `getCredit(id)`, `calcCredit(body)`, `decideCredit(id, body)`, `applyCredit(id)`; `listOffers({ customerId })`, `getOfferContext(customerId)`, `createOffer(body)`, `withdrawOffer(id)`, `notifyOffer(id)`; `listStorageTasks()`, `getStorageOpenCount()`, `markStorageDone(id)`; `getFunktionen(customerId)`, `setNewsFeature(customerId, { projectId, action, note })`; `getCustomerAboStatus(customerId)`.
|
||||
- Produces: `useSidebarCounts() -> { chat, credits, storage }` (60 s, sichtbarer Tab, Fehler behalten Wert); `buildNavLinks({ isAdmin, pulseLevel, counts })` mit Kunden-Kindern `chat` (count `counts.chat`), `gutschriften` (`/customers/gutschriften`, `counts.credits`), `angebote` (`/customers/angebote`, kein count), `speicher` (`/customers/email-speicher`, Label „E-Mail-Speicher“, `counts.storage`).
|
||||
- Produces in `money.js`: `formatCents(cents) -> '12,34 €'`, `parseEuroToCents(text) -> number|null`.
|
||||
- Produces in `creditFormat.js` (aus `public/admin.js:535-906`): `CREDIT_STATUS` (Pill-Text und Ton je Status, admin.js `creditStatusPill`), `historyLabel(type)`, `calcLine(calc)` (`Rechnungszeitraum … → Vorschlag …`), `decisionError(action, note) -> string|null` (Notiz ≥ 5 Zeichen bei question/reject, Texte wie admin.js:845-857), `decisionConfirm(action, amountCents, customerName) -> string` (admin.js:858-863), `decisionMessage(action, result) -> string` (admin.js:873-881), `applyMessage(result)`, `BILLING_MONTHS = [1, 3, 6, 12, 24, 36]`.
|
||||
- Produces in `offerFormat.js` (aus `public/admin.js:984-1317`): `addMonthsYmd`, `offerDiscountEstimate`, `discountLine(offer)` (admin.js:1034-1057), `offerPreviewText`, `evaluateOfferForm(context, form) -> { errors: string[], running: boolean, saveLabel: string, previewLines: string[] }` (Regeln admin.js `renderOfferPreview` 1155-1205), `offerConfirm(context, form, preview)` (admin.js:1229), `createdMessage(result)`.
|
||||
- Produces in `storageFormat.js` (aus `public/admin.js:908-982`): `storageLabel(units)`, `summaryText({ allocatedGb, packageGb, warnGb, packageName })`, `taskText(task)`, `orderedText(task)`, `failedLine(event)`.
|
||||
|
||||
- [ ] **Step 1: Failing tests** (Erwartungswerte wortgleich aus admin.js; je Modul mindestens: alle Status-/Verlaufs-Texte, eine vollständige Rechenzeile, beide Validierungsfehler, alle drei Bestätigungsfragen, Erfolgsmeldung mit gebucht/nicht gebucht und Mail gesendet/fehlgeschlagen; Angebots-Vorschau mit/ohne laufendem Abo, offenes Angebot blockiert, Preis ≥ regulär blockiert, Rabattende geschätzt und bekannt; Speicher-Texte einrichten/periodenende/teilweise/gescheitert; `formatCents(123456) === '1.234,56 €'`, `parseEuroToCents('12,5') === 1250`, `parseEuroToCents('') === null`)
|
||||
|
||||
```js
|
||||
test('buildNavLinks: vier Unterpunkte unter Kunden mit Zahlen', () => {
|
||||
const kunden = buildNavLinks({ isAdmin: false, pulseLevel: 'ok', counts: { chat: 3, credits: 1, storage: 2 } }).find((l) => l.key === 'customers')
|
||||
assert.deepEqual(kunden.children.map((c) => [c.key, c.label, c.href, c.count]), [
|
||||
['chat', 'Chat', '/customers/chat', 3],
|
||||
['gutschriften', 'Gutschriften', '/customers/gutschriften', 1],
|
||||
['angebote', 'Abo-Angebote', '/customers/angebote', undefined],
|
||||
['speicher', 'E-Mail-Speicher', '/customers/email-speicher', 2],
|
||||
])
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2:** Run `node --test tests/unit/*.test.mjs` → FAIL
|
||||
- [ ] **Step 3:** Implementieren (Icons in `Navbar.jsx`: Gutschriften `IconReceiptRefund`, Angebote `IconDiscount`, E-Mail-Speicher `IconMailCog` – vorher in `node_modules/@tabler/icons-react/dist/esm/icons/` prüfen, sonst nächstliegendes vorhandenes Icon)
|
||||
- [ ] **Step 4:** Run `npm test` → PASS
|
||||
- [ ] **Step 5: Commit** — „Team-API, Sidebar-Zaehler und Format-Hilfen fuer Gutschriften, Angebote und E-Mail-Speicher“
|
||||
|
||||
### Task 5: Abo-Status, Funktionen-Reiter und E-Mail-Speicher
|
||||
|
||||
**Files:**
|
||||
- Create: `src/components/customer/AboStatusCard.jsx`, `src/components/customer/CustomerFunktionen.jsx`, `src/pages/StorageTasksPage.jsx`
|
||||
- Modify: `src/App.jsx` (Route `/customers/email-speicher`), `src/lib/customerTabs.js` (+ Test: `abo`, `gutschriften`, `funktionen` an ihren Plätzen; `abo` rendert vorerst nur `AboStatusCard`, Angebote folgen in Task 7), `src/pages/CustomerDetailPage.jsx`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Task 4.
|
||||
- Produces: `AboStatusCard({ customerId })`: Zeilen „Abo“ (Wartungs-Abo/Hosting-Abo/kein Abo, Status-Pille), „Leistungsbeginn“, „Nächste Abbuchung“ (nur aktiv), „Gekündigt zum“ (nur `cancelAt`), „Zusatzspeicher“ (`storageLabel`).
|
||||
- Produces: `CustomerFunktionen({ customerId })` wie admin.js:188-296 (Tabelle Projekt | News | Ticket | Aktionen, Pillen Aus/Bestellt/Einrichtung/Aktiv/Abgeschaltet, Notiz gekürzt auf 60 Zeichen mit Titel, „Ticket fehlt“, Knöpfe „Aktivieren (ohne Zahlung)“/„Abschalten“ mit Inline-Bestätigung samt Notizfeld (max. 120) und „Ja“/„Abbrechen“, Status „Gespeichert.“).
|
||||
- Produces: `StorageTasksPage` wie admin.js:908-982 (Zusammenfassung mit Warnfarbe ab `warnGb`, Tabelle Fällig | Kunde | Aufgabe in konsoleH | Bestellt | „Erledigt“, Rückfrage „Stripe-Menge von Hand korrigiert?“ nur bei `partial`, Liste „Gescheiterte Änderungen (letzte 30 Tage)“, Erklärtext aus `public/admin.html:299`); Kundenname verlinkt auf `/customers/<id>`.
|
||||
|
||||
- [ ] **Step 1:** `tests/unit/customerTabs.test.mjs` auf die volle Reiterfolge anpassen → Run → FAIL
|
||||
- [ ] **Step 2: Playwright-Checks schreiben** (Harness wie Etappe 2): E-Mail-Speicher-Zusammenfassung mit Warnfarbe ab Schwelle, Aufgabentexte einrichten/periodenende, „Erledigt“ ruft `POST …/done` (bei `partial` erst nach Rückfrage), gescheiterte Liste; Funktionen-Reiter: „Aktivieren (ohne Zahlung)“ → Inline-Bestätigung mit Notiz → Body `{ projectId, action: 'activate', note }` → Zeile aktualisiert, „Gespeichert.“; Abo-Status-Karte für aktives Abo und „kein Abo“ → Run → FAIL
|
||||
- [ ] **Step 3:** Implementieren; Run Harness → ok; `npm test` → PASS
|
||||
- [ ] **Step 4: Commit** — „Kundenseite: Abo-Status und Funktionen; Seite E-Mail-Speicher“
|
||||
|
||||
### Task 6: Gutschriften (Sammelliste, Detail, Kunden-Reiter)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/components/credits/CreditList.jsx`, `src/components/credits/CreditDetail.jsx`, `src/pages/CreditsPage.jsx`
|
||||
- Modify: `src/App.jsx` (Route `/customers/gutschriften`), `src/pages/CustomerDetailPage.jsx` (Reiter `gutschriften` mit `customerId`-Filter, `&antrag=` öffnet Detail)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Task 4 (`listCredits`, `getCredit`, `calcCredit`, `decideCredit`, `applyCredit`, `creditFormat`), `KundenansichtButton` (statt „Als Kunde ansehen“).
|
||||
- Produces: `CreditList({ customerId?, selectedId, onSelect })` (Filter „Offen“/„Alle“, Spalten und Leertexte wie admin.js; Knopf „Prüfen“/„Ansehen“); `CreditDetail({ id, onClose, onChanged })` mit allen Abschnitten aus admin.js:684-796 (Nachweise als Links auf `file.url`, neuer Tab), Formular mit 4 Prüfhaken, Rechner (`calcCredit` 300 ms entprellt, Gutschrift-Betrag folgt dem Vorschlag bis zur ersten eigenen Eingabe), Notiz (max. 4000), Knöpfe „Genehmigen & gutschreiben“ (erst bei 4 Haken und Betrag > 0) / „Rückfrage senden“ / „Ablehnen“ mit `decisionError` und `window.confirm(decisionConfirm(…))`; während der Anfrage alle drei Knöpfe gesperrt; Ergebnis über `decisionMessage`; entschiedene Anträge zeigen die Zusammenfassung und ggf. „Jetzt in Stripe gutschreiben“.
|
||||
- Produces: `CreditsPage` unter `/customers/gutschriften`: Liste links/oben, Detail daneben/darunter; `?antrag=<id>` öffnet direkt, Schließen entfernt den Parameter.
|
||||
|
||||
- [ ] **Step 1: Playwright-Checks schreiben** (Harness wie Etappe 2 im Scratchpad, Stripe-/Mail-Ergebnisse per gemockter Team-API): Liste „Offen“ zeigt Pillen „Zu prüfen“/„Rückfrage beim Kunden“; Detail per `?antrag=`; Genehmigen gesperrt bis 4 Haken; Rechner-Zeile nach Eingabe; Rückfrage ohne Notiz → Fehlertext ohne Anfrage; Genehmigen → confirm-Text exakt, Body `{ action: 'approve', amountCents, checks, … }`, Meldung „Genehmigt. Das Guthaben ist in Stripe gebucht. Die E-Mail an den Kunden ist raus.“; Doppelklick → genau eine POST-Anfrage; 409 → Servertext sichtbar, Knöpfe wieder frei; Nachweis-Link zeigt auf `file.url`; Kunden-Reiter zeigt nur Anträge dieses Kunden; 390 px ohne waagerechtes Scrollen.
|
||||
- [ ] **Step 2:** Run Harness → FAIL (Seiten fehlen)
|
||||
- [ ] **Step 3:** Implementieren
|
||||
- [ ] **Step 4:** Harness → alle ok; `npm test` → PASS
|
||||
- [ ] **Step 5: Commit** — „Gutschriften im Ticketsystem: Sammelliste, Pruefung und Entscheidung, Kunden-Reiter“
|
||||
|
||||
### Task 7: Abo & Angebote (Sammelliste, Formular, Kunden-Reiter)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/components/offers/OfferList.jsx`, `src/components/offers/OfferForm.jsx`, `src/pages/OffersPage.jsx`
|
||||
- Modify: `src/App.jsx` (Route `/customers/angebote`), `src/pages/CustomerDetailPage.jsx` (Reiter `abo`: `AboStatusCard`, darunter `OfferList customerId` und „Neues Angebot“ mit fest gewähltem Kunden)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Task 4 (`listOffers`, `getOfferContext`, `createOffer`, `withdrawOffer`, `notifyOffer`, `offerFormat`, `customerPickLabel`/`findCustomerByLabel` aus `chatFormat.js`), `useCustomers`.
|
||||
- Produces: `OfferList({ customerId?, reloadKey })` (Filter „Offen & aktiv“/„Alle“, Spalten, Statuszelle mit Pillen, Stripe-Links, Mailstatus, Knöpfe „E-Mail senden“/„Erneut senden“ und „Zurückziehen“ mit Bestätigungen/Meldungen wie admin.js); `OfferForm({ fixedCustomerId?, onCreated, onCancel })`: Felder und Vorgaben wie admin.js:1256-1311; Kontext genau einmal je aufgelöster Kunden-ID laden, veraltete Antworten verwerfen, beim Wechsel sofort leeren; Vorschau/Speichern über `evaluateOfferForm`, Speichern erst mit Kontext des aktuellen Kunden; `window.confirm(offerConfirm(…))`; nach dem Anlegen Meldung `createdMessage`.
|
||||
- Produces: `OffersPage` unter `/customers/angebote` mit Knopf „Neues Angebot“.
|
||||
|
||||
- [ ] **Step 1: Playwright-Checks schreiben**: Liste „Offen & aktiv“ filtert, Rabattzeile; „Neues Angebot“: Kunde per eindeutiger Beschriftung, Kontext genau einmal geladen; Kundenwechsel mit verzögertem Kontext von A → Vorschau zeigt nie A, „Angebot anlegen“ gesperrt bis Kontext B da; offenes Angebot blockiert mit rotem Hinweis; laufendes Abo → Knopftext „Angebot anlegen und aufs laufende Abo anwenden“; confirm-Text exakt; Body `{ customerId, productKey, monthlyPriceCents, months, validUntil, note, internalNote, notifyCustomer }`; Zurückziehen/Erneut senden mit Bestätigung; Kunden-Reiter `abo` zeigt Abo-Status und nur eigene Angebote; 390 px ohne waagerechtes Scrollen.
|
||||
- [ ] **Step 2:** Run → FAIL · **Step 3:** Implementieren · **Step 4:** Harness → ok; `npm test` → PASS
|
||||
- [ ] **Step 5: Commit** — „Abo-Angebote im Ticketsystem: Sammelliste, neues Angebot, Kunden-Reiter Abo & Angebote“
|
||||
|
||||
---
|
||||
|
||||
## Abschluss
|
||||
|
||||
### Task 8: Gesamtprüfung und Übergabe
|
||||
|
||||
- [ ] Portal `npm test` grün; neue Tests unter Node 20 grün. WOMS `npm test` grün; Vite-Build erfolgreich; alle Harness-Checks (Etappe 2 und 3) grün.
|
||||
- [ ] Abschlussreview beider Branches (frischer Reviewer, 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): neue Team-Pfade ohne JWT 401, Admin-Panel unverändert; 3. WOMS `git -C /home/webklar/apps/ticket.webklar.com/dev-admin-umzug push origin feature/admin-umzug:test`; 4. Live-Check: Bundle enthält „Genehmigen & gutschreiben“, „Neues Angebot“, „Gescheiterte Änderungen“; 5. Nutzer-Test mit einem echten offenen Antrag/Angebot erst nach Rücksprache (echte Stripe-Buchungen!).
|
||||
@@ -20,7 +20,7 @@ function sendError(res, err, fallback) {
|
||||
return res.status(status).json({ error: err.message || fallback })
|
||||
}
|
||||
|
||||
offersAdminRouter.get('/', async (_req, res) => {
|
||||
offersAdminRouter.get('/', async (req, res) => {
|
||||
try {
|
||||
const [docs, customers, subs] = await Promise.all([
|
||||
listOffers(),
|
||||
@@ -30,8 +30,12 @@ offersAdminRouter.get('/', async (_req, res) => {
|
||||
])
|
||||
const byId = new Map(customers.map((c) => [c.$id, c]))
|
||||
const subById = new Map(subs.map((s) => [s.$id, s]))
|
||||
// ?customerId= (Kunden-Reiter "Abo & Angebote" im Ticketsystem)
|
||||
const customerId = typeof req.query.customerId === 'string' ? req.query.customerId : ''
|
||||
return res.json({
|
||||
offers: docs.map((d) => adminOfferView(d, byId.get(d.customerId), subById.get(d.stripeSubscriptionId))),
|
||||
offers: docs
|
||||
.filter((d) => !customerId || d.customerId === customerId)
|
||||
.map((d) => adminOfferView(d, byId.get(d.customerId), subById.get(d.stripeSubscriptionId))),
|
||||
})
|
||||
} catch (err) {
|
||||
return sendError(res, err, 'Angebote konnten nicht geladen werden')
|
||||
|
||||
@@ -101,6 +101,15 @@ async function reconcileOpenSwitches() {
|
||||
}
|
||||
}
|
||||
|
||||
// Zahl offener Aufgaben fuer die Sidebar des Ticketsystems: ohne Wechsel-Abgleich (kein Stripe), jede Minute abgefragt
|
||||
storageTasksAdminRouter.get('/open-count', async (_req, res) => {
|
||||
try {
|
||||
return res.json({ open: (await listOpenStorageTasks()).length })
|
||||
} catch {
|
||||
return res.json({ open: 0 })
|
||||
}
|
||||
})
|
||||
|
||||
storageTasksAdminRouter.get('/', async (_req, res) => {
|
||||
try {
|
||||
await reconcileOpenSwitches()
|
||||
|
||||
@@ -2,6 +2,8 @@ import { Router } from 'express'
|
||||
import { config } from '../config.js'
|
||||
import { getSessionCustomerId, requireSession } from '../middleware/session.js'
|
||||
import { getDocument, listDocuments, Query } from '../services/appwriteAdmin.js'
|
||||
import { signFileLink } from '../services/signedLinks.js'
|
||||
import { withLocks } from '../services/keyedLock.js'
|
||||
import {
|
||||
answerQuestion,
|
||||
applyTakeoverCredit,
|
||||
@@ -124,7 +126,10 @@ takeoverAdminRouter.get('/', async (req, res) => {
|
||||
])
|
||||
const byId = new Map(customers.map((c) => [c.$id, c]))
|
||||
const onlyOpen = req.query.status !== 'all'
|
||||
// ?customerId= (Kunden-Reiter im Ticketsystem); openCount zaehlt weiter alle offenen Antraege
|
||||
const customerId = typeof req.query.customerId === 'string' ? req.query.customerId : ''
|
||||
const requests = docs
|
||||
.filter((d) => !customerId || d.customerId === customerId)
|
||||
.filter((d) => !onlyOpen || d.status === STATUS.open || d.status === STATUS.question)
|
||||
.map((d) => {
|
||||
const c = byId.get(d.customerId)
|
||||
@@ -173,9 +178,21 @@ takeoverAdminRouter.get('/open-count', async (_req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
// Nachweise bekommen einen signierten Link (15 min) auf den Team-Eingang: das Ticketsystem oeffnet sie ohne
|
||||
// Authorization-Header (routes/team/files.js). Bei jedem Oeffnen des Antrags neu signiert.
|
||||
function withSignedFileUrls(detail, now = Date.now()) {
|
||||
const id = detail.request.id
|
||||
const base = `${config.publicBaseUrl}/api/team/takeover-credits/${encodeURIComponent(id)}/files`
|
||||
const files = detail.request.files.map((file) => {
|
||||
const { exp, sig } = signFileLink({ kind: 'takeover', customerId: id, id: file.id, now })
|
||||
return { ...file, url: `${base}/${encodeURIComponent(file.id)}?exp=${exp}&sig=${sig}` }
|
||||
})
|
||||
return { ...detail, request: { ...detail.request, files } }
|
||||
}
|
||||
|
||||
takeoverAdminRouter.get('/:id', async (req, res) => {
|
||||
try {
|
||||
return res.json(await getAdminDetail(req.params.id))
|
||||
return res.json(withSignedFileUrls(await getAdminDetail(req.params.id)))
|
||||
} catch (err) {
|
||||
return sendError(res, err, 'Antrag konnte nicht geladen werden')
|
||||
}
|
||||
@@ -198,7 +215,8 @@ takeoverAdminRouter.get('/:id/files/:fileId', async (req, res) => {
|
||||
takeoverAdminRouter.post('/:id/decision', async (req, res) => {
|
||||
try {
|
||||
const admin = { name: req.session.adminName || req.session.name, email: req.session.adminEmail || req.session.email }
|
||||
const result = await decideTakeoverRequest(req.params.id, req.body || {}, admin)
|
||||
// Je Antrag nur eine Entscheidung gleichzeitig: wer zweitens kommt, sieht den neuen Status (409 "bereits entschieden")
|
||||
const result = await withLocks([`takeover:${req.params.id}`], () => decideTakeoverRequest(req.params.id, req.body || {}, admin))
|
||||
return res.json({
|
||||
success: true,
|
||||
status: result.doc.status,
|
||||
@@ -215,8 +233,7 @@ takeoverAdminRouter.post('/:id/decision', async (req, res) => {
|
||||
/** Genehmigte, aber noch nicht verrechnete Gutschrift erneut an Stripe senden. */
|
||||
takeoverAdminRouter.post('/:id/apply', async (req, res) => {
|
||||
try {
|
||||
const doc = await getTakeoverRequest(req.params.id)
|
||||
const result = await applyTakeoverCredit(doc)
|
||||
const result = await withLocks([`takeover:${req.params.id}`], async () => applyTakeoverCredit(await getTakeoverRequest(req.params.id)))
|
||||
return res.json({ success: result.applied, applied: result.applied, reason: result.reason || '' })
|
||||
} catch (err) {
|
||||
return sendError(res, err, 'Verrechnung fehlgeschlagen')
|
||||
|
||||
@@ -2,6 +2,9 @@ import { Router } from 'express'
|
||||
import { config } from '../../config.js'
|
||||
import { listViewLog } from '../../services/customerViewLog.js'
|
||||
import { getPortalAccessStatus } from '../../services/portalAccessStatus.js'
|
||||
import { PRODUCT_KEYS } from '../../services/products.js'
|
||||
import { ACTIVE_STATUSES, getCurrentSubscription } from '../../services/stripeBilling.js'
|
||||
import { serviceStartOf } from '../../services/takeoverCredits.js'
|
||||
import { createStaffPreviewToken } from '../../services/staffPreviewTokens.js'
|
||||
|
||||
// Kunden im Team-Eingang (/api/team/customers), requireStaffJwt sitzt in routes/team/index.js
|
||||
@@ -27,6 +30,29 @@ router.get('/:id/protokoll', async (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
// Abo-Status (Kunden-Reiter "Abo & Angebote", Stunden-Pruefung am Ticket): productKey/active wie die Admin-Route
|
||||
// /api/admin/customers/:id/abo, dazu Laufzeit und Speicher; nur der Appwrite-Spiegel, kein Stripe
|
||||
router.get('/:id/abo', async (req, res) => {
|
||||
try {
|
||||
const sub = await getCurrentSubscription(req.params.id)
|
||||
if (!sub) {
|
||||
return res.json({ productKey: null, active: false, status: null, storageUnits: 0, startedAt: null, serviceStart: null, currentPeriodEnd: null, cancelAt: null })
|
||||
}
|
||||
return res.json({
|
||||
productKey: PRODUCT_KEYS.includes(sub.productKey) ? sub.productKey : 'wartung',
|
||||
active: ACTIVE_STATUSES.has(sub.status),
|
||||
status: sub.status || null,
|
||||
storageUnits: Number(sub.storageUnits) || 0,
|
||||
startedAt: sub.startedAt || null,
|
||||
serviceStart: serviceStartOf(sub),
|
||||
currentPeriodEnd: sub.currentPeriodEnd || null,
|
||||
cancelAt: sub.cancelAt || null,
|
||||
})
|
||||
} catch (err) {
|
||||
return res.status(err.status || 500).json({ error: err.message || 'Abo konnte nicht geladen werden' })
|
||||
}
|
||||
})
|
||||
|
||||
// Kundenansicht oeffnen: Einmal-Link (60 s), den das Ticketsystem in einem neuen Tab aufruft
|
||||
router.post('/:id/kundenansicht', async (req, res) => {
|
||||
try {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Router } from 'express'
|
||||
import { openAttachment, sendAttachment } from '../../services/chatAttachments.js'
|
||||
import { verifyFileLink } from '../../services/signedLinks.js'
|
||||
import { getTakeoverRequest, readUpload } from '../../services/takeoverCredits.js'
|
||||
|
||||
/**
|
||||
* Anhaenge mit signiertem Link (?exp=&sig=, 15 min) ohne JWT ausliefern: <img>, <audio> und Downloads im
|
||||
* Chat-Anhaenge und Gutschrift-Nachweise mit signiertem Link (?exp=&sig=, 15 min) ohne JWT ausliefern: <img>, <audio> und Downloads im
|
||||
* Ticketsystem koennen keinen Authorization-Header tragen. Ohne sig geht es weiter zur JWT-Pruefung.
|
||||
* Eingehaengt in routes/team/index.js VOR requireStaffJwt.
|
||||
*/
|
||||
@@ -23,4 +24,23 @@ router.get('/chats/:customerId/attachments/:id', async (req, res, next) => {
|
||||
}
|
||||
})
|
||||
|
||||
// Nachweise einer Uebernahmegutschrift (wie takeoverAdminRouter GET /:id/files/:fileId), signiert ueber die Antrags-ID
|
||||
router.get('/takeover-credits/:id/files/:fileId', async (req, res, next) => {
|
||||
if (req.query.sig === undefined) return next()
|
||||
const { id, fileId } = req.params
|
||||
if (!verifyFileLink({ kind: 'takeover', customerId: id, id: fileId, exp: req.query.exp, sig: String(req.query.sig) })) {
|
||||
return res.status(403).json({ error: 'Link abgelaufen. Bitte den Antrag neu öffnen.' })
|
||||
}
|
||||
try {
|
||||
const { file, content } = await readUpload(await getTakeoverRequest(id), fileId)
|
||||
res.setHeader('Content-Type', file.type)
|
||||
res.setHeader('Content-Disposition', `inline; filename*=UTF-8''${encodeURIComponent(file.name)}`)
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff')
|
||||
res.setHeader('Cache-Control', 'private, no-store')
|
||||
return res.send(content)
|
||||
} catch (err) {
|
||||
return res.status(err.status || 500).send(err.message || 'Datei nicht verfügbar')
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -1,6 +1,10 @@
|
||||
import { Router } from 'express'
|
||||
import { requireStaffJwt } from '../../middleware/staffJwt.js'
|
||||
import { chatAdminRouter } from '../chatAdmin.js'
|
||||
import { funktionenAdminRouter } from '../admin/funktionenAdmin.js'
|
||||
import { offersAdminRouter } from '../offers.js'
|
||||
import { storageTasksAdminRouter } from '../storageTasks.js'
|
||||
import { takeoverAdminRouter } from '../takeoverCredits.js'
|
||||
import customersRoutes from './customers.js'
|
||||
import filesRoutes from './files.js'
|
||||
import previewRoutes from './preview.js'
|
||||
@@ -13,5 +17,10 @@ router.use(requireStaffJwt)
|
||||
router.use('/customers', customersRoutes)
|
||||
router.use('/preview-link', previewRoutes)
|
||||
router.use(chatAdminRouter)
|
||||
// Gutschriften, Abo-Angebote, E-Mail-Speicher, Funktionen: dieselben Router wie im Admin-Panel
|
||||
router.use('/takeover-credits', takeoverAdminRouter)
|
||||
router.use('/offers', offersAdminRouter)
|
||||
router.use('/storage-tasks', storageTasksAdminRouter)
|
||||
router.use('/funktionen', funktionenAdminRouter)
|
||||
|
||||
export default router
|
||||
@@ -347,7 +347,7 @@ function withHistory(doc, entry) {
|
||||
return json
|
||||
}
|
||||
|
||||
const serviceStartOf = (sub) => sub?.trialEnd || sub?.startedAt || null
|
||||
export const serviceStartOf = (sub) => sub?.trialEnd || sub?.startedAt || null
|
||||
const isActiveSub = (sub) => Boolean(sub && ACTIVE_STATUSES.has(sub.status))
|
||||
// Produkt des Abos: Altbestand ohne productKey und unbekannte Schluessel gelten als Wartungs-Abo
|
||||
const productKeyOf = (sub) => (PRODUCT_KEYS.includes(sub?.productKey) ? sub.productKey : 'wartung')
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { test, after } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'
|
||||
import { startAppwriteMock } from './support/appwriteMock.js'
|
||||
import { startFakeStripe } from './support/fakeStripe.js'
|
||||
import { useTestEnv } from './support/testEnv.js'
|
||||
import { schemaKeys } from './support/schema.js'
|
||||
import { mailSink } from './support/mailSink.js'
|
||||
|
||||
// Etappe 3: Gutschriften, Abo-Angebote, E-Mail-Speicher und Funktionen ueber den Team-Eingang (/api/team, JWT)
|
||||
process.env.TAKEOVER_UPLOAD_DIR = mkdtempSync(path.join(os.tmpdir(), 'team-takeover-'))
|
||||
const aw = await startAppwriteMock({
|
||||
schema: {
|
||||
subscriptions: schemaKeys('subscriptions'),
|
||||
contractEvents: schemaKeys('contractEvents'),
|
||||
takeoverCredits: schemaKeys('takeoverCredits'),
|
||||
aboOffers: schemaKeys('aboOffers'),
|
||||
portalFeatures: schemaKeys('portalFeatures'),
|
||||
},
|
||||
})
|
||||
const st = await startFakeStripe()
|
||||
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
|
||||
const { startTestApp, rawGet, post } = await import('./support/testApp.js')
|
||||
const { seedFixtures, consent } = await import('./support/fixtures.js')
|
||||
const { setMailTransportForTests } = await import('../server/services/mailer.js')
|
||||
const { recordContractEvent } = await import('../server/services/contractEvents.js')
|
||||
const { default: teamRoutes } = await import('../server/routes/team/index.js')
|
||||
const app = await startTestApp({ '/api/team': teamRoutes })
|
||||
setMailTransportForTests(mailSink())
|
||||
after(() => {
|
||||
setMailTransportForTests(null)
|
||||
return Promise.all([app.close(), aw.close(), st.close()])
|
||||
})
|
||||
|
||||
seedFixtures(aw)
|
||||
aw.seed('employees', { $id: 'e_staff2', userId: 'u_staff2', displayName: 'Andrej', email: 'andrej@webklar.com' })
|
||||
const credit = (id, customerId, status) =>
|
||||
aw.seed('takeoverCredits', {
|
||||
$id: id, customerId, email: 'k@example.invalid', services: JSON.stringify(['hosting']), provider: 'IONOS', domain: 'kunde.de',
|
||||
invoiceAmountCents: 12000, billingMonths: 12, paidUntil: '2026-12-31T00:00:00.000Z', message: 'Bitte verrechnen',
|
||||
files: '[]', status, history: '[]', takeoverFrom: '2026-10-01T00:00:00.000Z',
|
||||
})
|
||||
credit('tc1', 'c1', 'offen')
|
||||
credit('tc2', 'c1', 'abgelehnt')
|
||||
credit('tc3', 'c2', 'offen')
|
||||
await recordContractEvent({
|
||||
id: 'speicher-c2', customerId: 'c2', subscriptionId: 'sub_h2', type: 'speicher', productKey: 'hosting',
|
||||
details: { kind: 'einrichten', fromUnits: 0, toUnits: 2, effectiveAt: new Date().toISOString(), totalGb: 15, monthlyCents: 200 },
|
||||
consent, status: 'offen', dueAt: new Date().toISOString(),
|
||||
})
|
||||
aw.seed('websiteProjects', { $id: 'pa1', customerId: 'c1', projectName: 'Team-Test' })
|
||||
|
||||
const staff = { headers: { authorization: 'Bearer jwt-staff' } }
|
||||
|
||||
test('alle vier Bereiche mit jwt-staff erreichbar (kein Admin-Label noetig)', async () => {
|
||||
for (const p of ['/api/team/takeover-credits', '/api/team/offers', '/api/team/storage-tasks', '/api/team/funktionen/c1']) {
|
||||
assert.equal((await rawGet(p, staff)).status, 200, p)
|
||||
}
|
||||
})
|
||||
|
||||
test('Kundenfilter: Gutschriften nur des Kunden, openCount weiter ueber alle', async () => {
|
||||
const all = (await rawGet('/api/team/takeover-credits?status=all&customerId=c1', staff)).body
|
||||
assert.deepEqual(all.requests.map((r) => r.id).sort(), ['tc1', 'tc2'])
|
||||
assert.equal(all.openCount, 2)
|
||||
const open = (await rawGet('/api/team/takeover-credits?customerId=c2', staff)).body
|
||||
assert.deepEqual(open.requests.map((r) => r.id), ['tc3'])
|
||||
})
|
||||
|
||||
test('Angebot anlegen als Mitarbeiter, Kundenfilter der Angebotsliste', async () => {
|
||||
const res = await post('/api/team/offers', { customerId: 'c2', productKey: 'wartung', monthlyPriceCents: 2500, months: 2, notifyCustomer: false }, staff)
|
||||
assert.equal(res.status, 201, JSON.stringify(res.body))
|
||||
assert.equal(res.body.offer.createdBy, 'Andrej <andrej@webklar.com>')
|
||||
assert.deepEqual((await rawGet('/api/team/offers?customerId=c2', staff)).body.offers.map((o) => o.customerId), ['c2'])
|
||||
assert.deepEqual((await rawGet('/api/team/offers?customerId=c1', staff)).body.offers, [])
|
||||
})
|
||||
|
||||
test('E-Mail-Speicher open-count ohne Stripe-Abgleich', async () => {
|
||||
const before = st.calls.length
|
||||
assert.deepEqual((await rawGet('/api/team/storage-tasks/open-count', staff)).body, { open: 1 })
|
||||
assert.equal(st.calls.length, before)
|
||||
})
|
||||
|
||||
test('Mitarbeiter aus dem JWT: Rueckfrage, Erledigt und Funktionen-Notiz tragen Andrej', async () => {
|
||||
const q = await post('/api/team/takeover-credits/tc3/decision', { action: 'question', note: 'Bitte Rechnung schicken' }, staff)
|
||||
assert.equal(q.status, 200, JSON.stringify(q.body))
|
||||
const detail = (await rawGet('/api/team/takeover-credits/tc3', staff)).body
|
||||
assert.equal(detail.request.history.at(-1).by, 'Andrej <andrej@webklar.com>')
|
||||
const done = await post('/api/team/storage-tasks/speicher-c2/done', {}, staff)
|
||||
assert.equal(done.status, 200, JSON.stringify(done.body))
|
||||
assert.equal(done.body.task.doneBy, 'andrej@webklar.com')
|
||||
const news = await post('/api/team/funktionen/c1/news', { projectId: 'pa1', action: 'activate', note: 'Pilot' }, staff)
|
||||
assert.equal(news.status, 200)
|
||||
assert.match(news.body.projects[0].features.news.adminNote, /Andrej: activate – Pilot$/)
|
||||
})
|
||||
|
||||
test('ohne JWT 401 fuer alle neuen Pfade', async () => {
|
||||
for (const p of ['/api/team/takeover-credits', '/api/team/takeover-credits/open-count', '/api/team/offers', '/api/team/storage-tasks', '/api/team/storage-tasks/open-count', '/api/team/funktionen/c1']) {
|
||||
assert.equal((await rawGet(p)).status, 401, p)
|
||||
}
|
||||
})
|
||||
|
||||
// Task 2: Nachweise per signiertem Link (15 min) – Links im Ticketsystem tragen keinen Authorization-Header
|
||||
const FILE_ID = '0b9c2f0e-6d6e-4c1a-9a51-6a3a9d1b2c3d'
|
||||
aw.seed('takeoverCredits', {
|
||||
// eigener Kunde und entschieden, damit Filter und openCount der anderen Tests gleich bleiben
|
||||
$id: 'tc9', customerId: 'c4', email: 'k@example.invalid', services: JSON.stringify(['hosting']), provider: 'IONOS', domain: 'kunde.de',
|
||||
invoiceAmountCents: 12000, billingMonths: 12, paidUntil: '2026-12-31T00:00:00.000Z', message: 'x', status: 'abgelehnt', history: '[]',
|
||||
takeoverFrom: '2026-10-01T00:00:00.000Z',
|
||||
files: JSON.stringify([{ id: FILE_ID, name: 'rechnung.pdf', type: 'application/pdf', ext: 'pdf', size: 9, uploadedAt: '2026-10-01T08:00:00.000Z' }]),
|
||||
})
|
||||
mkdirSync(path.join(process.env.TAKEOVER_UPLOAD_DIR, 'tc9'), { recursive: true })
|
||||
writeFileSync(path.join(process.env.TAKEOVER_UPLOAD_DIR, 'tc9', `${FILE_ID}.pdf`), '%PDF-1.4\n')
|
||||
|
||||
test('Nachweis: signierter Link ohne JWT 200 mit Inhalt, fremder Antrag/abgelaufen 403, ohne sig 401', async (t) => {
|
||||
const detail = (await rawGet('/api/team/takeover-credits/tc9', staff)).body
|
||||
const url = detail.request.files[0].url
|
||||
assert.match(url, new RegExp(`^https://project\\.webklar\\.com/api/team/takeover-credits/tc9/files/${FILE_ID}\\?exp=\\d+&sig=[0-9a-f]{64}$`))
|
||||
const u = new URL(url)
|
||||
const rel = u.pathname + u.search
|
||||
const ok = await fetch(app.url + rel)
|
||||
assert.equal(ok.status, 200)
|
||||
assert.equal(ok.headers.get('content-type'), 'application/pdf')
|
||||
assert.equal(await ok.text(), '%PDF-1.4\n')
|
||||
assert.equal((await rawGet(rel.replace('/tc9/', '/tc1/'))).status, 403)
|
||||
assert.equal((await rawGet(u.pathname)).status, 401)
|
||||
const now = Date.now()
|
||||
t.mock.method(Date, 'now', () => now + 16 * 60 * 1000)
|
||||
const expired = await rawGet(rel)
|
||||
assert.equal(expired.status, 403)
|
||||
assert.equal(expired.body.error, 'Link abgelaufen. Bitte den Antrag neu öffnen.')
|
||||
})
|
||||
|
||||
// Abschlussreview: zwei Mitarbeiter entscheiden im selben Moment -> genau eine Entscheidung, eine Kunden-Mail
|
||||
test('gleichzeitige Entscheidungen: eine gewinnt, die andere bekommt 409', async () => {
|
||||
credit('tc7', 'c2', 'offen')
|
||||
const sink = mailSink()
|
||||
setMailTransportForTests(sink)
|
||||
const checks = { recipient: true, service: true, period: true, replaced: true }
|
||||
const figures = { invoiceAmountCents: 12000, billingMonths: 12, paidUntil: '2026-12-31', takeoverFrom: '2026-10-01' }
|
||||
const [a, b] = await Promise.all([
|
||||
post('/api/team/takeover-credits/tc7/decision', { action: 'approve', amountCents: 5000, checks, note: '', ...figures }, staff),
|
||||
post('/api/team/takeover-credits/tc7/decision', { action: 'reject', note: 'Leider nicht moeglich' }, staff),
|
||||
])
|
||||
assert.deepEqual([a.status, b.status].sort(), [200, 409], JSON.stringify([a.body, b.body]))
|
||||
const loser = a.status === 409 ? a : b
|
||||
assert.equal(loser.body.error, 'Über diesen Antrag wurde bereits entschieden.')
|
||||
assert.equal(sink.sent.length, 1, 'genau eine Kunden-Mail')
|
||||
})
|
||||
@@ -67,3 +67,21 @@ test('alle Team-Routen ohne JWT -> 401', async () => {
|
||||
assert.equal(res.status, 401, `${method} ${p}`)
|
||||
}
|
||||
})
|
||||
|
||||
// Etappe 3: Abo-Status fuer alle Mitarbeiter (Kunden-Reiter "Abo & Angebote", Stunden-Pruefung am Ticket)
|
||||
test('abo: Mitarbeiter ohne Admin-Label sieht den Abo-Status', async () => {
|
||||
aw.seed('subscriptions', {
|
||||
$id: 'sub_t', customerId: 'c3', status: 'active', productKey: 'wartung', storageUnits: 1,
|
||||
startedAt: '2026-09-01T10:00:00.000Z', trialEnd: '2026-09-15T00:00:00.000Z', currentPeriodEnd: '2026-11-01T00:00:00.000Z', cancelAt: null,
|
||||
})
|
||||
const res = await rawGet('/api/team/customers/c3/abo', auth)
|
||||
assert.equal(res.status, 200)
|
||||
assert.deepEqual(res.body, {
|
||||
productKey: 'wartung', active: true, status: 'active', storageUnits: 1, startedAt: '2026-09-01T10:00:00.000Z',
|
||||
serviceStart: '2026-09-15T00:00:00.000Z', currentPeriodEnd: '2026-11-01T00:00:00.000Z', cancelAt: null,
|
||||
})
|
||||
assert.deepEqual((await rawGet('/api/team/customers/c4/abo', auth)).body, {
|
||||
productKey: null, active: false, status: null, storageUnits: 0, startedAt: null, serviceStart: null, currentPeriodEnd: null, cancelAt: null,
|
||||
})
|
||||
assert.equal((await rawGet('/api/team/customers/c3/abo')).status, 401)
|
||||
})
|
||||
Reference in new issue
Block a user