Hustler: Name in der Oberfläche, Budget höchstens 150 $/Monat (6 $/Tag) mit hartem Deckel

Kenso, 17.10.: "150 € im Monat max", "nenne es Hustler". Technische
Namen (workers*) bleiben.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-17 20:26:42 +00:00
1 parent 740089c997
commit 11e4280a92
8 files changed
+184 -156

No files matched your search

@@ -18,7 +18,7 @@
- Appwrite: Projekt `6a1058610003c5a13a05`, Datenbank `woms-database`, Rechte `[]`, `documentSecurity=false`, IDs ≤ 36 Zeichen, kein Feld `data`, JSON-Felder als Text, Zeiten ISO-8601 UTC.
- Dienst: Port 8092 nur intern (Basis `/api/workers`), in Compose weder `ports:` noch Traefik-Label; Zugang ausschließlich über nginx von `ticket.webklar.com`.
- Auth: `X-Appwrite-JWT` → Appwrite `GET /account`, 60 s Cache; Label `mitarbeiter` oder `admin` ist Pflicht (Kunden-Konten teilen das Appwrite-Projekt), `admin` für `/settings`.
- Budget: 10 $/Tag, 250 $/Monat, Warnung bei 80 %, Tag gestoppt bei 100 %; `mailColdPerDay` ≤ 3 ohne Ausnahme, `mailReplyCapPerDay` ≤ 15, `runsPerNight` 1–2.
- Budget [Kenso, 17.10.: max. 150 € im Monat]: `monthlyBudgetUsd` 150 $ (≈ 130 €, unter 150 €, solange 1 € ≥ 1 $), `dailyBudgetUsd` 6 $ (Ausreißer-Schutz), Warnung bei 80 %, Tag gestoppt bei 100 %; das Console-Workspace-Ausgabenlimit steht ebenfalls auf 150 $/Monat (Betriebsanleitung 4); `mailColdPerDay` ≤ 3 ohne Ausnahme, `mailReplyCapPerDay` ≤ 15, `runsPerNight` 1–2.
- Preise je MTok: Opus 4/20, Sonnet 2/10, Haiku 0,10/0,50 (Prompt > 100 000 Tokens: 0,50/2,50); Cache-Lesen ×0,05 (Haiku ×0,1), Cache-Schreiben 5 min ×1,25, 1 h ×2; Batch: Tokens ×0,5; Websuche 0,01 $ je Suche (nie rabattiert).
- Modelle `claude-opus-5-5`, `claude-sonnet-5-5`, `claude-haiku-5-5`; kein `temperature`/`top_p`/`top_k`; Suche (Werkzeuge) und Strukturierung (Schema) sind getrennte Aufrufe; `pause_turn` höchstens 5 Fortsetzungen.
- API-Budgetantworten (HTTP 400 „You have reached your specified … API usage limits“, HTTP 429 `enforced_spend_limit_reached`) ⇒ `BudgetStop('console')`, Tag gestoppt, nie wiederholen.
@@ -313,7 +313,7 @@ Die Ergänzungen E1–E11 dieses Plans sind in die Übersicht übernommen: siehe
- Test: `test/db.test.js`, `test/fakeDb.test.js`
**Interfaces:**
- Produces: `createDb({ endpoint, projectId, apiKey }, { sdk }) → { get(col,id) → doc|null, list(col,queries=[]) → doc[], listAll(col,queries=[]) → doc[], count(col,queries=[]) → number, create(col,data,id?) → doc, update(col,id,data) → doc, remove(col,id) }` (Übersicht §6 + E3); `ConflictError` (`.code = 409`); `DATABASE_ID = 'woms-database'`; `Query` (Re-Export); `GESPERRTE_SAMMLUNGEN = ['customers','workorders','websiteProjects','customerPortalAccess']` (E11): `create`/`update`/`remove` darauf werfen `Error('Schreiben in <col> ist für Workers gesperrt')` ohne SDK-Aufruf, Lesen bleibt erlaubt. `createFakeDb({ schema = SCHEMA, clock, loose = ['customers','workorders','websiteProjects','customerPortalAccess'] })` mit derselben Schnittstelle (inkl. Schreibsperre; nur `seed` darf in lose Sammlungen schreiben) plus `seed(col, over = {}, id?) → doc` (füllt Pflichtfelder mit Typ-Standardwerten), `all(col) → doc[]`, `calls` = Array `{ op:'get'|'list'|'listAll'|'count'|'create'|'update'|'remove', col, id?, queries? }` (`listAll` protokolliert nur den eigenen Aufruf, nicht die Seiten). `makeDeps({ now, …over }) → { db, clock, log }`.
- Produces: `createDb({ endpoint, projectId, apiKey }, { sdk }) → { get(col,id) → doc|null, list(col,queries=[]) → doc[], listAll(col,queries=[]) → doc[], count(col,queries=[]) → number, create(col,data,id?) → doc, update(col,id,data) → doc, remove(col,id) }` (Übersicht §6 + E3); `ConflictError` (`.code = 409`); `DATABASE_ID = 'woms-database'`; `Query` (Re-Export); `GESPERRTE_SAMMLUNGEN = ['customers','workorders','websiteProjects','customerPortalAccess']` (E11): `create`/`update`/`remove` darauf werfen `Error('Schreiben in <col> ist für Hustler gesperrt')` ohne SDK-Aufruf, Lesen bleibt erlaubt. `createFakeDb({ schema = SCHEMA, clock, loose = ['customers','workorders','websiteProjects','customerPortalAccess'] })` mit derselben Schnittstelle (inkl. Schreibsperre; nur `seed` darf in lose Sammlungen schreiben) plus `seed(col, over = {}, id?) → doc` (füllt Pflichtfelder mit Typ-Standardwerten), `all(col) → doc[]`, `calls` = Array `{ op:'get'|'list'|'listAll'|'count'|'create'|'update'|'remove', col, id?, queries? }` (`listAll` protokolliert nur den eigenen Aufruf, nicht die Seiten). `makeDeps({ now, …over }) → { db, clock, log }`.
- `sdk` = `{ Client, Databases, ID }` (Standard: `import * as sdk from 'node-appwrite'`).
- [ ] **Step 1: Tests schreiben.** `db.test.js` (Fake-SDK: `mkSdk(fns, calls)` baut `{ Client, ID, Databases }`, jede Methode aus `fns` protokolliert ihre Argumente in `calls`):
@@ -397,7 +397,7 @@ Die Ergänzungen E1–E11 dieses Plans sind in die Übersicht übernommen: siehe
})
```
- [ ] **Step 2: Fehlschlag sehen.** `cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/db.test.js test/fakeDb.test.js` → FAIL `ERR_MODULE_NOT_FOUND`.
- [ ] **Step 3: Implementieren.** `db.js`: `new sdk.Client().setEndpoint().setProject().setKey()`, `new sdk.Databases(client)`; `create(col, data, id = sdk.ID.unique())`; Fehler mit `code === 409` → `new ConflictError(...)` (Ursache als `cause`); `get` fängt nur 404; `listAll` hängt selbst `Query.limit(100)` und `Query.cursorAfter(lastId)` an, bis eine Seite < 100 liefert (Sicherung: höchstens 1000 Seiten), eigene `limit`/`cursor`-Queries → Fehler; `count` = `total` einer `limit(1)`-Abfrage. `create`/`update`/`remove` prüfen zuerst `GESPERRTE_SAMMLUNGEN` (Fehlertext `Schreiben in <col> ist für Workers gesperrt`). Kein Retry in dieser Schicht. `fakeDb.js`: Dokumente in Maps; jeder Aufruf wird in `calls` protokolliert (Form siehe Interfaces); Queries per `JSON.parse` der SDK-Query-Strings; `$id` (ohne ID `fake000001`…), `$createdAt`/`$updatedAt` aus `clock`; Prüfung gegen `SCHEMA` (unbekannte Felder – auch jeder `$…`-Schlüssel in `data`, Typen, `datetime` = von `Date.parse` lesbarer ISO-String, Offset erlaubt, Enum, Länge nach `max ?? size`, Pflichtfelder nur bei `create`, `unique`-Indizes → `ConflictError`; Meldungen nennen Feldnamen und Art: `Unknown attribute: <feld>`, `<feld>: enum …`, `<feld>: required`, `<feld>: size/length …`, `<feld>: ungültiger Wert`); Standard-Limit 25; nicht unterstützte Query-Methoden werfen `Query-Methode nicht unterstützt: <name>`; `listAll` ruft die eigene `list`-Paginierung. `helpers.js`: `makeDeps({ now = '2026-10-19T09:00:00.000Z', ...over })`.
- [ ] **Step 3: Implementieren.** `db.js`: `new sdk.Client().setEndpoint().setProject().setKey()`, `new sdk.Databases(client)`; `create(col, data, id = sdk.ID.unique())`; Fehler mit `code === 409` → `new ConflictError(...)` (Ursache als `cause`); `get` fängt nur 404; `listAll` hängt selbst `Query.limit(100)` und `Query.cursorAfter(lastId)` an, bis eine Seite < 100 liefert (Sicherung: höchstens 1000 Seiten), eigene `limit`/`cursor`-Queries → Fehler; `count` = `total` einer `limit(1)`-Abfrage. `create`/`update`/`remove` prüfen zuerst `GESPERRTE_SAMMLUNGEN` (Fehlertext `Schreiben in <col> ist für Hustler gesperrt`). Kein Retry in dieser Schicht. `fakeDb.js`: Dokumente in Maps; jeder Aufruf wird in `calls` protokolliert (Form siehe Interfaces); Queries per `JSON.parse` der SDK-Query-Strings; `$id` (ohne ID `fake000001`…), `$createdAt`/`$updatedAt` aus `clock`; Prüfung gegen `SCHEMA` (unbekannte Felder – auch jeder `$…`-Schlüssel in `data`, Typen, `datetime` = von `Date.parse` lesbarer ISO-String, Offset erlaubt, Enum, Länge nach `max ?? size`, Pflichtfelder nur bei `create`, `unique`-Indizes → `ConflictError`; Meldungen nennen Feldnamen und Art: `Unknown attribute: <feld>`, `<feld>: enum …`, `<feld>: required`, `<feld>: size/length …`, `<feld>: ungültiger Wert`); Standard-Limit 25; nicht unterstützte Query-Methoden werfen `Query-Methode nicht unterstützt: <name>`; `listAll` ruft die eigene `list`-Paginierung. `helpers.js`: `makeDeps({ now = '2026-10-19T09:00:00.000Z', ...over })`.
- [ ] **Step 4: Tests laufen lassen.** Gleicher Befehl → PASS.
- [ ] **Step 5: Commit.** `git add src/db.js test/support/fakeDb.js test/support/helpers.js test/db.test.js test/fakeDb.test.js && git commit -m "P1-T04: db.js mit ConflictError und schema-prüfende Fake-DB" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"`
@@ -619,7 +619,7 @@ Die Ergänzungen E1–E11 dieses Plans sind in die Übersicht übernommen: siehe
```js
test('Standardwerte §4.1 ohne gespeichertes Dokument', async () => {
const s = await getSettings(makeDeps())
assert.deepEqual([s.paused, s.dailyBudgetUsd, s.monthlyBudgetUsd, s.warnPct, s.mailColdPerDay, s.mailReplyCapPerDay], [false, 10, 250, 80, 3, 15])
assert.deepEqual([s.paused, s.dailyBudgetUsd, s.monthlyBudgetUsd, s.warnPct, s.mailColdPerDay, s.mailReplyCapPerDay], [false, 6, 150, 80, 3, 15]) // [Kenso, 17.10.: max. 150 € im Monat]: 150 $ ≈ 130 €, Tag 6 $
assert.deepEqual([s.sendWindowStart, s.sendWindowEnd, s.sendWeekdaysOnly, s.runsPerNight, s.candidatesPerRun], ['09:00', '16:00', true, 1, 10])
assert.deepEqual(s.activeRooms, ['netz', 'kleinanzeigen', 'portale', 'foren', 'aerzte', 'handwerk', 'selbst', 'laden'])
assert.deepEqual([s.connectedRooms, s.regions], [[], ['Rheinland-Pfalz']])
@@ -637,7 +637,8 @@ Die Ergänzungen E1–E11 dieses Plans sind in die Übersicht übernommen: siehe
test('Validierung: ungültige Patches werfen ValidationError (400, deutsch, mit Feldname) und ändern nichts', async () => {
const deps = makeDeps()
for (const [patch, re] of [[{ mailColdPerDay: 4 }, /mailColdPerDay/], [{ mailColdPerDay: 3.5 }, /mailColdPerDay/], [{ mailReplyCapPerDay: 16 }, /mailReplyCapPerDay/], [{ dailyBudgetUsd: 0 }, /dailyBudgetUsd/],
[{ monthlyBudgetUsd: -1 }, /monthlyBudgetUsd/], [{ warnPct: 101 }, /warnPct/], [{ sendWindowStart: '9:00' }, /sendWindowStart/], [{ sendWindowStart: '16:00', sendWindowEnd: '09:00' }, /sendWindow/],
[{ monthlyBudgetUsd: -1 }, /monthlyBudgetUsd/], [{ monthlyBudgetUsd: 151 }, /monthlyBudgetUsd/], [{ dailyBudgetUsd: 151 }, /dailyBudgetUsd/], // [Kenso, 17.10.: max. 150 € im Monat] als harte Grenze
[{ warnPct: 101 }, /warnPct/], [{ sendWindowStart: '9:00' }, /sendWindowStart/], [{ sendWindowStart: '16:00', sendWindowEnd: '09:00' }, /sendWindow/],
[{ runsPerNight: 3 }, /runsPerNight/], [{ activeRooms: ['nirgends'] }, /activeRooms/], [{ connectedRooms: ['nirgends'] }, /connectedRooms/], [{ previewGate: 'vielleicht' }, /previewGate/],
[{ previewsSent: 5 }, /previewsSent/], [{ unbekannt: 1 }, /unbekannt/], [{ reportEmail: 'kein-mail' }, /reportEmail/], [{ regions: [] }, /regions/],
[{ offerProductKey: 'gratis' }, /offerProductKey/], [{ offerProductKey: 'wartung' }, /offerProductKey/], // A11: verkauft wird nur "hosting" (P5-E10)
@@ -645,17 +646,17 @@ Die Ergänzungen E1–E11 dieses Plans sind in die Übersicht übernommen: siehe
[{ retentionDays: 91 }, /retentionDays/], [{ retentionDays: 6 }, /retentionDays/]]) // A11: 7–90 Tage (der Datenschutztext verspricht höchstens 90)
await assert.rejects(updateSettings(deps, patch, 'u1'), (e) => e instanceof ValidationError && e.status === 400 && re.test(e.message), JSON.stringify(patch))
assert.equal(deps.db.all('workersSettings').length, 0)
await updateSettings(deps, { mailColdPerDay: 3, connectedRooms: ['netz', 'bruecke', 'waage'], retentionDays: 90, offerText: 'x'.repeat(400), offerProductKey: 'hosting' }, 'u1') // Obergrenzen sind erlaubt: 3; jeder bekannte Raum; 90 Tage; 400 Zeichen
await updateSettings(deps, { mailColdPerDay: 3, connectedRooms: ['netz', 'bruecke', 'waage'], retentionDays: 90, offerText: 'x'.repeat(400), offerProductKey: 'hosting', monthlyBudgetUsd: 150 }, 'u1') // Obergrenzen sind erlaubt: 3; jeder bekannte Raum; 90 Tage; 400 Zeichen; 150 $
assert.equal((await updateSettings(deps, { retentionDays: 7 }, 'u1')).retentionDays, 7)
})
test('getSettings begrenzt die Mail-Grenzen auch bei von Hand geändertem Dokument (E11)', async () => {
const deps = makeDeps(); deps.db.seed('workersSettings', { mailColdPerDay: 50, mailReplyCapPerDay: 99, runsPerNight: 9 }, 'main')
const s = await getSettings(deps); assert.deepEqual([s.mailColdPerDay, s.mailReplyCapPerDay, s.runsPerNight], [3, 15, 2])
const deps = makeDeps(); deps.db.seed('workersSettings', { mailColdPerDay: 50, mailReplyCapPerDay: 99, runsPerNight: 9, monthlyBudgetUsd: 999, dailyBudgetUsd: 500 }, 'main')
const s = await getSettings(deps); assert.deepEqual([s.mailColdPerDay, s.mailReplyCapPerDay, s.runsPerNight, s.monthlyBudgetUsd, s.dailyBudgetUsd], [3, 15, 2, 150, 150]) // Budget-Deckel 150 $ gilt auch bei Konsolen-Änderung
})
test('publicSettings entfernt previewsSent und $-Felder', async () => { const p = publicSettings(await getSettings(makeDeps())); assert.ok(!('previewsSent' in p)); assert.ok(!Object.keys(p).some((k) => k.startsWith('$'))) })
```
- [ ] **Step 2: Fehlschlag sehen.** `cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/settings.test.js` → FAIL `ERR_MODULE_NOT_FOUND`.
- [ ] **Step 3: Implementieren.** `errors.js`: `HttpError extends Error` mit `status`; Unterklassen. `settings.js`: `DEFAULTS` wie im Test (Arrays je Aufruf kopieren); `getSettings` = `db.get('workersSettings','main')` über `DEFAULTS` gelegt (nur bekannte Schlüssel), danach `mailColdPerDay` auf höchstens 3, `mailReplyCapPerDay` auf höchstens 15 und `runsPerNight` auf höchstens 2 begrenzt (harte Grenzen gelten auch, wenn das Dokument in der Appwrite-Konsole geändert wurde). `updateSettings`: ganzen Patch zuerst validieren (erst danach schreiben), dann `get`: fehlt → `create(…, 'main')` mit allen `DEFAULTS` + Patch (bei `ConflictError` weiter mit `update`), sonst `update`. Grenzen: Budgets > 0; `warnPct` ganze Zahl 1–100; `mailColdPerDay` ganze Zahl 0–3; `mailReplyCapPerDay` 0–15; `sendWindow*` `HH:MM`, Start < Ende; `runsPerNight` 1–2; `candidatesPerRun` 1–20; `activeRooms` ⊆ `SEARCH_ROOMS`; `connectedRooms` ⊆ alle `room`-Werte aus `ROLES`; `regions` 1–30 Strings (je 1–64 Zeichen); `previewGate` ∈ {erste_n, immer, nie}; `previewGateN` 0–1000; `offerText` 1–400 Zeichen (hält eine Kaltmail unter 180 Wörtern, H3); `offerProductKey` nur `hosting` (das Portal verkauft nichts anderes, P5-E10); `pultStaleDays` 1–30; `retentionDays` 7–90 (der Datenschutztext verspricht höchstens 90 Tage); `followupDays`/`previewReminderDays` 1–60; `previewDeleteDays` 7–365; `reportEmail` einfache Adressprüfung ≤ 256; `paused`/`sendWeekdaysOnly` Boolean; `previewsSent` im Patch → „internes Feld“; Meldungen deutsch, beginnen mit dem Feldnamen. `publicSettings` und `DEFAULTS` bleiben wie beschrieben; die Boolean-Felder `privacyInteressentenLive` (P3) sowie `sendingHalted` und `sendingHaltedReason` (P4) fehlen hier bewusst (Übersicht §4.1): P3 und P4 ergänzen später `DEFAULTS` und Validierung (`sendingHalted:false` leert den Grund).
- [ ] **Step 3: Implementieren.** `errors.js`: `HttpError extends Error` mit `status`; Unterklassen. `settings.js`: `DEFAULTS` wie im Test (Arrays je Aufruf kopieren); `getSettings` = `db.get('workersSettings','main')` über `DEFAULTS` gelegt (nur bekannte Schlüssel), danach `mailColdPerDay` auf höchstens 3, `mailReplyCapPerDay` auf höchstens 15, `runsPerNight` auf höchstens 2, `monthlyBudgetUsd` auf höchstens 150 und `dailyBudgetUsd` auf höchstens `monthlyBudgetUsd` begrenzt (harte Grenzen gelten auch, wenn das Dokument in der Appwrite-Konsole geändert wurde). `updateSettings`: ganzen Patch zuerst validieren (erst danach schreiben), dann `get`: fehlt → `create(…, 'main')` mit allen `DEFAULTS` + Patch (bei `ConflictError` weiter mit `update`), sonst `update`. Grenzen: Budgets > 0, `monthlyBudgetUsd` ≤ 150 [Kenso, 17.10.: max. 150 € im Monat], `dailyBudgetUsd` ≤ `monthlyBudgetUsd`; `warnPct` ganze Zahl 1–100; `mailColdPerDay` ganze Zahl 0–3; `mailReplyCapPerDay` 0–15; `sendWindow*` `HH:MM`, Start < Ende; `runsPerNight` 1–2; `candidatesPerRun` 1–20; `activeRooms` ⊆ `SEARCH_ROOMS`; `connectedRooms` ⊆ alle `room`-Werte aus `ROLES`; `regions` 1–30 Strings (je 1–64 Zeichen); `previewGate` ∈ {erste_n, immer, nie}; `previewGateN` 0–1000; `offerText` 1–400 Zeichen (hält eine Kaltmail unter 180 Wörtern, H3); `offerProductKey` nur `hosting` (das Portal verkauft nichts anderes, P5-E10); `pultStaleDays` 1–30; `retentionDays` 7–90 (der Datenschutztext verspricht höchstens 90 Tage); `followupDays`/`previewReminderDays` 1–60; `previewDeleteDays` 7–365; `reportEmail` einfache Adressprüfung ≤ 256; `paused`/`sendWeekdaysOnly` Boolean; `previewsSent` im Patch → „internes Feld“; Meldungen deutsch, beginnen mit dem Feldnamen. `publicSettings` und `DEFAULTS` bleiben wie beschrieben; die Boolean-Felder `privacyInteressentenLive` (P3) sowie `sendingHalted` und `sendingHaltedReason` (P4) fehlen hier bewusst (Übersicht §4.1): P3 und P4 ergänzen später `DEFAULTS` und Validierung (`sendingHalted:false` leert den Grund).
- [ ] **Step 4: Test laufen lassen.** Gleicher Befehl → PASS (5 Tests).
- [ ] **Step 5: Commit.** `git add src/errors.js src/settings.js test/settings.test.js && git commit -m "P1-T07: Einstellungen mit Validierung" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"`
@@ -826,27 +827,27 @@ Die Ergänzungen E1–E11 dieses Plans sind in die Übersicht übernommen: siehe
- [ ] **Step 1: Test schreiben.** (`near` aus `test/support/helpers.js`; Standard-`makeDeps` = Montag 11:00 Berlin.)
```js
test('frisch: ok, kein warn', async () => assert.deepEqual(await checkBudget(makeDeps(), 1), { ok: true, reason: null, warn: false }))
test('Tagesgrenze: 8,5 $ → warn; Schätzung 1,6 sprengt den Tag; reply:true darf trotzdem', async () => {
const deps = makeDeps(); await book(deps, { workerId: 'rita', usd: 8.5 })
assert.deepEqual(await checkBudget(deps, 1), { ok: true, reason: null, warn: true })
test('Tagesgrenze (6 $): 5 $ → warn; Schätzung 1,6 sprengt den Tag; reply:true darf trotzdem', async () => {
const deps = makeDeps(); await book(deps, { workerId: 'rita', usd: 5 })
assert.deepEqual(await checkBudget(deps, 0.5), { ok: true, reason: null, warn: true })
assert.deepEqual(await checkBudget(deps, 1.6), { ok: false, reason: 'tag', warn: true }); assert.equal((await checkBudget(deps, 1.6, { reply: true })).ok, true)
})
test('Pause gewinnt immer, auch bei reply', async () => {
const deps = makeDeps(); await updateSettings(deps, { paused: true }, 'u')
assert.equal((await checkBudget(deps, 0)).reason, 'pause'); assert.equal((await checkBudget(deps, 0, { reply: true })).reason, 'pause')
})
test('Monatsgrenze 250 $ gilt auch für reply', async () => {
const deps = makeDeps(); deps.db.seed('workersDaily', { costUsd: 245 }, '2026-10-05')
assert.equal((await checkBudget(deps, 6)).reason, 'monat'); assert.equal((await checkBudget(deps, 6, { reply: true })).reason, 'monat'); assert.equal((await checkBudget(deps, 4)).ok, true)
test('Monatsgrenze 150 $ gilt auch für reply', async () => {
const deps = makeDeps(); deps.db.seed('workersDaily', { costUsd: 146 }, '2026-10-05')
assert.equal((await checkBudget(deps, 5)).reason, 'monat'); assert.equal((await checkBudget(deps, 5, { reply: true })).reason, 'monat'); assert.equal((await checkBudget(deps, 3)).ok, true)
})
test('80 %: genau eine budget_warnung (otto) und eine Mail, auch bei einer zweiten Buchung darüber', async () => {
const mails = []; const deps = makeDeps({ notify: async (m) => { mails.push(m); return { sent: true } } })
await book(deps, { workerId: 'rita', usd: 8.1 }); await book(deps, { workerId: 'rita', usd: 0.5 })
await book(deps, { workerId: 'rita', usd: 4.9 }); await book(deps, { workerId: 'rita', usd: 0.3 }) // 4,9 $ von 6 $ = 82 %; danach 5,2 $, noch unter 100 %
const ev = deps.db.all('workersEvents').filter((e) => e.kind === 'budget_warnung')
assert.equal(ev.length, 1); assert.equal(ev[0].workerId, 'otto'); assert.equal(mails.length, 1); assert.match(mails[0].subject, /Budget/)
})
test('100 %: ein budget_stopp ohne zusätzliche Warnung; danach tag-gesperrt, reply darf; keine Wiederholung', async () => {
const deps = makeDeps(); const r = await book(deps, { workerId: 'rita', usd: 10.2 })
const deps = makeDeps(); const r = await book(deps, { workerId: 'rita', usd: 6.2 })
assert.deepEqual(deps.db.all('workersEvents').map((e) => e.kind), ['budget_stopp']); assert.deepEqual([r.warned, r.stopped], [true, true])
assert.deepEqual(await checkBudget(deps, 0.01), { ok: false, reason: 'tag', warn: true }); assert.equal((await checkBudget(deps, 0.01, { reply: true })).ok, true)
await book(deps, { workerId: 'rita', usd: 0.5 }); assert.equal(deps.db.all('workersEvents').length, 1)
@@ -860,8 +861,8 @@ Die Ergänzungen E1–E11 dieses Plans sind in die Übersicht übernommen: siehe
const d = await getDaily(deps, '2026-10-19'); assert.equal(d.searches, 2); assert.deepEqual(d.counters.rita, { searches: 2, gelesen: 5, tokensIn: 1000, costUsd: 0.02 }); near(d.costUsd, 0.02)
})
test('vier gleichzeitige Buchungen verlieren keine Kosten; Warnung nur einmal', async () => {
const deps = makeDeps(); await Promise.all([2.1, 2.1, 2.1, 2.1].map((usd) => book(deps, { workerId: 'kim', usd })))
const d = await getDaily(deps, '2026-10-19'); near(d.costUsd, 8.4); near(d.counters.kim.costUsd, 8.4)
const deps = makeDeps(); await Promise.all([1.4, 1.4, 1.4, 1.4].map((usd) => book(deps, { workerId: 'kim', usd }))) // 5,6 $ von 6 $: über 80 %, unter 100 %
const d = await getDaily(deps, '2026-10-19'); near(d.costUsd, 5.6); near(d.counters.kim.costUsd, 5.6)
assert.equal(deps.db.all('workersEvents').filter((e) => e.kind === 'budget_warnung').length, 1)
})
```
@@ -886,12 +887,14 @@ Die Ergänzungen E1–E11 dieses Plans sind in die Übersicht übernommen: siehe
const SMTP = { host: 'mail.example', port: 587, user: 'u', pass: 'geheim-pw', from: 'server@example.de' }
function mk({ fail } = {}) { const t = { attempts: 0, sent: [], cfg: null }
t.createTransport = (cfg) => { t.cfg = cfg; return { sendMail: async (m) => { t.attempts++; if (fail) throw fail; t.sent.push(m); return { messageId: 'x' } } } }; return t }
test('sendet an getTo(): Betreff einzeilig mit [Workers]-Präfix, STARTTLS, Timeouts', async () => {
test('sendet an getTo(): Betreff einzeilig mit [Hustler]-Präfix, STARTTLS, Timeouts', async () => {
const deps = makeDeps(); const t = mk()
const n = createNotifier({ smtp: SMTP, getTo: async () => 'kenso@example.de', createTransport: t.createTransport, clock: deps.clock, log: deps.log })
assert.deepEqual(await n.notify({ subject: 'Budget\nWarnung', text: 'T' }), { sent: true })
assert.deepEqual([t.sent[0].to, t.sent[0].from, t.sent[0].subject], ['kenso@example.de', 'server@example.de', '[Workers] Budget Warnung'])
assert.deepEqual([t.sent[0].to, t.sent[0].from, t.sent[0].subject], ['kenso@example.de', 'server@example.de', '[Hustler] Budget Warnung'])
assert.deepEqual([t.cfg.host, t.cfg.port, t.cfg.requireTLS, t.cfg.connectionTimeout, t.cfg.auth], ['mail.example', 587, true, 20000, { user: 'u', pass: 'geheim-pw' }])
await n.notify({ subject: 'Hustler-Nachtbericht 19.10.2026', text: 'T' }); await n.notify({ subject: '[Hustler] Hinweis', text: 'T' }) // beginnt schon mit „Hustler“ → kein zweites Präfix
assert.deepEqual([t.sent[1].subject, t.sent[2].subject], ['Hustler-Nachtbericht 19.10.2026', '[Hustler] Hinweis'])
})
test('ohne Konfiguration oder Empfänger: sent=false, kein Transport', async () => {
const deps = makeDeps(); const t = mk(); const base = { createTransport: t.createTransport, clock: deps.clock, log: deps.log }
@@ -912,7 +915,7 @@ Die Ergänzungen E1–E11 dieses Plans sind in die Übersicht übernommen: siehe
})
```
- [ ] **Step 2: Fehlschlag sehen.** `cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/notify.test.js` → FAIL `ERR_MODULE_NOT_FOUND`.
- [ ] **Step 3: Implementieren.** Transport lazy einmal erzeugen: `{ host, port, secure: port === 465, requireTLS: port !== 465, connectionTimeout: 20000, greetingTimeout: 20000, socketTimeout: 20000, auth: { user, pass } }`. Betreff: CR/LF → Leerzeichen, Präfix `[Workers] ` falls nicht vorhanden. Auth-Fehler (`code === 'EAUTH'` oder `responseCode` ∈ {530, 534, 535}) sperren weitere Versuche bis zum nächsten Berliner Tag (`berlinNow(clock).day`) — Hintergrund: wiederholte falsche SMTP-Logins sperrten am 2026-09-30 die Server-IP bei Hetzner. Logs enthalten nie Passwort oder Nachrichtentext.
- [ ] **Step 3: Implementieren.** Transport lazy einmal erzeugen: `{ host, port, secure: port === 465, requireTLS: port !== 465, connectionTimeout: 20000, greetingTimeout: 20000, socketTimeout: 20000, auth: { user, pass } }`. Betreff: CR/LF → Leerzeichen, Präfix `[Hustler] ` davor, außer der Betreff beginnt schon mit `[Hustler]` oder `Hustler` (so bleibt der Morgenbericht-Betreff `Hustler-Nachtbericht TT.MM.JJJJ` unverändert). Auth-Fehler (`code === 'EAUTH'` oder `responseCode` ∈ {530, 534, 535}) sperren weitere Versuche bis zum nächsten Berliner Tag (`berlinNow(clock).day`) — Hintergrund: wiederholte falsche SMTP-Logins sperrten am 2026-09-30 die Server-IP bei Hetzner. Logs enthalten nie Passwort oder Nachrichtentext.
- [ ] **Step 4: Test laufen lassen.** Gleicher Befehl → PASS (4 Tests).
- [ ] **Step 5: Commit.** `git add src/notify.js test/notify.test.js && git commit -m "P1-T12: interner Mailer mit Login-Sperre pro Tag" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"`
@@ -958,7 +961,7 @@ Die Ergänzungen E1–E11 dieses Plans sind in die Übersicht übernommen: siehe
test('Vorabprüfung: Pause und Tagesgrenze → BudgetStop ohne API-Aufruf; reply:true darf am Tageslimit', async () => {
const sdk = fakeSdk([msg()]); const c = createClaude({ apiKey: 'k', sdk }); const ask = (deps, o = {}) => c.ask(deps, { workerId: 'nora', system: 's', input: 'i', maxTokens: 10, ...o })
let deps = makeDeps(); await updateSettings(deps, { paused: true }, 't'); await assert.rejects(ask(deps), (e) => e instanceof BudgetStop && e.reason === 'pause')
deps = makeDeps(); await book(deps, { workerId: 'rita', usd: 10 }); await assert.rejects(ask(deps), (e) => e instanceof BudgetStop && e.reason === 'tag')
deps = makeDeps(); await book(deps, { workerId: 'rita', usd: 6 }); await assert.rejects(ask(deps), (e) => e instanceof BudgetStop && e.reason === 'tag')
assert.equal(sdk.calls.length, 0); await ask(deps, { reply: true }); assert.equal(sdk.calls.length, 1)
})
test('API-Budgetantworten: 400-Workspace-Limit und 429 enforced_spend_limit_reached → BudgetStop(console), Tag gestoppt, Ereignis einmal, kein weiterer Aufruf', async () => {
@@ -1406,7 +1409,7 @@ Die Ergänzungen E1–E11 dieses Plans sind in die Übersicht übernommen: siehe
test('nichts geplant: Pause (still), Budget erschöpft, kein angeschlossener Raum (ein leerlauf-Ereignis); Kim/Pia/inaktive Räume nie', async () => {
let deps = makeDeps(); await updateSettings(deps, { paused: true, connectedRooms: ['netz'] }, 't')
assert.deepEqual(await planNight(deps, { run: 1 }), { planned: [], skipped: 'pause' }); assert.equal(deps.db.all('workersJobs').length + deps.db.all('workersEvents').length, 0)
deps = makeDeps(); await updateSettings(deps, { connectedRooms: ['netz'] }, 't'); await book(deps, { workerId: 'rita', usd: 9.9 })
deps = makeDeps(); await updateSettings(deps, { connectedRooms: ['netz'] }, 't'); await book(deps, { workerId: 'rita', usd: 5.5 }) // 5,5 + Mindestrest 1 $ > Tagesgrenze 6 $
assert.equal((await planNight(deps, { run: 1 })).skipped, 'budget'); assert.equal(deps.db.all('workersJobs').length, 0)
deps = makeDeps(); await updateSettings(deps, { connectedRooms: ['kleinanzeigen', 'portale'] }, 't')
assert.deepEqual(await planNight(deps, { run: 1 }), { planned: [], skipped: 'keine_raeume' })
@@ -1438,12 +1441,12 @@ Die Ergänzungen E1–E11 dieses Plans sind in die Übersicht übernommen: siehe
const ev = (n, kind, workerId = 'kim', ts = '2026-10-19T00:30:00.000Z', text = 'x') => { for (let i = 0; i < n; i++) deps.db.seed('workersEvents', { ts, workerId, kind, text }) }
ev(12, 'fund'); ev(5, 'doppelt', 'arne'); ev(7, 'bewertet', 'bea'); ev(3, 'top3', 'bea'); ev(4, 'botlead', 'arne'); ev(3, 'gesendet', 'max'); ev(1, 'eingang', 'nora'); ev(4, 'auftrag', 'otto')
ev(1, 'fehler', 'kim', '2026-10-19T01:00:00.000Z', 'Kim: Postfach nicht erreichbar.'); ev(9, 'fund', 'kim', '2026-10-17T00:00:00.000Z') // letztes: zu alt
deps.db.seed('workersDaily', { costUsd: 6.4 }, '2026-10-18'); deps.db.seed('workersDaily', { costUsd: 0.3 }, '2026-10-19'); deps.db.seed('workersDaily', { costUsd: 34.4 }, '2026-10-05')
deps.db.seed('workersDaily', { costUsd: 4.2 }, '2026-10-18'); deps.db.seed('workersDaily', { costUsd: 0.3 }, '2026-10-19'); deps.db.seed('workersDaily', { costUsd: 34.4 }, '2026-10-05')
for (const status of ['offen', 'in_arbeit', 'erledigt']) deps.db.seed('workersPult', { status })
const { subject, text } = await buildReport(deps)
assert.equal(subject, 'Nachtbericht 19.10.2026')
assert.equal(subject, 'Hustler-Nachtbericht 19.10.2026')
for (const re of [/Gefunden: 12 · schon bekannt: 5 · bewertet: 7 · Top 3: 3 · Bot-Leads: 4/, /Gesendet: 3 · Antworten eingegangen: 1 · Deals: 0 · gesperrt: 0/, /Wartet auf dich: 2/, /Aufträge von Otto: 4/,
/Kosten gestern: 6,40 \$ · heute bis jetzt: 0,30 \$ · Monat: 41,10 \$ von 250,00 \$ \(Tagesgrenze 10,00 \$\)/, /Fehler: 1 \(zuletzt: Kim: Postfach nicht erreichbar\.\)/,
/Kosten gestern: 4,20 \$ · heute bis jetzt: 0,30 \$ · Monat: 38,90 \$ von 150,00 \$ \(Tagesgrenze 6,00 \$\)/, /Fehler: 1 \(zuletzt: Kim: Postfach nicht erreichbar\.\)/,
/Noch nicht angeschlossen: Netz-Radar, Kleinanzeigen, Portale, Foren & Fragen, Ärzte & Praxen, Handwerk, Selbstständige, Läden & Gastro/]) assert.match(text, re)
})
test('ruhige Nacht, Pause und Budget-Hinweise', async () => {
@@ -1453,13 +1456,13 @@ Die Ergänzungen E1–E11 dieses Plans sind in die Übersicht übernommen: siehe
})
test('sendReport reicht Betreff/Text an notify; Fehlschlag wird als fehler-Ereignis sichtbar', async () => {
const sent = []; const ok = makeDeps({ notify: async (m) => { sent.push(m); return { sent: true } } })
assert.deepEqual(await sendReport(ok), { sent: true }); assert.match(sent[0].subject, /^Nachtbericht \d\d\.\d\d\.\d{4}$/)
assert.deepEqual(await sendReport(ok), { sent: true }); assert.match(sent[0].subject, /^Hustler-Nachtbericht \d\d\.\d\d\.\d{4}$/)
const bad = makeDeps({ notify: async () => ({ sent: false, reason: 'smtp_auth' }) }); await sendReport(bad)
const e = bad.db.all('workersEvents').at(-1); assert.deepEqual([e.kind, e.workerId], ['fehler', 'otto']); assert.match(e.text, /Morgenbericht nicht gesendet: smtp_auth/)
})
```
- [ ] **Step 2: Fehlschlag sehen.** `cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/bericht.test.js` → FAIL `ERR_MODULE_NOT_FOUND`.
- [ ] **Step 3: Implementieren.** Fenster: Ereignisse mit `ts ≥ jetzt − 24 h` (`listAll` mit `Query.greaterThanEqual('ts', …)`), gezählt nach `kind`; Kosten aus den Tagesdokumenten (gestern = Tag vor `now.day`, heute, Monat = `monthDocs`) und den Einstellungen; Pult = `count` mit `status` ∈ {offen, in_arbeit}; Zeilenformate **wörtlich** wie im Test; Zeile `Fehler: n (zuletzt: <Text des neuesten fehler-Ereignisses>)` nur bei n > 0; `Budget: Warnung ausgelöst`/`Stopp ausgelöst` nur bei entsprechenden Ereignissen im Fenster; `Pause: aktiv` nur bei `paused`; `Noch nicht angeschlossen: …` = aktive Räume (Reihenfolge `SEARCH_ROOMS`, Namen via `roomLabel`) ohne Anschluss, Zeile entfällt wenn leer; kam keine der gezählten Arten (fund, doppelt, bewertet, top3, botlead, gesendet, eingang, deal, gesperrt, fehler) vor, zusätzlich `Die Nacht war ruhig: keine neuen Funde, keine Nachrichten.`; Anrede `Guten Morgen, hier ist Otto. Nachtbericht für den 19.10.2026.`; Datum `TT.MM.JJJJ` aus `now.day`. `sendReport` ohne `deps.notify` ⇒ Ergebnis `{ sent:false, reason:'nicht_konfiguriert' }` (und das `fehler`-Ereignis). Kein Modellaufruf.
- [ ] **Step 3: Implementieren.** Fenster: Ereignisse mit `ts ≥ jetzt − 24 h` (`listAll` mit `Query.greaterThanEqual('ts', …)`), gezählt nach `kind`; Kosten aus den Tagesdokumenten (gestern = Tag vor `now.day`, heute, Monat = `monthDocs`) und den Einstellungen; Pult = `count` mit `status` ∈ {offen, in_arbeit}; Zeilenformate **wörtlich** wie im Test; Zeile `Fehler: n (zuletzt: <Text des neuesten fehler-Ereignisses>)` nur bei n > 0; `Budget: Warnung ausgelöst`/`Stopp ausgelöst` nur bei entsprechenden Ereignissen im Fenster; `Pause: aktiv` nur bei `paused`; `Noch nicht angeschlossen: …` = aktive Räume (Reihenfolge `SEARCH_ROOMS`, Namen via `roomLabel`) ohne Anschluss, Zeile entfällt wenn leer; kam keine der gezählten Arten (fund, doppelt, bewertet, top3, botlead, gesendet, eingang, deal, gesperrt, fehler) vor, zusätzlich `Die Nacht war ruhig: keine neuen Funde, keine Nachrichten.`; Anrede `Guten Morgen, hier ist Otto. Hustler-Nachtbericht für den 19.10.2026.`; Betreff `Hustler-Nachtbericht TT.MM.JJJJ` (der Betreff beginnt schon mit „Hustler“, `notify` setzt deshalb kein zweites `[Hustler]`-Präfix davor, T12); Datum `TT.MM.JJJJ` aus `now.day`. `sendReport` ohne `deps.notify` ⇒ Ergebnis `{ sent:false, reason:'nicht_konfiguriert' }` (und das `fehler`-Ereignis). Kein Modellaufruf.
- [ ] **Step 4: Test laufen lassen.** Gleicher Befehl → PASS (3 Tests).
- [ ] **Step 5: Commit.** `git add src/otto/bericht.js test/bericht.test.js && git commit -m "P1-T21: Otto schreibt den Morgenbericht" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"`
@@ -1584,7 +1587,7 @@ Die Ergänzungen E1–E11 dieses Plans sind in die Übersicht übernommen: siehe
for (const m of ['GET', 'PUT']) { const r = await t.req('/settings', { method: m, body: m === 'PUT' ? { warnPct: 70 } : undefined }); assert.deepEqual([r.status, (await r.json()).error], [403, 'Nur für Admins.']) }; await t.close()
})
test('Admin: Einstellungen lesen (ohne previewsSent), ändern, Validierungsfehler 400 mit deutschem Text', async () => {
const t = await boot({ labels: ['admin'] }); const g = await (await t.req('/settings')).json(); assert.equal(g.dailyBudgetUsd, 10); assert.ok(!('previewsSent' in g))
const t = await boot({ labels: ['admin'] }); const g = await (await t.req('/settings')).json(); assert.equal(g.dailyBudgetUsd, 6); assert.ok(!('previewsSent' in g))
const p = await t.req('/settings', { method: 'PUT', body: { dailyBudgetUsd: 12 } }); assert.equal((await p.json()).dailyBudgetUsd, 12)
const bad = await t.req('/settings', { method: 'PUT', body: { mailColdPerDay: 4 } }); assert.equal(bad.status, 400); assert.match((await bad.json()).error, /mailColdPerDay/); await t.close()
})
@@ -1601,7 +1604,7 @@ Die Ergänzungen E1–E11 dieses Plans sind in die Übersicht übernommen: siehe
})
```
- [ ] **Step 2: Fehlschlag sehen.** `cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/apiAuth.test.js` → FAIL `ERR_MODULE_NOT_FOUND` (`src/api.js`).
- [ ] **Step 3: Implementieren.** Nach dem Muster `/home/webklar/apps/serverpuls/src/api.js` (`HttpError`, `sendJson` mit `Cache-Control: no-store`, JWT-Form `^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$` und ≤ 2048 Zeichen, Cache `jwt → Promise` 60 s mit Teilen laufender Anfragen, höchstens 500 Einträge, `AbortSignal.timeout(5000)`), aber: `labels` muss `mitarbeiter` **oder** `admin` enthalten (sonst 403 `Kein Zugriff.`), `/settings` verlangt `admin` (403 `Nur für Admins.`); Appwrite 401/403/404 auf `/account` ⇒ 401 `Sitzung abgelaufen.`, sonstige Fehler/Netz ⇒ 502 `Anmeldedienst nicht erreichbar.`. Antworttexte: fehlendes JWT `Nicht angemeldet.`, unbekannter Pfad `Nicht gefunden.`, falsche Methode 405 `Methode nicht erlaubt.`, ungültiges JSON/Feld 400 (`Ungültige Anfrage.` bzw. Text der `ValidationError`), Body > 16 KB 413 `Anfrage zu groß.`, sonst 500 `Interner Fehler.` (Details nur ins Log). `POST /pause`: Body `{ paused: boolean }`; nur bei Änderung `updateSettings(deps, { paused }, user.$id)` und `emit` (`system`, `pause`, Text `Alle Workers pausiert (<Name>).` bzw. `Pause aufgehoben (<Name>).`, `meta:{ paused, by: user.$id }`); Antwort `{ paused }`. Routen-Tabelle so bauen, dass T25 weitere Pfade ergänzt.
- [ ] **Step 3: Implementieren.** Nach dem Muster `/home/webklar/apps/serverpuls/src/api.js` (`HttpError`, `sendJson` mit `Cache-Control: no-store`, JWT-Form `^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$` und ≤ 2048 Zeichen, Cache `jwt → Promise` 60 s mit Teilen laufender Anfragen, höchstens 500 Einträge, `AbortSignal.timeout(5000)`), aber: `labels` muss `mitarbeiter` **oder** `admin` enthalten (sonst 403 `Kein Zugriff.`), `/settings` verlangt `admin` (403 `Nur für Admins.`); Appwrite 401/403/404 auf `/account` ⇒ 401 `Sitzung abgelaufen.`, sonstige Fehler/Netz ⇒ 502 `Anmeldedienst nicht erreichbar.`. Antworttexte: fehlendes JWT `Nicht angemeldet.`, unbekannter Pfad `Nicht gefunden.`, falsche Methode 405 `Methode nicht erlaubt.`, ungültiges JSON/Feld 400 (`Ungültige Anfrage.` bzw. Text der `ValidationError`), Body > 16 KB 413 `Anfrage zu groß.`, sonst 500 `Interner Fehler.` (Details nur ins Log). `POST /pause`: Body `{ paused: boolean }`; nur bei Änderung `updateSettings(deps, { paused }, user.$id)` und `emit` (`system`, `pause`, Text `Alle Hustler pausiert (<Name>).` bzw. `Pause aufgehoben (<Name>).`, `meta:{ paused, by: user.$id }`); Antwort `{ paused }`. Routen-Tabelle so bauen, dass T25 weitere Pfade ergänzt.
- [ ] **Step 4: Test laufen lassen.** Gleicher Befehl → PASS (7 Tests).
- [ ] **Step 5: Commit.** `git add src/api.js test/support/apiBoot.js test/apiAuth.test.js && git commit -m "P1-T24: HTTP-API mit Mitarbeiter-/Admin-Prüfung" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"`
@@ -1624,7 +1627,7 @@ Die Ergänzungen E1–E11 dieses Plans sind in die Übersicht übernommen: siehe
await updateSettings(deps, { connectedRooms: ['netz', 'foren', 'aerzte'] }, 't')
deps.db.seed('workersJobs', { workerId: 'rita', kind: 'suche', status: 'laeuft' }); deps.db.seed('workersJobs', { workerId: 'ali', kind: 'suche', status: 'offen' }); deps.db.seed('workersJobs', { workerId: 'otto', kind: 'bericht', status: 'offen' })
deps.db.seed('workersEvents', { ts: '2026-10-19T08:55:00.000Z', workerId: 'ali', kind: 'fehler', text: 'Ali: Quelle nicht erreichbar.' }); deps.db.seed('workersEvents', { ts: '2026-10-19T08:50:00.000Z', workerId: 'sven', kind: 'fehler', text: 'Sven: Fehler.' })
deps.db.seed('workersDaily', { costUsd: 2.5, deals: 1, countersJson: JSON.stringify({ rita: { gelesen: 5, tokensIn: 100, tokensOut: 50, costUsd: 0.1, calls: 2 } }) }, '2026-10-19'); deps.db.seed('workersDaily', { costUsd: 6.4, deals: 2 }, '2026-10-18')
deps.db.seed('workersDaily', { costUsd: 2.5, deals: 1, countersJson: JSON.stringify({ rita: { gelesen: 5, tokensIn: 100, tokensOut: 50, costUsd: 0.1, calls: 2 } }) }, '2026-10-19'); deps.db.seed('workersDaily', { costUsd: 3.4, deals: 2 }, '2026-10-18')
for (const status of ['offen', 'in_arbeit', 'erledigt']) deps.db.seed('workersPult', { status }); for (let i = 0; i < 3; i++) deps.db.seed('workersProspects', { status: 'botlead' }); deps.db.seed('workersProspects', { status: 'gefunden' })
deps.db.seed('workersThreads', { status: 'offen' }); deps.db.seed('workersThreads', { status: 'abgeschlossen' })
// A12: Kennzahlen kommen aus workersOffers (bezahlt, paidAt im Berliner Monat); die Tageszähler deals (oben 1 und 2) werden ignoriert
@@ -1641,13 +1644,13 @@ Die Ergänzungen E1–E11 dieses Plans sind in die Übersicht übernommen: siehe
assert.deepEqual(Object.keys(w).filter((id) => w[id].status === 'grau').sort(), ['hanna', 'kim', 'lea', 'pia', 'sven'])
assert.deepEqual(['otto', 'bea', 'max', 'clara', 'nora', 'theo', 'bob', 'bianca', 'ben', 'paul', 'arne'].map((id) => [id, w[id].connected, w[id].status === 'grau']), ['otto', 'bea', 'max', 'clara', 'nora', 'theo', 'bob', 'bianca', 'ben', 'paul', 'arne'].map((id) => [id, true, false]))
assert.deepEqual(['rita', 'ali', 'finn', 'kim', 'sven'].map((id) => w[id].connected), [true, true, true, false, false])
assert.deepEqual(s.kpis, { newAbosMonth: 1, mrrNewEur: 20, dealsMonth: 1, openThreads: 1, pultOpen: 2, botLeads: 3, apiTodayUsd: 2.5, apiMonthUsd: 8.9, dailyBudgetUsd: 10, monthlyBudgetUsd: 250 }) // dealsMonth 1 (bezahltes Angebot im Oktober), nicht 3 (Σ workersDaily.deals)
assert.deepEqual(s.kpis, { newAbosMonth: 1, mrrNewEur: 20, dealsMonth: 1, openThreads: 1, pultOpen: 2, botLeads: 3, apiTodayUsd: 2.5, apiMonthUsd: 5.9, dailyBudgetUsd: 6, monthlyBudgetUsd: 150 }) // dealsMonth 1 (bezahltes Angebot im Oktober), nicht 3 (Σ workersDaily.deals)
assert.equal(s.lastEventTs, '2026-10-19T08:55:00.000Z')
})
test('buildState auf leerer DB: ohne connectedRooms sind genau die acht Such-Worker grau, die übrigen elf nicht (P1-E9), Kennzahlen 0, lastEventTs null', async () => {
const s = await buildState(makeDeps()); assert.deepEqual(s.workers.filter((w) => w.status === 'grau').map((w) => w.workerId).sort(), ['ali', 'finn', 'hanna', 'kim', 'lea', 'pia', 'rita', 'sven'])
const rest = s.workers.filter((w) => w.status !== 'grau'); assert.equal(rest.length, 11); assert.ok(rest.every((w) => w.status === 'leerlauf' && w.connected === true)); assert.equal(s.workers.find((w) => w.workerId === 'otto').status, 'leerlauf')
assert.deepEqual(s.kpis, { newAbosMonth: 0, mrrNewEur: 0, dealsMonth: 0, openThreads: 0, pultOpen: 0, botLeads: 0, apiTodayUsd: 0, apiMonthUsd: 0, dailyBudgetUsd: 10, monthlyBudgetUsd: 250 }); assert.equal(s.lastEventTs, null)
assert.deepEqual(s.kpis, { newAbosMonth: 0, mrrNewEur: 0, dealsMonth: 0, openThreads: 0, pultOpen: 0, botLeads: 0, apiTodayUsd: 0, apiMonthUsd: 0, dailyBudgetUsd: 6, monthlyBudgetUsd: 150 }); assert.equal(s.lastEventTs, null)
assert.deepEqual([s.sendingHalted, s.sendingHaltedReason], [false, '']) // H4: Standard; P4 legt die Einstellungsfelder an und prüft den gestoppten Zustand
})
test('buildEvents: aufsteigend ab after (strikt größer), Limit geklemmt, ohne after die letzten N aufsteigend, meta geparst; ungültiges Datum → ValidationError', async () => {
@@ -1676,7 +1679,7 @@ Die Ergänzungen E1–E11 dieses Plans sind in die Übersicht übernommen: siehe
})
```
- [ ] **Step 2: Fehlschlag sehen.** `cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/view.test.js` → FAIL `ERR_MODULE_NOT_FOUND` (`src/view.js`).
- [ ] **Step 3: Implementieren.** `buildState`: Einstellungen (daraus `paused`, `sendingHalted: Boolean(settings.sendingHalted)`, `sendingHaltedReason: settings.sendingHaltedReason ?? ''`); Aufträge `status ∈ {laeuft, offen}` (`limit 200`); Ereignisse der letzten 30 min (`ts` absteigend, `limit 200`) – „aktuell fehlerhaft“ = jüngstes Ereignis des Workers innerhalb 15 min ist `fehler`; `connected` = `!SEARCH_ROOMS.includes(room) || connectedRooms.includes(room)` (`SEARCH_ROOMS` aus `src/roles.js`, T05; verbindlich P1-E9: Bea, Max, Clara, Nora, Theo, Bob, Bianca, Ben, Paul, Arne und Otto sind nie grau, ein Such-Raum ist es, bis er in `connectedRooms` steht; Einträge in `connectedRooms`, die keine Such-Räume sind, ändern nichts); Status in der Rangfolge `grau` (nicht angeschlossen) > `fehler` > `arbeitet` (Job `laeuft`) > `wartet` (Job `offen`) > `leerlauf`; `task`: `grau` ⇒ `noch nicht angeschlossen`, `fehler` ⇒ Text des Fehler-Ereignisses, `arbeitet` ⇒ Text des jüngsten Ereignisses des Workers (sonst `JOB_LABEL`), `wartet` ⇒ `Wartet auf Start: <JOB_LABEL>`, sonst `''`; `JOB_LABEL` = deutsche Substantive für alle 15 Auftragsarten (`suche` → `Suche`, `bericht` → `Bericht`, `alarm_lesen` → `Alarm-Postfach lesen`, …); `counters` aus `getDaily(heute).counters[workerId]` ohne `tokensIn, tokensOut, costUsd, calls`. Kennzahlen: eine einzige Liste bezahlter Angebote aus `workersOffers` (Abfrage nur über den Index `status = 'bezahlt'`, `limit 500`; im Code gefiltert auf `paidAt` im Berliner Monat, also ≥ `berlinStartOfDay(Monatserster)` und < `berlinStartOfDay(Folgemonatserster)`); daraus `dealsMonth` = Länge der Liste, `newAbosMonth` = Anzahl der Einträge, deren `productKey` in der Preistabelle steht, `mrrNewEur` = Summe der Preise `{ hosting: 20 }` (A12; `workersDaily.deals` wird nicht gelesen), `openThreads` = `count` Fäden mit Status offen/wartet_auf_kunde/wartet_auf_uns, `pultOpen` = `count` offen+in_arbeit, `botLeads` = `count` Interessenten `botlead`, `apiTodayUsd`/`apiMonthUsd` aus den Tagesdokumenten (alle Geldwerte auf 6 Nachkommastellen runden). `buildEvents`/`buildTrace` mit den Indizes `ts` bzw. `workerId+ts`; `meta` = geparstes `metaJson` oder `null`; ungültiges `after` (`Date.parse` = NaN) ⇒ `ValidationError('Ungültiges Datum.')`, gültiges wird als `new Date(after).toISOString()` in die Query gegeben; jedes ausgegebene `ts`/`lastEventTs` läuft durch `new Date(x).toISOString()` (E9); Limits klemmen statt ablehnen. Routen in `api.js` für Team (Pfadparameter URL-dekodieren, unbekannter Worker ⇒ 404 `Unbekannter Worker.`).
- [ ] **Step 3: Implementieren.** `buildState`: Einstellungen (daraus `paused`, `sendingHalted: Boolean(settings.sendingHalted)`, `sendingHaltedReason: settings.sendingHaltedReason ?? ''`); Aufträge `status ∈ {laeuft, offen}` (`limit 200`); Ereignisse der letzten 30 min (`ts` absteigend, `limit 200`) – „aktuell fehlerhaft“ = jüngstes Ereignis des Workers innerhalb 15 min ist `fehler`; `connected` = `!SEARCH_ROOMS.includes(room) || connectedRooms.includes(room)` (`SEARCH_ROOMS` aus `src/roles.js`, T05; verbindlich P1-E9: Bea, Max, Clara, Nora, Theo, Bob, Bianca, Ben, Paul, Arne und Otto sind nie grau, ein Such-Raum ist es, bis er in `connectedRooms` steht; Einträge in `connectedRooms`, die keine Such-Räume sind, ändern nichts); Status in der Rangfolge `grau` (nicht angeschlossen) > `fehler` > `arbeitet` (Job `laeuft`) > `wartet` (Job `offen`) > `leerlauf`; `task`: `grau` ⇒ `noch nicht angeschlossen`, `fehler` ⇒ Text des Fehler-Ereignisses, `arbeitet` ⇒ Text des jüngsten Ereignisses des Workers (sonst `JOB_LABEL`), `wartet` ⇒ `Wartet auf Start: <JOB_LABEL>`, sonst `''`; `JOB_LABEL` = deutsche Substantive für alle 15 Auftragsarten (`suche` → `Suche`, `bericht` → `Bericht`, `alarm_lesen` → `Alarm-Postfach lesen`, …); `counters` aus `getDaily(heute).counters[workerId]` ohne `tokensIn, tokensOut, costUsd, calls`. Kennzahlen: eine einzige Liste bezahlter Angebote aus `workersOffers` (Abfrage nur über den Index `status = 'bezahlt'`, `limit 500`; im Code gefiltert auf `paidAt` im Berliner Monat, also ≥ `berlinStartOfDay(Monatserster)` und < `berlinStartOfDay(Folgemonatserster)`); daraus `dealsMonth` = Länge der Liste, `newAbosMonth` = Anzahl der Einträge, deren `productKey` in der Preistabelle steht, `mrrNewEur` = Summe der Preise `{ hosting: 20 }` (A12; `workersDaily.deals` wird nicht gelesen), `openThreads` = `count` Fäden mit Status offen/wartet_auf_kunde/wartet_auf_uns, `pultOpen` = `count` offen+in_arbeit, `botLeads` = `count` Interessenten `botlead`, `apiTodayUsd`/`apiMonthUsd` aus den Tagesdokumenten (alle Geldwerte auf 6 Nachkommastellen runden). `buildEvents`/`buildTrace` mit den Indizes `ts` bzw. `workerId+ts`; `meta` = geparstes `metaJson` oder `null`; ungültiges `after` (`Date.parse` = NaN) ⇒ `ValidationError('Ungültiges Datum.')`, gültiges wird als `new Date(after).toISOString()` in die Query gegeben; jedes ausgegebene `ts`/`lastEventTs` läuft durch `new Date(x).toISOString()` (E9); Limits klemmen statt ablehnen. Routen in `api.js` für Team (Pfadparameter URL-dekodieren, unbekannter Worker ⇒ 404 `Unbekannter Hustler.`).
- [ ] **Step 4: Tests laufen lassen.** Gleicher Befehl und `test/apiAuth.test.js` → PASS (6 + 7 Tests).
- [ ] **Step 5: Commit.** `git add src/view.js src/api.js test/view.test.js && git commit -m "P1-T25: API-Sichten state, events, trace" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"`
@@ -1790,7 +1793,7 @@ Die Ergänzungen E1–E11 dieses Plans sind in die Übersicht übernommen: siehe
const d = read('Dockerfile'); assert.match(d, /^FROM node:22-alpine$/m); assert.match(d, /npm ci --omit=dev/); assert.match(d, /^USER node$/m); assert.match(d, /chown node:node \/data/); assert.match(d, /^CMD \["node", "src\/main\.js"\]$/m); assert.ok(!/chromium/.test(d.replace(/#.*$/gm, '')))
assert.match(d, /^COPY prompts \.\/prompts$/m); assert.ok(d.indexOf('COPY prompts ./prompts') < d.indexOf('USER node')) // H5: Prompts im Image (der Mount überdeckt sie)
})
test('Serverpuls-Eintrag', () => assert.deepEqual(JSON.parse(read('deploy/serverpuls-apps-eintrag.json')), { app: 'Workers', match: ['webklar-workers*'], folders: ['workers'] }))
test('Serverpuls-Eintrag', () => assert.deepEqual(JSON.parse(read('deploy/serverpuls-apps-eintrag.json')), { app: 'Hustler', match: ['webklar-workers*'], folders: ['workers'] })) // Anzeigename in der Serverpuls-Tabelle (Mitarbeiter sehen ihn); Treffer und Ordner bleiben technisch
```
- [ ] **Step 2: Fehlschlag sehen.** `cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/deploy.test.js` → FAIL `ENOENT … deploy/compose-workers.yml`.
- [ ] **Step 3: Bausteine anlegen** (wörtlicher Inhalt):
@@ -1858,7 +1861,7 @@ Die Ergänzungen E1–E11 dieses Plans sind in die Übersicht übernommen: siehe
```
dazu im Kommentar der Datei die Zeile `# Unter "volumes:" ergänzen: workers_data:`. Der Mount hängt das Verzeichnis ein (nicht einzelne Dateien), damit auch von `git pull` ersetzte Dateien sichtbar werden; das Image enthält `prompts/` zusätzlich als Rückfall. `deploy/nginx-workers.conf`:
```nginx
# Workers (Kundenfindung, nur Mitarbeiter) - Same-Origin Proxy
# Hustler (Kundenfindung, nur Mitarbeiter) - Same-Origin Proxy
location /api/workers/ {
resolver 127.0.0.11 valid=30s ipv6=off;
set $workers_upstream webklar-workers;
@@ -1871,7 +1874,7 @@ Die Ergänzungen E1–E11 dieses Plans sind in die Übersicht übernommen: siehe
client_max_body_size 64k;
}
```
`deploy/serverpuls-apps-eintrag.json`: `{ "app": "Workers", "match": ["webklar-workers*"], "folders": ["workers"] }`.
`deploy/serverpuls-apps-eintrag.json`: `{ "app": "Hustler", "match": ["webklar-workers*"], "folders": ["workers"] }`.
- [ ] **Step 4: Tests laufen lassen.** `cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/deploy.test.js` → PASS (4 Tests); danach der Gesamtlauf `timeout 300 node --max-old-space-size=1024 --test test/*.test.js` → alle Tests grün, 0 fail.
- [ ] **Step 5: Commit.** `git add Dockerfile .dockerignore deploy/compose-workers.yml deploy/nginx-workers.conf deploy/serverpuls-apps-eintrag.json test/deploy.test.js && git commit -m "P1-T27: Container-, nginx- und Serverpuls-Bausteine" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"`
@@ -1893,7 +1896,7 @@ Reihenfolge einhalten. **[Claude]** = Schritt, den der ausführende Agent mit de
3. **[Kenso] Appwrite-Schlüssel.** Appwrite-Konsole → Projekt `6a1058610003c5a13a05` → Integrationen → API-Schlüssel → Name `workers`, kein Ablauf, Bereiche `databases.read/write`, `collections.read/write`, `attributes.read/write`, `indexes.read/write`, `documents.read/write`. Dann:
`read -rs -p 'Appwrite-Key workers: ' K; echo; printf 'WORKERS_APPWRITE_API_KEY=%s\n' "$K" >> /home/webklar/apps/.env; unset K`
*Prüfen:* `command grep -c '^WORKERS_APPWRITE_API_KEY=.' /home/webklar/apps/.env` → `1`.
4. **[Kenso] Anthropic.** Console: Workspace „Workers“ anlegen, dort Ausgabenlimit 250 $/Monat setzen und einen API-Schlüssel erzeugen (Spec §12). Dann `read -rs -p 'Anthropic-Key Workers: ' K; echo; printf 'WORKERS_ANTHROPIC_API_KEY=%s\n' "$K" >> /home/webklar/apps/.env; unset K`. *Prüfen:* `command grep -c '^WORKERS_ANTHROPIC_API_KEY=.' /home/webklar/apps/.env` → `1`.
4. **[Kenso] Anthropic.** Console: Workspace „Hustler“ anlegen, dort das Ausgabenlimit (Workspace-Ausgabenlimit) auf **150 $/Monat** setzen [Kenso, 17.10.: max. 150 € im Monat; 150 $ ≈ 130 €, also sicher darunter, solange 1 € ≥ 1 $] und einen API-Schlüssel erzeugen (Spec §12). Dann `read -rs -p 'Anthropic-Key Hustler: ' K; echo; printf 'WORKERS_ANTHROPIC_API_KEY=%s\n' "$K" >> /home/webklar/apps/.env; unset K`. *Prüfen:* `command grep -c '^WORKERS_ANTHROPIC_API_KEY=.' /home/webklar/apps/.env` → `1`.
5. **[Kenso] Berichtskonto** (intern, kein Kaltkontakt; Passwort wird aus Serverpuls übernommen, ohne es zu zeigen):
```
cd /home/webklar/apps
@@ -1932,7 +1935,7 @@ Reihenfolge einhalten. **[Claude]** = Schritt, den der ausführende Agent mit de
```
*Prüfen:* `docker logs --tail 20 webklar-workers` enthält `workers listening on 8092` und keine Zeile mit dem Wort „fehlt“; `docker stats --no-stream webklar-workers` unter 768 MiB (erwartet < 150 MiB); `docker exec webklar-ticket-integrations wget -qO- http://webklar-workers:8092/api/workers/health` → `{"ok":true,"version":"0.1.0"}`; `docker exec webklar-workers ls /app/prompts` zeigt die 15 `.md`-Dateien (Bind-Mount des Checkouts, schreibgeschützt). Nie ein nacktes `docker compose up -d` (startet Gestopptes).
11. **[Kenso] nginx.** In `/home/webklar/apps/ticket.webklar.com/nginx/ticket.webklar.com.conf` den Inhalt von `deploy/nginx-workers.conf` direkt nach dem Serverpuls-Block (`location /api/serverpuls/ { … }`) einfügen, dann `docker exec webklar-ticket nginx -t && docker exec webklar-ticket nginx -s reload`. *Prüfen:* `curl -s https://ticket.webklar.com/api/workers/health` → `{"ok":true,"version":"0.1.0"}`; `curl -s -o /dev/null -w '%{http_code}\n' https://ticket.webklar.com/api/workers/state` → `401`.
12. **[Kenso] Abnahme Bericht und Nachtplan.** Nach 06:30 Berlin holt der erste Tick den heutigen Bericht sofort nach: innerhalb von 2 Minuten nach dem Start kommt `[Workers] Nachtbericht TT.MM.JJJJ` bei `kenso.gri@gmail.com` an (auch Spam prüfen). Vor 06:30 oder zur Wiederholung:
12. **[Kenso] Abnahme Bericht und Nachtplan.** Nach 06:30 Berlin holt der erste Tick den heutigen Bericht sofort nach: innerhalb von 2 Minuten nach dem Start kommt `Hustler-Nachtbericht TT.MM.JJJJ` bei `kenso.gri@gmail.com` an (auch Spam prüfen). Vor 06:30 oder zur Wiederholung:
`timeout 120 docker exec webklar-workers node scripts/jetzt.mjs bericht` und `timeout 120 docker exec webklar-workers node scripts/jetzt.mjs nachtplan` (nur solange `connectedRooms` leer ist). *Erwartet:* die Mail; `nachtplan` druckt `{"planned":[],"skipped":"keine_raeume"}` und erzeugt ein `leerlauf`-Ereignis von Otto in `workersEvents`. Meldet der Bericht `smtp_auth` im Ereignis „Morgenbericht nicht gesendet“: **nicht** wiederholen (der Dienst versucht es erst morgen wieder), Passwort prüfen – wiederholte falsche Logins sperren die Server-IP bei Hetzner.
13. **[Kenso] Abnahme API im Browser** (eingeloggt auf `https://ticket.webklar.com`, Entwicklerkonsole):
```js
@@ -1941,9 +1944,9 @@ Reihenfolge einhalten. **[Claude]** = Schritt, den der ausführende Agent mit de
```
*Erwartet:* `state` 200 mit 19 Workern (solange `connectedRooms` leer ist, sind nur die acht Such-Worker Rita, Kim, Pia, Finn, Ali, Hanna, Sven und Lea grau mit `connected: false`; die übrigen elf, darunter Otto, Bea, Max, Clara, Nora, Theo, Bob, Bianca, Ben, Paul und Arne, sind nicht grau; ein Such-Raum wird erst mit seinem Eintrag in `connectedRooms` angeschlossen, P1-E9), `events` 200 mit dem `leerlauf`-Ereignis, `settings` 200 (du bist Admin). Schlägt schon `/v1/account/jwts` fehl, genügt der Test mit der WOMS-Oberfläche in P2. Antwortet die API trotz Login mit `502 Anmeldedienst nicht erreichbar.` oder `401 Sitzung abgelaufen.`, im Compose-Block `APPWRITE_ENDPOINT=https://appwrite.webklar.com/v1` setzen (wie Serverpuls; der interne Endpunkt hat die JWT-Prüfung nicht bestanden) und `docker compose up -d --no-deps workers` wiederholen.
14. **[Kenso] Serverpuls kennt die App.** Eintrag aus `deploy/serverpuls-apps-eintrag.json` in `/home/webklar/apps/serverpuls/config/apps.json` ergänzen (neben „Traefik“), dann
`cd /home/webklar/apps/serverpuls && git add config/apps.json && git commit -m "apps.json: Workers" && cd /home/webklar/apps && docker compose build serverpuls && docker compose up -d --no-deps serverpuls`. *Prüfen:* `docker logs --tail 5 webklar-serverpuls` ohne Fehler; nach dem nächsten Größenlauf (03:00 Berlin) steht „Workers“ in der App-Tabelle der Serverpuls-Seite.
`cd /home/webklar/apps/serverpuls && git add config/apps.json && git commit -m "apps.json: Hustler" && cd /home/webklar/apps && docker compose build serverpuls && docker compose up -d --no-deps serverpuls`. *Prüfen:* `docker logs --tail 5 webklar-serverpuls` ohne Fehler; nach dem nächsten Größenlauf (03:00 Berlin) steht „Hustler“ in der App-Tabelle der Serverpuls-Seite.
**Update später:** Code geändert: `cd /home/webklar/apps/workers && git pull --ff-only && cd .. && docker compose build workers && docker compose up -d --no-deps workers`. **Nur Prompts geändert** (`prompts/*.md`): `cd /home/webklar/apps/workers && git pull --ff-only` genügt, ohne Neubau und ohne Neustart; der Dienst liest eine Datei beim nächsten Aufruf neu, sobald sich ihre Änderungszeit geändert hat (Bind-Mount `./workers/prompts:/app/prompts:ro`, H5). Eine leere oder fehlende Prompt-Datei lässt die Aufrufe dieser Rolle mit `Prompt leer: <id>` bzw. `Prompt fehlt: <id>` fehlschlagen.
**Rückbau:** `docker compose stop workers`; nginx-Sicherung zurückkopieren und `nginx -s reload`; die Daten in Appwrite bleiben (nichts wird gelöscht).
**Fertig, wenn:** Schritte 7, 9, 10, 11, 12 ihre „Erwartet“-Zeilen erfüllen und ab dem nächsten Morgen um 06:30 der Nachtbericht automatisch kommt. In WOMS ist noch nichts zu sehen (kommt mit P2).
**Fertig, wenn:** Schritte 7, 9, 10, 11, 12 ihre „Erwartet“-Zeilen erfüllen und ab dem nächsten Morgen um 06:30 der Hustler-Nachtbericht automatisch kommt. In WOMS ist noch nichts zu sehen (kommt mit P2).