Compare commits

..
5 Commits
10 changed files with 486 additions and 8 deletions

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!).
+6 -2
View File
@@ -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')
+9
View File
@@ -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()
+21 -4
View File
@@ -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')
+26
View File
@@ -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 {
+21 -1
View File
@@ -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
+9
View File
@@ -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
+1 -1
View File
@@ -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')
+150
View File
@@ -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')
})
+18
View File
@@ -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)
})