1727 lines
193 KiB
Markdown
1727 lines
193 KiB
Markdown
# Workers P1 – Dienst-Fundament – Umsetzungsplan
|
||
|
||
> **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:** Ein eigenständiger Node-Dienst `webklar-workers` mit Appwrite-Sammlungen, Nie-doppelt-Register samt Startbestand, Ereignisstrom, Budget-Wächter, Claude-Hülle, Otto (Nachtplan + Morgenbericht) und JWT-geschützter HTTP-API, auf dem P2–P6 aufbauen.
|
||
**Architecture:** Reine Module mit injizierten Abhängigkeiten (`deps = { db, clock, log, claude, notify }`) ohne Netz in Tests; Appwrite ist die einzige Datenhaltung (13 Sammlungen `workers*`, nur Server-Schlüssel), ein Tick alle 60 s mit neustartfesten Tagesmarkern treibt Otto, die API (Port 8092) liest daraus Zustand, Ereignisse und Spur. Betrieb als gehärteter Container `webklar-workers` hinter dem nginx von `ticket.webklar.com`.
|
||
**Tech Stack:** Node 22 (ESM, `node:test`, `node:http`), `node-appwrite@14.1.0`, `@anthropic-ai/sdk@0.135.0`, `nodemailer@10.1.2`, Docker Compose.
|
||
**Spec:** /home/webklar/apps/ticket.webklar.com/dev-workers/docs/superpowers/specs/2026-10-17-workers-design.md · **Schnittstellen:** /home/webklar/apps/ticket.webklar.com/dev-workers/docs/superpowers/plans/2026-10-17-workers-00-uebersicht.md (verbindlich)
|
||
|
||
## Global Constraints
|
||
|
||
- Alle Regeln aus Übersicht §2 gelten.
|
||
- Orte: Repo `knso/workers-dienst` (privat, Benutzer `knso`, **nicht** Org WEBklar); Dev-Checkout `/home/webklar/apps/workers-dev`, Branch `p1-fundament`; Betrieb `/home/webklar/apps/workers`, Branch `main`; Agenten committen lokal und pushen nie.
|
||
- Befehle laufen im Dev-Checkout; Tests: `timeout 300 node --max-old-space-size=1024 --test test/<datei>.test.js`, Gesamtlauf `… --test test/*.test.js`; ESM, keine Semikolons, deutsche Kommentare.
|
||
- Abhängigkeiten in P1 exakt `@anthropic-ai/sdk@0.135.0`, `node-appwrite@14.1.0`, `nodemailer@10.1.2`; spätere Pläne ergänzen `imapflow@2.5.2`, `mailparser@3.9.40`, `playwright-core@1.62.1` (in P1 nicht installieren).
|
||
- Keine echten Anthropic-, SMTP- oder Appwrite-Aufrufe in Tests; DB, Uhr, SDK, Transport und `fetch` kommen per Parameter (`deps`, `sdk`, `createTransport`, `fetchImpl`).
|
||
- 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.
|
||
- 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.
|
||
- Zeit (Europe/Berlin, Tick 60 s): `nachtplan` ab 22:00 (Fenster bis 05:00), `nacht2` ab 01:00, `bericht` ab 06:30; Sendefenster 09:00–16:00 Mo–Fr, Feiertage werden nicht berücksichtigt.
|
||
- Register: `$id` = `sha256(kind + ":" + value)` als Hex, erste 32 Zeichen; Werte werden nie gespeichert; `namplz` ist nur Hinweis (`hint=true`).
|
||
- Alt-Passwörter (`portalPasswort`, `portal_passwort`, `portalPassword`) werden nie gelesen oder übernommen; der Seed liest `customers`/`websiteProjects` nur mit `Query.select`-Whitelist.
|
||
- Keine Schreibzugriffe auf `customers`, `workorders`, `websiteProjects`, `customerPortalAccess`, Appwrite-Benutzer (Übersicht §2.13); der Seed liest dort nur.
|
||
- Secrets (Schlüssel, Passwörter, Tokens) erscheinen nie in Logs, Fehlermeldungen, Ereignissen oder Skriptausgaben; Skripte drucken nur Zähler.
|
||
- Otto macht in P1 keinen Modellaufruf: Nachtplan und Bericht sind deterministischer Code.
|
||
- Container-Werte exakt Übersicht §9 (`mem_limit 768m`, `memswap_limit 768m`, `cpus 1.0`, `NODE_OPTIONS=--max-old-space-size=384`, …); `apk add chromium` kommt erst mit P4.
|
||
- Jede Commit-Nachricht endet mit der Zeile `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.
|
||
|
||
## Review Focus
|
||
|
||
- Portalkunde mit gültigem Appwrite-JWT (Label fehlt oder `customer`) ruft `/state` oder `PUT /settings` → 403 `{error}` ohne Daten; Mitarbeiter ohne `admin` → `PUT /settings` 403, `POST /pause` 200. → Test in **P1-T24**.
|
||
- Widerspruch von einer Freemail-Adresse (`x@gmail.com`) → nur diese Adresse gesperrt, nie `gmail.com`; von `info@mueller.de` → Adresse und Domain `mueller.de`. → Test in **P1-T19**.
|
||
- Dieselbe Firma in Mischschreibweisen (Telefon `+49 (0) 631 123456` / `0631/123456` / `0049 631 123456`; Website `[x](https://www.x.de/)` / `x.de/kontakt`) → je ein identischer Schlüssel; Website `keine`, Facebook-Link, Verzeichnis → kein Domain-Schlüssel; `betrieb.jimdofree.com` → volle Subdomain statt `jimdofree.com`. → Tests in **P1-T16** und **P1-T17**.
|
||
- Absturz zwischen Register-Eintrag und Anlage des Interessenten, Neustart, dieselbe Suche liefert denselben Fund mit derselben `prospectId` (Aufrufer vergeben sie über `prospectIdFor`, E10) → wieder `fresh`; ein anderer Interessent mit gleichem Schlüssel → Treffer. → Test in **P1-T18**.
|
||
- Vier gleichzeitige Claude-Aufrufe (parallele Scouts) und ein Neustart mitten in der Nacht → alle Kosten gebucht (kein verlorenes Update), Warnung/Stopp genau einmal, Nachtplan nicht doppelt. → Tests in **P1-T09**, **P1-T11**, **P1-T26**.
|
||
|
||
## File Structure
|
||
|
||
Repo `/home/webklar/apps/workers-dev/` (Zweck je Datei):
|
||
|
||
| Datei | Verantwortung |
|
||
|---|---|
|
||
| `package.json`, `package-lock.json`, `.gitignore`, `.dockerignore`, `Dockerfile` | exakte Pins, Image `node:22-alpine` |
|
||
| `src/config.js` | Env → Konfiguration; Fehler nennen nur Variablennamen |
|
||
| `src/errors.js` | `HttpError`, `ValidationError`, `NotFoundError` mit `status` |
|
||
| `src/db.js` | Appwrite-Hülle (`node-appwrite`), `ConflictError`, `Query`-Re-Export, Schreibsperre `GESPERRTE_SAMMLUNGEN` |
|
||
| `src/schema.js` | `SCHEMA` (13 Sammlungen nach Übersicht §4), `rowBytes` |
|
||
| `src/roles.js` | `ROLES`, `FLOORS`, `assertCap`, `CapError`, Raum-Helfer |
|
||
| `src/events.js` | `emit` mit Art-/Rollen-Prüfung, strikt aufsteigendem `ts` |
|
||
| `src/settings.js` | Standardwerte §4.1, `getSettings`, `updateSettings` mit Validierung |
|
||
| `src/schedule.js` | `berlinNow`, `runKey`, `isDue`, `inSendWindow`, `berlinStartOfDay` |
|
||
| `src/daily.js` | einziger, serialisierter Schreiber von `workersDaily`, Lauf-Marker |
|
||
| `src/budget.js` | `costOf`, `checkBudget`, `book`, `markStopped`, `formatUsd` |
|
||
| `src/notify.js` | interne Mails an `settings.reportEmail` (Berichtskonto) |
|
||
| `src/claude.js` | `createClaude` mit `ask`, `search`, `batch`; `BudgetStop`; `BOT_VERBOT_DOMAINS` |
|
||
| `src/jobs.js` | Auftragswarteschlange `workersJobs` |
|
||
| `src/register/normalize.js` | Normalisierung (Port aus WOMS `leadDuplicates.js` + neue Funktionen) |
|
||
| `src/register/keys.js` | `keysFor`, `keyId`, `claimKeys`, `isSuppressed`, `suppress` |
|
||
| `src/register/seed.js` | reine Seed-Bausteine für Alt-Leads und Kunden |
|
||
| `src/otto/nachtplan.js` | `planNight`: Räume/Regionen wählen, `suche`-Jobs, `auftrag`-Ereignisse |
|
||
| `src/otto/bericht.js` | `buildReport`, `sendReport` (Morgenbericht) |
|
||
| `src/view.js` | `buildState`, `buildEvents`, `buildTrace` für die API |
|
||
| `src/api.js` | HTTP-Server, JWT-Prüfung, Routen |
|
||
| `src/tick.js` | `createTick`: Job-Tabelle, Marker, Pause-Regeln |
|
||
| `src/main.js` | `buildDeps`, `start`, Graceful Shutdown |
|
||
| `scripts/setup-collections.mjs` | Sammlungen anlegen, `--check` |
|
||
| `scripts/seed-register.mjs` | Startbestand, `--dry-run` |
|
||
| `scripts/check-modelle.mjs` | manuelle Prüfung `web_search`-Fähigkeit |
|
||
| `scripts/jetzt.mjs` | Bericht/Nachtplan sofort auslösen (Abnahme) |
|
||
| `deploy/compose-workers.yml`, `deploy/nginx-workers.conf`, `deploy/serverpuls-apps-eintrag.json` | Text-Bausteine für Kenso |
|
||
| `test/*.test.js`, `test/support/{helpers,fakeDb,fakeSdk,apiBoot}.js`, `test/fixtures/*` | Tests, Fakes, Fixtures |
|
||
|
||
## Ergänzung zur Übersicht
|
||
|
||
> Diese Punkte fehlen in `…-00-uebersicht.md`, P1 braucht sie und legt sie so fest. Der Plan-Verantwortliche übernimmt sie in die Übersicht; P2–P6 dürfen sich darauf stützen.
|
||
>
|
||
> - **E1 Schema:** §4-Felder mit `s(n)`, n ≥ 8000 (`workersJobs.payloadJson/resultJson`, `workersMessages.body`, `workersPult.originalText/draft`, `workersPreviews.reviewNotes`) werden mit `size: 20000` angelegt (Appwrite-TEXT; sonst „Row width limit reached“, 65 535 Byte je Zeile). Das §4-Maximum steht als `max` im Schema und wird von den Schreibern eingehalten. Zusätzliche Indizes (Zählabfragen und Auftragssuche): `workersJobs.idx_kind_status` (`kind`+`status`) und `workersThreads.idx_status` (`status`).
|
||
> - **E2 Rollen:** `ROLES` = Array in Tabellenreihenfolge §3 aus `{ workerId, name, floor, room, model:'opus'|'sonnet'|'haiku', role, caps }`; `FLOORS` = Array der 10 Etagen-Strings; Fixture `workers-roster.json` = `{ "floors": FLOORS, "roles": ROLES }`; `MODELS` = `{ opus:'claude-opus-5-5', sonnet:'claude-sonnet-5-5', haiku:'claude-haiku-5-5' }`; Zusätze `SEARCH_ROOMS`, `workerForRoom(room)`, `roomLabel(room)`, `hasCap`. `trace.worker` = ROLES-Eintrag + `modelId` (voller Modellname).
|
||
> - **E3 deps/DB:** `clock = { now(): number }` (ms); `log(msg)`; `notify({ subject, text }) → { sent, reason? }` (neu, `src/notify.js`, sendet an `settings.reportEmail` über `WORKERS_REPORT_SMTP_*`); `db.get` → Dokument oder `null`; `db.list`/`listAll` → Array; neu `db.count(col, queries) → number`; `Query` kommt aus `src/db.js`.
|
||
> - **E4 Tageszähler/Zeit:** neue Datei `src/daily.js` ist der einzige Schreiber von `workersDaily` (serialisiert); `book` addiert `counters` je Worker, die Schlüssel `mailsCold, mailsReply, searches, found, contacted, deals` zusätzlich in die Tagesfelder; Marker in `countersJson.__runs`. `isDue(kind, now, last)`: `now` = Ergebnis von `berlinNow(clock)`; `last` = Lauf-Schlüssel (`runKey`: Tag, bei `nachtplan`/`nacht2` der Tag des 22:00-Starts) bzw. ms-Zeitstempel bei Intervallarten.
|
||
> - **E5 Budget:** `checkBudget(deps, estUsd, { reply = false })`; `reply:true` (Gespräche: Nora, Max, Clara, Theo) ignoriert nur die Tagesgrenze (Spec 8.10), nie Monat, Pause oder Console-Stopp; `ask`/`search` nehmen `reply`; neu `markStopped(deps, reason)`; `costOf` kennt den Haiku-Langprompt-Tarif. Folge für P4: Die Pause blockiert auch Noras Einordnung (Modellaufruf); der Posteingang wird in der Pause nur abgelegt und nach Aufhebung **vor jedem Versand** eingeordnet.
|
||
> - **E6 Claude:** `createClaude({ apiKey, sdk, searchModel })`; `search` liefert zusätzlich `incomplete` (Fortsetzungsgrenze erreicht) und `toolErrors: [{ tool, code, url? }]` (z. B. `url_not_allowed` bei robots.txt ⇒ Quelle auslassen); Env `WORKERS_SEARCH_MODEL` (optional; Fallback `claude-sonnet-5-5`, falls Haiku kein `web_search` kann).
|
||
> - **E7 Register:** `keysFor` akzeptiert für `website`/`email`/`phone`/`name` auch Arrays; `claimKeys(…, { blocked:true })` setzt `blocked` auch auf bereits vorhandenen Schlüsseln; gleiche `prospectId` auf vorhandenem Schlüssel ist kein Treffer (Wiederholung nach Absturz); `isSuppressed` prüft zusätzlich `workersKeys` mit `blocked=true` (Kunden, 2 kontaktierte Alt-Leads); `suppress`/`isSuppressed` leiten die Domain aus einer Firmen-Adresse ab, nie aus einer Freemail-Adresse.
|
||
> - **E8 Jobs:** `fail(deps, jobId, error, { retry = true })` (nach 3 Versuchen `fehler`), `reclaimStale(deps, { olderThanMs })` (hängende `laeuft`-Jobs zurück auf `offen`); Otto plant nur `suche`-Jobs (Rita, Finn, Ali, Hanna, Sven, Lea); Kim und Pia treibt der Tick `alarme` (P3).
|
||
> - **E9 API:** `trace.events` **neueste zuerst**, `trace.today.tokens` = `{ in, out }`; `GET /events` ohne `after` liefert die letzten `limit` Ereignisse aufsteigend; Zeitstempel (`ts`, `lastEventTs`) immer als ISO mit `Z` (Appwrite liefert `…+00:00`, ein ungeschütztes `+` in `?after=` würde zum Leerzeichen); Fehlertexte: 401 „Nicht angemeldet.“ / „Sitzung abgelaufen.“, 403 „Kein Zugriff.“ / „Nur für Admins.“, 404 „Nicht gefunden.“, 502 „Anmeldedienst nicht erreichbar.“; `connected` eines Workers = sein Raum ist `bruecke` oder steht in `settings.connectedRooms` (alle Räume, nicht nur Suchräume).
|
||
> - **E10 Interessenten-ID:** `prospectIdFor(keys)` (`src/register/keys.js`) = `'p'` + erste 30 Zeichen von `keyId` des ersten harten Schlüssels (`kind` ≠ `namplz`), `''` ohne harten Schlüssel (dann `ID.unique()`). P3/P6 benutzen sie als `workersProspects.$id` und `claimKeys`-`prospectId`, damit eine Wiederholung nach einem Absturz dieselbe ID liefert (E7).
|
||
> - **E11 Harte Sperren im Code:** `GESPERRTE_SAMMLUNGEN` (`src/db.js`: `customers`, `workorders`, `websiteProjects`, `customerPortalAccess`) – `db.create/update/remove` darauf wirft (Übersicht §2.13); `BOT_VERBOT_DOMAINS` (`src/claude.js`) – `search` setzt sie immer als `blocked_domains` des `web_fetch`-Werkzeugs (Übersicht §2.11: Plattformen mit Bot-Verbot nie direkt abrufen; Treffer der Websuche bleiben erlaubt); `getSettings` begrenzt `mailColdPerDay` auf 3 und `mailReplyCapPerDay` auf 15 auch bei von Hand geändertem Dokument.
|
||
|
||
---
|
||
|
||
### P1-T01: Gerüst, Testhelfer, Config
|
||
|
||
**Files:**
|
||
- Create: `/home/webklar/apps/workers-dev/package.json`, `.gitignore`, `src/config.js`, `test/support/helpers.js` (zunächst `fakeClock` und `near`), `package-lock.json` (generiert)
|
||
- Test: `test/config.test.js`
|
||
|
||
**Interfaces:**
|
||
- Produces: `loadConfig(env) → { appwrite:{endpoint,projectId,apiKey}, anthropicKey, searchModel, api:{port:8092}, vorschau:{port:8093,root:'/data/vorschau',baseUrl:'https://entwurf.project.webklar.com'}, smtp, imap, alertImap, reportSmtp, places, tz:'Europe/Berlin' }`, `ConfigError` (`.missing: string[]`), `fakeClock(iso) → { now(), set(isoOrMs), advance(ms) }`, `near(a, b)` (wirft, wenn `|a − b| ≥ 1e-9`; gemeinsam für alle Kostentests).
|
||
- Consumes: —
|
||
|
||
- [ ] **Step 1: Gerüst anlegen und Test schreiben.**
|
||
`mkdir -p /home/webklar/apps/workers-dev && cd /home/webklar/apps/workers-dev && git init -b p1-fundament`. `package.json`:
|
||
```json
|
||
{ "name": "workers-dienst", "version": "0.1.0", "private": true, "type": "module", "engines": { "node": ">=22" },
|
||
"scripts": { "test": "node --max-old-space-size=1024 --test test/*.test.js", "start": "node src/main.js" },
|
||
"dependencies": { "@anthropic-ai/sdk": "0.135.0", "node-appwrite": "14.1.0", "nodemailer": "10.1.2" } }
|
||
```
|
||
`.gitignore`: `node_modules`, `.env`, `data/`. Dann `NODE_OPTIONS=--max-old-space-size=1024 timeout 300 npm install --ignore-scripts` (erzeugt `package-lock.json`; prüfen: `rg -m1 '"version"' node_modules/node-appwrite/package.json` zeigt `14.1.0`). `test/support/helpers.js`: `fakeClock(iso = '2026-10-19T09:00:00.000Z')` mit `now()` (ms), `set(isoOrMs)`, `advance(ms)`, dazu `near(a, b) = assert.ok(Math.abs(a - b) < 1e-9, `${a} != ${b}`)`. Test:
|
||
```js
|
||
import test from 'node:test'
|
||
import assert from 'node:assert/strict'
|
||
import { loadConfig, ConfigError } from '../src/config.js'
|
||
const BASE = { APPWRITE_ENDPOINT: 'http://webklar-appwrite/v1', APPWRITE_PROJECT_ID: '6a1058610003c5a13a05', APPWRITE_API_KEY: 'aw-geheim-1', ANTHROPIC_API_KEY: 'sk-geheim-2' }
|
||
const REPORT = { WORKERS_REPORT_SMTP_HOST: 'mail.example', WORKERS_REPORT_SMTP_PORT: '587', WORKERS_REPORT_SMTP_USER: 'u', WORKERS_REPORT_SMTP_PASS: 'p', WORKERS_REPORT_SMTP_FROM: 'a@example.de' }
|
||
|
||
test('fehlende Pflichtnamen: Fehler nennt nur Namen, nie Werte', () => {
|
||
assert.throws(() => loadConfig({ ...BASE, APPWRITE_API_KEY: '', ANTHROPIC_API_KEY: ' ' }), (e) => {
|
||
assert.ok(e instanceof ConfigError)
|
||
assert.deepEqual(e.missing, ['APPWRITE_API_KEY', 'ANTHROPIC_API_KEY'])
|
||
assert.ok(!e.message.includes('geheim'))
|
||
return true
|
||
})
|
||
})
|
||
test('Festwerte; nicht gesetzte Gruppen sind null', () => {
|
||
const c = loadConfig(BASE)
|
||
assert.equal(c.api.port, 8092)
|
||
assert.deepEqual(c.vorschau, { port: 8093, root: '/data/vorschau', baseUrl: 'https://entwurf.project.webklar.com' })
|
||
assert.equal(c.tz, 'Europe/Berlin'); assert.equal(c.searchModel, ''); assert.equal(c.anthropicKey, 'sk-geheim-2')
|
||
for (const g of ['smtp', 'imap', 'alertImap', 'reportSmtp', 'places']) assert.equal(c[g], null)
|
||
})
|
||
test('halb gesetzte Gruppe: Fehler nennt die fehlenden Namen der Gruppe', () => {
|
||
assert.throws(() => loadConfig({ ...BASE, WORKERS_REPORT_SMTP_HOST: 'mail.example', WORKERS_REPORT_SMTP_USER: 'u' }),
|
||
(e) => { assert.deepEqual(e.missing, ['WORKERS_REPORT_SMTP_PORT', 'WORKERS_REPORT_SMTP_PASS', 'WORKERS_REPORT_SMTP_FROM']); return true })
|
||
})
|
||
test('vollständige Gruppe: Port als Zahl; ungültiger Port nennt den Namen', () => {
|
||
assert.deepEqual(loadConfig({ ...BASE, ...REPORT }).reportSmtp, { host: 'mail.example', port: 587, user: 'u', pass: 'p', from: 'a@example.de' })
|
||
assert.throws(() => loadConfig({ ...BASE, ...REPORT, WORKERS_REPORT_SMTP_PORT: 'abc' }), (e) => e.missing.includes('WORKERS_REPORT_SMTP_PORT'))
|
||
})
|
||
```
|
||
- [ ] **Step 2: Test laufen lassen, Fehlschlag sehen.** `cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/config.test.js` → FAIL `ERR_MODULE_NOT_FOUND` (`src/config.js`).
|
||
- [ ] **Step 3: Implementieren.** `src/config.js`: `loadConfig(env = process.env)`; Werte werden getrimmt, leer = fehlt. Pflicht: `APPWRITE_ENDPOINT`, `APPWRITE_PROJECT_ID`, `APPWRITE_API_KEY`, `ANTHROPIC_API_KEY`. Gruppen (Reihenfolge je Gruppe: HOST, PORT, USER, PASS, FROM): `smtp` = `WORKERS_SMTP_*` (mit FROM), `imap` = `WORKERS_IMAP_*` (ohne FROM), `alertImap` = `WORKERS_ALERT_IMAP_*` (ohne FROM), `reportSmtp` = `WORKERS_REPORT_SMTP_*` (mit FROM), `places` = `WORKERS_PLACES_API_KEY` → `{ apiKey }`. Komplett ungesetzt → `null`; teilweise gesetzt oder `PORT` keine ganze Zahl 1–65535 → `ConfigError` mit `.missing` in genannter Reihenfolge, Pflichtnamen zuerst; `ConfigError.message` = `Konfiguration unvollständig: ` + Namenliste. `searchModel` = `WORKERS_SEARCH_MODEL` oder `''`.
|
||
- [ ] **Step 4: Test laufen lassen.** Gleicher Befehl → PASS (4 Tests).
|
||
- [ ] **Step 5: Commit.** `git add package.json package-lock.json .gitignore src/config.js test/support/helpers.js test/config.test.js && git commit -m "P1-T01: Gerüst, Config, Testhelfer" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"`
|
||
|
||
---
|
||
|
||
### P1-T02: Schema der 13 Sammlungen
|
||
|
||
**Files:**
|
||
- Create: `src/schema.js`
|
||
- Test: `test/schema.test.js`
|
||
|
||
**Interfaces:**
|
||
- Produces: `SCHEMA: [{ id, name, attributes:[{ key, type:'string'|'integer'|'float'|'boolean'|'datetime'|'enum', size?, required, array, elements?, default?, max? }], indexes:[{ key, type:'key'|'unique', attributes:[…] }] }]` (Übersicht §6), `TEXT_SIZE = 20000`, `rowBytes(collection) → number`, `collectionDef(id)`.
|
||
- Consumes: —
|
||
|
||
- [ ] **Step 1: Test schreiben.**
|
||
```js
|
||
import { SCHEMA, rowBytes, TEXT_SIZE, collectionDef } from '../src/schema.js'
|
||
const IDS = ['workersSettings', 'workersProspects', 'workersKeys', 'workersSuppression', 'workersSearchLog', 'workersJobs', 'workersEvents', 'workersDaily', 'workersThreads', 'workersMessages', 'workersPult', 'workersOffers', 'workersPreviews']
|
||
const attr = (c, k) => collectionDef(c).attributes.find((a) => a.key === k)
|
||
|
||
test('13 Sammlungen in Übersichts-Reihenfolge', () => assert.deepEqual(SCHEMA.map((c) => c.id), IDS))
|
||
test('Attribut-Schlüssel je Sammlung exakt nach §4', () => {
|
||
const KEYS = {
|
||
workersSettings: ['paused', 'dailyBudgetUsd', 'monthlyBudgetUsd', 'warnPct', 'mailColdPerDay', 'mailReplyCapPerDay', 'sendWindowStart', 'sendWindowEnd', 'sendWeekdaysOnly', 'runsPerNight', 'candidatesPerRun', 'activeRooms', 'connectedRooms', 'regions', 'previewGate', 'previewGateN', 'previewsSent', 'offerText', 'offerProductKey', 'pultStaleDays', 'retentionDays', 'followupDays', 'previewReminderDays', 'previewDeleteDays', 'reportEmail', 'updatedAt', 'updatedBy'],
|
||
workersProspects: ['entrance', 'room', 'foundBy', 'source', 'sourceUrl', 'foundAt', 'name', 'contactName', 'email', 'emailSource', 'phone', 'phoneSource', 'website', 'street', 'postalCode', 'city', 'placeId', 'requestText', 'requestedAt', 'score', 'scoreReason', 'status', 'botLeadReason', 'threadId', 'previewId', 'offerId', 'customerId', 'isHealth', 'purgeAfter', 'purgedAt', 'updatedAt'],
|
||
workersKeys: ['kind', 'prospectId', 'origin', 'blocked', 'hint', 'createdAt'],
|
||
workersSuppression: ['kind', 'reason', 'createdAt', 'source'],
|
||
workersSearchLog: ['workerId', 'room', 'region', 'query', 'at', 'found', 'fresh'],
|
||
workersJobs: ['kind', 'workerId', 'status', 'prospectId', 'threadId', 'previewId', 'attempts', 'batchId', 'customId', 'payloadJson', 'resultJson', 'error', 'createdAt', 'startedAt', 'finishedAt'],
|
||
workersEvents: ['ts', 'workerId', 'kind', 'jobId', 'prospectId', 'text', 'link', 'to', 'tokensIn', 'tokensOut', 'costUsd', 'metaJson'],
|
||
workersDaily: ['costUsd', 'mailsCold', 'mailsReply', 'searches', 'found', 'contacted', 'deals', 'countersJson', 'warned', 'stopped'],
|
||
workersThreads: ['prospectId', 'channel', 'platform', 'platformUrl', 'toEmail', 'subject', 'status', 'lastInboundAt', 'lastOutboundAt', 'followupDueAt', 'followupSent', 'reminderDueAt', 'reminderSent', 'createdAt'],
|
||
workersMessages: ['threadId', 'direction', 'author', 'subject', 'body', 'messageId', 'inReplyTo', 'referencesJson', 'sentAt', 'channel', 'contactSource', 'classification', 'classificationConfidence', 'countsAsCold', 'createdAt'],
|
||
workersPult: ['kind', 'prospectId', 'threadId', 'previewId', 'title', 'originalText', 'draft', 'sourceUrl', 'status', 'claimedBy', 'claimedAt', 'doneBy', 'doneAt', 'createdAt', 'staleAt'],
|
||
workersOffers: ['tokenHash', 'prospectId', 'previewId', 'productKey', 'businessName', 'email', 'previewUrl', 'status', 'expiresAt', 'stripeSessionId', 'customerId', 'paidAt', 'createdAt'],
|
||
workersPreviews: ['token', 'prospectId', 'builder', 'status', 'round', 'reviewNotes', 'filesJson', 'createdAt', 'deleteAfter', 'convertedProjectId'] }
|
||
for (const [id, keys] of Object.entries(KEYS)) assert.deepEqual(collectionDef(id).attributes.map((a) => a.key), keys, id)
|
||
})
|
||
test('Regeln: id ≤ 36, kein Feld data oder $…, Index zeigt auf vorhandene Felder, Indexschlüssel ≤ 36, Pflichtfeld ohne default', () => {
|
||
for (const c of SCHEMA) {
|
||
assert.ok(c.id.length <= 36)
|
||
const keys = new Set(c.attributes.map((a) => a.key))
|
||
for (const a of c.attributes) { assert.ok(a.key !== 'data' && !a.key.startsWith('$')); assert.ok(!(a.required && a.default !== undefined), `${c.id}.${a.key}`) }
|
||
for (const i of c.indexes) { assert.ok(i.key.length <= 36); for (const k of i.attributes) assert.ok(keys.has(k), `${c.id}.${i.key}`) }
|
||
}
|
||
})
|
||
test('Spotchecks §4', () => {
|
||
assert.equal(attr('workersProspects', 'status').elements.length, 12)
|
||
assert.deepEqual(attr('workersJobs', 'kind').elements.length, 15)
|
||
assert.deepEqual(collectionDef('workersOffers').indexes.find((i) => i.attributes.join() === 'tokenHash').type, 'unique')
|
||
assert.ok(collectionDef('workersPult').indexes.some((i) => i.attributes.join() === 'status,createdAt'))
|
||
assert.ok(collectionDef('workersJobs').indexes.some((i) => i.key === 'idx_kind_status' && i.attributes.join() === 'kind,status')) // E1
|
||
assert.ok(collectionDef('workersThreads').indexes.some((i) => i.key === 'idx_status' && i.attributes.join() === 'status')) // E1
|
||
assert.equal(attr('workersSettings', 'mailColdPerDay').type, 'integer')
|
||
assert.equal(attr('workersSettings', 'activeRooms').array, true)
|
||
})
|
||
test('E1: lange Felder sind physisch TEXT, §4-Maximum bleibt als max', () => {
|
||
for (const [c, k, max] of [['workersJobs', 'payloadJson', 16000], ['workersJobs', 'resultJson', 16000], ['workersMessages', 'body', 16000], ['workersPult', 'originalText', 8000], ['workersPult', 'draft', 8000], ['workersPreviews', 'reviewNotes', 8000]]) {
|
||
assert.equal(attr(c, k).size, TEXT_SIZE); assert.equal(attr(c, k).max, max)
|
||
}
|
||
assert.equal(TEXT_SIZE, 20000)
|
||
})
|
||
test('rowBytes: Formel und Grenze', () => {
|
||
assert.equal(rowBytes({ attributes: [{ key: 'a', type: 'string', size: 256 }] }), 1026) // 4*256+2
|
||
assert.equal(rowBytes({ attributes: [{ key: 'a', type: 'string', size: 64 }] }), 257) // 4*64+1
|
||
assert.equal(rowBytes({ attributes: [{ key: 'a', type: 'string', size: 20000 }] }), 20) // TEXT
|
||
assert.ok(rowBytes({ attributes: [{ key: 'a', type: 'string', size: 16000 }, { key: 'b', type: 'string', size: 16000 }] }) > 65535) // darum E1
|
||
for (const c of SCHEMA) assert.ok(rowBytes(c) <= 60000, c.id)
|
||
})
|
||
```
|
||
- [ ] **Step 2: Fehlschlag sehen.** `cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/schema.test.js` → FAIL `ERR_MODULE_NOT_FOUND` (`src/schema.js`).
|
||
- [ ] **Step 3: Implementieren.** `src/schema.js`: Mini-DSL `s(key,size,opts)`, `i`, `f`, `b`, `dt`, `e(key,elements,opts)` und `idx(attrs…)`; `SCHEMA` Sammlung für Sammlung aus Übersicht §4 abschreiben (Mapping `s(n)`→`string`+`size`, `i`→`integer`, `f`→`float`, `b`→`boolean`, `dt`→`datetime`, `e[…]`→`enum`+`elements`, `[]`→`array:true`, `*`→`required:true`, sonst `required:false`; Reihenfolge wie §4; Indizes aus §4 mit Schlüssel `idx_<attr>_<attr>`, `tokenHash` als `unique`; Zusatzindizes aus E1 mit den Schlüsseln `idx_kind_status` und `idx_status`). `name` = Sammlungs-ID. Felder aus E1 mit `size: TEXT_SIZE` und `max`. `rowBytes(c)`: je Attribut Array → 20; `string` > 16381 → 20, sonst `size*4 + (size > 255 ? 2 : 1)`; `enum` → `längstes Element*4 + 1`; `integer`/`float`/`datetime` → 8; `boolean` → 1; Summe.
|
||
- [ ] **Step 4: Test laufen lassen.** Gleicher Befehl → PASS (6 Tests).
|
||
- [ ] **Step 5: Commit.** `git add src/schema.js test/schema.test.js && git commit -m "P1-T02: Schema der 13 Workers-Sammlungen" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"`
|
||
|
||
---
|
||
|
||
### P1-T03: Setup-Skript für die Sammlungen
|
||
|
||
**Files:**
|
||
- Create: `scripts/setup-collections.mjs`
|
||
- Test: `test/setupCollections.test.js`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `SCHEMA` (T02).
|
||
- Produces: `main({ check, client, log, schema = SCHEMA }) → { missingCollections:[id], missingAttributes:[{collection,key}], missingIndexes:[{collection,key}] }`; `exitCodeFor(result, check) → 0|1`; `attributeCall(collectionId, attr) → [methodName, args]` (SDK-Aufruf, `args` beginnt mit `'woms-database'`); `createSetupClient({ endpoint, projectId, apiKey }, { sdk }) → { collectionExists(id), listAttributes(id), listIndexes(id), createCollection(id, name), createAttribute(id, attr), waitForAttribute(id, key), createIndex(id, index) }`.
|
||
|
||
- [ ] **Step 1: Test schreiben.** Fake-Client im Test: Objekt `existing[id] = { attrs:[Schlüssel], idx:[Schlüssel] }`, protokolliert schreibende Aufrufe in `calls`.
|
||
```js
|
||
import { main, exitCodeFor, attributeCall, createSetupClient } from '../scripts/setup-collections.mjs'
|
||
import { SCHEMA } from '../src/schema.js'
|
||
function fakeClient(existing = {}) {
|
||
const calls = []
|
||
const st = structuredClone(existing) // { id: { attrs: [keys], idx: [keys] } }
|
||
return { calls, st,
|
||
collectionExists: async (id) => id in st,
|
||
listAttributes: async (id) => (st[id]?.attrs ?? []).map((key) => ({ key, status: 'available' })),
|
||
listIndexes: async (id) => (st[id]?.idx ?? []).map((key) => ({ key })),
|
||
createCollection: async (id, name) => { calls.push(['createCollection', id, name]); st[id] = { attrs: [], idx: [] } },
|
||
createAttribute: async (id, a) => { calls.push(['createAttribute', id, a.key]); st[id].attrs.push(a.key) },
|
||
waitForAttribute: async (id, key) => { calls.push(['wait', id, key]) },
|
||
createIndex: async (id, i) => { calls.push(['createIndex', id, i.key]); st[id].idx.push(i.key) } }
|
||
}
|
||
test('--check meldet alles als fehlend, ändert nichts, Exit 1', async () => {
|
||
const c = fakeClient(); const lines = []
|
||
const r = await main({ check: true, client: c, log: (l) => lines.push(l) })
|
||
assert.equal(r.missingCollections.length, 13); assert.equal(c.calls.length, 0)
|
||
assert.equal(exitCodeFor(r, true), 1); assert.ok(lines.some((l) => l.includes('workersJobs') && l.includes('FEHLT')))
|
||
})
|
||
test('Lauf legt an: Reihenfolge Sammlung → Attribute → warten → Indizes; zweiter Lauf und --check sind leer', async () => {
|
||
const c = fakeClient(); await main({ check: false, client: c, log: () => {} })
|
||
const ops = c.calls.map((x) => x[0]); assert.equal(ops.filter((o) => o === 'createCollection').length, 13)
|
||
const iJobs = c.calls.findIndex((x) => x[0] === 'createIndex' && x[1] === 'workersJobs')
|
||
assert.ok(c.calls.slice(0, iJobs).some((x) => x[0] === 'wait' && x[1] === 'workersJobs'))
|
||
const n = c.calls.length; await main({ check: false, client: c, log: () => {} }); assert.equal(c.calls.length, n)
|
||
assert.equal(exitCodeFor(await main({ check: true, client: c, log: () => {} }), true), 0)
|
||
})
|
||
test('fehlendes Attribut in vorhandener Sammlung wird nachgezogen, sonst nichts', async () => {
|
||
const full = Object.fromEntries(SCHEMA.map((s) => [s.id, { attrs: s.attributes.map((a) => a.key), idx: s.indexes.map((i) => i.key) }]))
|
||
full.workersDaily.attrs = full.workersDaily.attrs.filter((k) => k !== 'stopped')
|
||
const c = fakeClient(full); await main({ check: false, client: c, log: () => {} })
|
||
assert.deepEqual(c.calls.filter((x) => x[0] === 'createAttribute'), [['createAttribute', 'workersDaily', 'stopped']])
|
||
})
|
||
test('attributeCall: Abbildung auf node-appwrite 14 (Positionsargumente)', () => {
|
||
assert.deepEqual(attributeCall('c', { key: 'a', type: 'string', size: 64, required: true }), ['createStringAttribute', ['woms-database', 'c', 'a', 64, true, undefined, false]])
|
||
assert.deepEqual(attributeCall('c', { key: 'a', type: 'enum', elements: ['x', 'y'], required: false }), ['createEnumAttribute', ['woms-database', 'c', 'a', ['x', 'y'], false, undefined, false]])
|
||
assert.deepEqual(attributeCall('c', { key: 'a', type: 'string', size: 32, array: true }), ['createStringAttribute', ['woms-database', 'c', 'a', 32, false, undefined, true]])
|
||
assert.equal(attributeCall('c', { key: 'a', type: 'integer' })[0], 'createIntegerAttribute')
|
||
assert.equal(attributeCall('c', { key: 'a', type: 'float' })[0], 'createFloatAttribute')
|
||
assert.equal(attributeCall('c', { key: 'a', type: 'boolean' })[0], 'createBooleanAttribute')
|
||
assert.equal(attributeCall('c', { key: 'a', type: 'datetime' })[0], 'createDatetimeAttribute')
|
||
})
|
||
test('echter Client fragt Attribute/Indizes mit limit(200) (Standard wären 25) und legt Sammlungen ohne Rechte an', async () => {
|
||
const calls = []; const page = { attributes: [{ key: 'a', status: 'available' }], indexes: [{ key: 'i' }] }
|
||
class Databases { async listAttributes(...a) { calls.push(['listAttributes', ...a]); return page } async listIndexes(...a) { calls.push(['listIndexes', ...a]); return page } async createCollection(...a) { calls.push(['createCollection', ...a]) } }
|
||
class Client { setEndpoint() { return this } setProject() { return this } setKey() { return this } }
|
||
const c = createSetupClient({ endpoint: 'e', projectId: 'p', apiKey: 'k' }, { sdk: { Client, Databases } })
|
||
await c.listAttributes('workersJobs'); await c.listIndexes('workersJobs'); await c.createCollection('workersJobs', 'workersJobs')
|
||
assert.deepEqual(calls, [['listAttributes', 'woms-database', 'workersJobs', [Query.limit(200)]], ['listIndexes', 'woms-database', 'workersJobs', [Query.limit(200)]], ['createCollection', 'woms-database', 'workersJobs', 'workersJobs', [], false, true]])
|
||
})
|
||
```
|
||
(Import im Test: `import { Query } from 'node-appwrite'`; `src/db.js` entsteht erst in T04.)
|
||
- [ ] **Step 2: Fehlschlag sehen.** `cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/setupCollections.test.js` → FAIL `ERR_MODULE_NOT_FOUND` (`scripts/setup-collections.mjs`).
|
||
- [ ] **Step 3: Implementieren.** `main`: erst alles prüfen (Sammlung, Attribute, Indizes), Zeilen `+ Collection <id>` / `= <id>: n Attribute, m Indizes vorhanden` / `- <id>.<key> FEHLT` (Muster `/home/webklar/apps/kundenbereich.webklar.com/build/scripts/unterkunft-setup.mjs`; fehlt die Sammlung, druckt `--check` `- Collection <id> FEHLT` und danach je Attribut/Index eine `FEHLT`-Zeile); mit `check` hier zurückkehren (Schlusszeile `Alles vorhanden.` bzw. `<n> Eintrag/Einträge fehlen.`); sonst je Sammlung `createCollection(id, id)`, fehlende Attribute anlegen, **danach** für jedes neue Attribut `waitForAttribute`, erst dann fehlende Indizes; Schlusszeile `Fertig.`. `attributeCall` nach den Signaturen in `node_modules/node-appwrite/dist/services/databases.d.ts` (`string`: `(db,col,key,size,required,default,array)`; `enum`: `(db,col,key,elements,required,default,array)`; `integer`/`float`: `(db,col,key,required,min,max,default,array)`; `boolean`/`datetime`: `(db,col,key,required,default,array)`); `required`/`array` default `false`. `createSetupClient` mit `sdk.Client/Databases`, `listAttributes`/`listIndexes` mit `Query.limit(200)`, `createCollection(DB, id, name, [], false, true)`, `waitForAttribute` pollt `getAttribute` jede Sekunde bis `available` (60 s, `failed` wirft). CLI (nur wenn direkt gestartet): `--check`; Env `APPWRITE_ENDPOINT` (Standard `https://appwrite.webklar.com/v1`), `APPWRITE_PROJECT_ID` (Standard `6a1058610003c5a13a05`), `APPWRITE_API_KEY` (fehlt → Meldung mit dem Namen, Exit 2); Exit `exitCodeFor`.
|
||
- [ ] **Step 4: Test laufen lassen.** Gleicher Befehl → PASS (5 Tests).
|
||
- [ ] **Step 5: Commit.** `git add scripts/setup-collections.mjs test/setupCollections.test.js && git commit -m "P1-T03: Setup-Skript mit --check" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"`
|
||
|
||
---
|
||
|
||
### P1-T04: Appwrite-Hülle `db.js` und schema-prüfende Fake-DB
|
||
|
||
**Files:**
|
||
- Create: `src/db.js`, `test/support/fakeDb.js`
|
||
- Modify: `test/support/helpers.js` (ergänzt `makeDeps`)
|
||
- 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 }`.
|
||
- `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`):
|
||
```js
|
||
const mkSdk = (fns, calls = []) => ({ Client: class { setEndpoint() { return this } setProject() { return this } setKey() { return this } }, ID: { unique: () => 'uniq1' },
|
||
Databases: class { constructor() { for (const [k, f] of Object.entries(fns)) this[k] = async (...a) => { calls.push([k, ...a]); return f(...a) } } } })
|
||
const err = (code) => Object.assign(new Error('x'), { code })
|
||
const cfg = { endpoint: 'e', projectId: 'p', apiKey: 'k' }
|
||
test('create reicht Datenbank, Sammlung, ID und Daten durch; ohne ID ID.unique(); 409 → ConflictError, sonst unverändert', async () => {
|
||
const calls = []; const db = createDb(cfg, { sdk: mkSdk({ createDocument: async (d, c, id, data) => { if (data.boom) throw err(data.boom); return { $id: id, ...data } } }, calls) })
|
||
assert.deepEqual(await db.create('workersKeys', { a: 1 }, 'x'), { $id: 'x', a: 1 }); assert.deepEqual(calls[0], ['createDocument', DATABASE_ID, 'workersKeys', 'x', { a: 1 }])
|
||
assert.equal((await db.create('workersKeys', { a: 1 })).$id, 'uniq1')
|
||
await assert.rejects(db.create('workersKeys', { boom: 409 }, 'x'), (e) => e instanceof ConflictError && e.code === 409)
|
||
const e500 = await db.create('workersKeys', { boom: 500 }, 'x').catch((e) => e); assert.ok(!(e500 instanceof ConflictError)); assert.equal(e500.code, 500)
|
||
})
|
||
test('get: gefunden → Dokument; 404 → null; anderer Fehler wirft', async () => {
|
||
const db = createDb(cfg, { sdk: mkSdk({ getDocument: async (d, c, id) => { if (id === 'weg') throw err(404); if (id === 'kaputt') throw err(500); return { $id: id } } }) })
|
||
assert.deepEqual(await db.get('c', 'a'), { $id: 'a' }); assert.equal(await db.get('c', 'weg'), null); await assert.rejects(db.get('c', 'kaputt'), (e) => e.code === 500)
|
||
})
|
||
test('list liefert documents; count liefert total mit Query.limit(1)', async () => {
|
||
const calls = []; const db = createDb(cfg, { sdk: mkSdk({ listDocuments: async () => ({ total: 7, documents: [{ $id: 'a' }] }) }, calls) })
|
||
assert.deepEqual(await db.list('c', [Query.equal('s', ['x'])]), [{ $id: 'a' }]); assert.equal(await db.count('c', [Query.equal('s', ['x'])]), 7)
|
||
assert.deepEqual(calls[1], ['listDocuments', DATABASE_ID, 'c', [Query.equal('s', ['x']), Query.limit(1)]])
|
||
})
|
||
test('listAll holt 250 Dokumente in 3 Seiten à 100 (limit + cursorAfter); eigene limit-Query wirft', async () => {
|
||
const all = Array.from({ length: 250 }, (_, i) => ({ $id: `d${i}` })); const calls = []
|
||
const db = createDb(cfg, { sdk: mkSdk({ listDocuments: async (d, c, qs) => { const after = qs.map((q) => JSON.parse(q)).find((q) => q.method === 'cursorAfter')?.values[0]; const start = after ? Number(after.slice(1)) + 1 : 0; return { total: 250, documents: all.slice(start, start + 100) } } }, calls) })
|
||
assert.equal((await db.listAll('c')).length, 250); assert.equal(calls.length, 3); assert.ok(calls[1][3].includes(Query.limit(100))); assert.ok(calls[1][3].includes(Query.cursorAfter('d99')))
|
||
await assert.rejects(db.listAll('c', [Query.limit(5)]), /limit/)
|
||
})
|
||
test('update und remove delegieren', async () => {
|
||
const calls = []; const db = createDb(cfg, { sdk: mkSdk({ updateDocument: async (d, c, id, data) => ({ $id: id, ...data }), deleteDocument: async () => ({}) }, calls) })
|
||
assert.deepEqual(await db.update('c', 'a', { x: 1 }), { $id: 'a', x: 1 }); await db.remove('c', 'a'); assert.deepEqual(calls.map((c) => c[0]), ['updateDocument', 'deleteDocument'])
|
||
})
|
||
test('Kundensammlungen sind für Schreibzugriffe gesperrt (Übersicht §2.13), Lesen geht; kein SDK-Schreibaufruf', async () => {
|
||
const calls = []; const db = createDb(cfg, { sdk: mkSdk({ listDocuments: async () => ({ total: 0, documents: [] }), createDocument: async () => ({}), updateDocument: async () => ({}), deleteDocument: async () => ({}) }, calls) })
|
||
assert.deepEqual(GESPERRTE_SAMMLUNGEN, ['customers', 'workorders', 'websiteProjects', 'customerPortalAccess'])
|
||
for (const c of GESPERRTE_SAMMLUNGEN) { await assert.rejects(db.create(c, {}), /gesperrt/); await assert.rejects(db.update(c, 'x', {}), /gesperrt/); await assert.rejects(db.remove(c, 'x'), /gesperrt/); await db.list(c) }
|
||
assert.ok(calls.every((c) => c[0] === 'listDocuments'))
|
||
})
|
||
```
|
||
`fakeDb.test.js` (`seed` ist synchron):
|
||
```js
|
||
const ISO = '2026-10-19T09:00:00.000Z'
|
||
const OK = { kind: 'dom', origin: 'workers', blocked: false, hint: false, createdAt: ISO }
|
||
test('prüft Dokumente gegen SCHEMA wie Appwrite', async () => {
|
||
const db = createFakeDb()
|
||
await assert.rejects(db.create('workersKeys', { ...OK, nope: 1 }, 'a'), /Unknown attribute/)
|
||
await assert.rejects(db.create('workersKeys', { ...OK, kind: 'xx' }, 'a'), /enum/i)
|
||
await assert.rejects(db.create('workersKeys', { kind: 'dom' }, 'a'), /required/i)
|
||
await assert.rejects(db.create('workersEvents', { ts: ISO, workerId: 'otto', kind: 'leerlauf', text: 'x'.repeat(301) }, 'e'), /size|length/i)
|
||
await db.create('workersKeys', OK, 'a'); await assert.rejects(db.create('workersKeys', OK, 'a'), ConflictError)
|
||
})
|
||
test('Länge nach max statt size: payloadJson mit 16001 Zeichen wird abgelehnt, 16000 nicht', async () => {
|
||
const db = createFakeDb(); const base = { kind: 'suche', workerId: 'ali', status: 'offen', attempts: 0, createdAt: ISO }
|
||
await assert.rejects(db.create('workersJobs', { ...base, payloadJson: 'x'.repeat(16001) }), /size|length/i); await db.create('workersJobs', { ...base, payloadJson: 'x'.repeat(16000) })
|
||
})
|
||
test('Standard-Limit 25 wie Appwrite; Query.limit(100) und listAll holen alle', async () => {
|
||
const db = createFakeDb(); for (let i = 0; i < 30; i++) db.seed('workersEvents', { ts: ISO, workerId: 'otto', kind: 'leerlauf', text: String(i) })
|
||
assert.equal((await db.list('workersEvents')).length, 25); assert.equal((await db.list('workersEvents', [Query.limit(100)])).length, 30); assert.equal((await db.listAll('workersEvents')).length, 30)
|
||
})
|
||
test('Queries: equal (mehrere Werte = ODER), greaterThan, orderDesc, offset, cursorAfter, select', async () => {
|
||
const db = createFakeDb(); for (const [i, k] of ['fund', 'doppelt', 'fund'].entries()) db.seed('workersEvents', { ts: `2026-10-19T09:00:0${i}.000Z`, workerId: 'otto', kind: k, text: `t${i}` }, `e${i}`)
|
||
const ids = async (qs) => (await db.list('workersEvents', qs)).map((d) => d.$id)
|
||
assert.deepEqual(await ids([Query.equal('kind', ['fund', 'doppelt'])]), ['e0', 'e1', 'e2']); assert.deepEqual(await ids([Query.equal('kind', ['doppelt'])]), ['e1'])
|
||
assert.deepEqual(await ids([Query.greaterThan('ts', '2026-10-19T09:00:00.000Z'), Query.orderDesc('ts')]), ['e2', 'e1']); assert.deepEqual(await ids([Query.orderAsc('ts'), Query.offset(1)]), ['e1', 'e2'])
|
||
assert.deepEqual(await ids([Query.orderAsc('ts'), Query.cursorAfter('e0'), Query.limit(1)]), ['e1'])
|
||
assert.deepEqual(Object.keys((await db.list('workersEvents', [Query.select(['kind'])]))[0]).sort(), ['$id', 'kind'])
|
||
})
|
||
test('unbekannte Query-Methode wirft', async () => assert.rejects(createFakeDb().list('workersEvents', [Query.search('text', 'x')]), /nicht unterstützt/))
|
||
test('unique-Index (tokenHash) → ConflictError; unbekannte Sammlung → code 404; lose Sammlung prüft nichts', async () => {
|
||
const db = createFakeDb(); db.seed('workersOffers', { tokenHash: 'h1' }); assert.throws(() => db.seed('workersOffers', { tokenHash: 'h1' }), ConflictError)
|
||
await assert.rejects(db.get('nirgends', 'x'), (e) => e.code === 404); db.seed('customers', { name: 'Freitext', irgendwas: 1 }, 'c1'); assert.equal((await db.get('customers', 'c1')).irgendwas, 1)
|
||
for (const op of [() => db.create('customers', { name: 'x' }), () => db.update('customers', 'c1', { name: 'y' }), () => db.remove('customers', 'c1')]) await assert.rejects(op(), /gesperrt/) // Schreibsperre auch im Fake (§2.13)
|
||
})
|
||
test('$-Schlüssel in data werden abgelehnt (Appwrite verträgt kein fremdes $id); datetime darf einen Offset tragen', async () => {
|
||
const db = createFakeDb(); await db.create('workersKeys', OK, 'a')
|
||
await assert.rejects(db.update('workersKeys', 'a', { $id: 'zzz' }), /Unknown attribute/); await assert.rejects(db.update('workersKeys', 'a', { createdAt: 'gestern' }), /createdAt/)
|
||
await db.update('workersKeys', 'a', { createdAt: '2026-10-19T09:00:00.000+00:00' })
|
||
})
|
||
```
|
||
- [ ] **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 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>"`
|
||
|
||
---
|
||
|
||
### P1-T05: Rollen-Register mit Werkzeug-Sperre
|
||
|
||
**Files:**
|
||
- Create: `src/roles.js`, `test/fixtures/workers-roster.json`
|
||
- Test: `test/roles.test.js`
|
||
|
||
**Interfaces:**
|
||
- Produces: `ROLES`, `FLOORS`, `MODELS`, `role(workerId)`, `assertCap(workerId, cap)` (wirft `CapError`), `CapError`, `hasCap(workerId, cap) → boolean`, `SEARCH_ROOMS`, `workerForRoom(room) → workerId|null`, `roomLabel(room) → string` (Übersicht §3, §6, E2).
|
||
|
||
- [ ] **Step 1: Fixture und Test schreiben.** `test/fixtures/workers-roster.json` = `{ "floors": [10 Etagen aus §3], "roles": [19 Objekte `{ workerId, name, floor, room, model, role, caps }` in Tabellenreihenfolge; `role` = Zelle „Einzige Rolle“ wörtlich] }` (WOMS bekommt später dieselbe Datei). Test:
|
||
```js
|
||
const roster = JSON.parse(readFileSync(new URL('./fixtures/workers-roster.json', import.meta.url), 'utf8'))
|
||
test('ROLES/FLOORS entsprechen der gemeinsamen Fixture (Spiegel in WOMS)', () => assert.deepEqual({ floors: FLOORS, roles: ROLES }, roster))
|
||
test('19 Rollen, 10 Etagen in Reihenfolge', () => {
|
||
assert.equal(ROLES.length, 19)
|
||
assert.deepEqual(FLOORS, ['bruecke', 'anfragen', 'ohne_website', 'bewertung', 'gespraeche', 'werkstatt', 'pruefung', 'freigabe', 'tresor', 'keller'])
|
||
assert.deepEqual(MODELS, { opus: 'claude-opus-5-5', sonnet: 'claude-sonnet-5-5', haiku: 'claude-haiku-5-5' })
|
||
})
|
||
test('Besetzung exakt nach Übersicht §3 (workerId, Etage, Raum, Modell)', () => assert.deepEqual(ROLES.map((r) => [r.workerId, r.floor, r.room, r.model]), [
|
||
['otto', 'bruecke', 'bruecke', 'opus'], ['rita', 'anfragen', 'netz', 'haiku'], ['kim', 'anfragen', 'kleinanzeigen', 'haiku'], ['pia', 'anfragen', 'portale', 'haiku'], ['finn', 'anfragen', 'foren', 'haiku'],
|
||
['ali', 'ohne_website', 'aerzte', 'haiku'], ['hanna', 'ohne_website', 'handwerk', 'haiku'], ['sven', 'ohne_website', 'selbst', 'haiku'], ['lea', 'ohne_website', 'laden', 'haiku'], ['bea', 'bewertung', 'waage', 'sonnet'],
|
||
['max', 'gespraeche', 'mail', 'sonnet'], ['clara', 'gespraeche', 'forum', 'sonnet'], ['nora', 'gespraeche', 'posteingang', 'haiku'], ['theo', 'gespraeche', 'abschluss', 'sonnet'],
|
||
['bob', 'werkstatt', 'tisch1', 'opus'], ['bianca', 'werkstatt', 'tisch2', 'opus'], ['ben', 'werkstatt', 'tisch3', 'opus'], ['paul', 'pruefung', 'pruefung', 'sonnet'], ['arne', 'keller', 'archiv', 'haiku']]))
|
||
test('Fähigkeiten sind getrennt: send_mail nur Max, pult nur Clara und Theo', () => {
|
||
assert.deepEqual(ROLES.filter((r) => r.caps.includes('send_mail')).map((r) => r.workerId), ['max'])
|
||
assert.deepEqual(ROLES.filter((r) => r.caps.includes('pult')).map((r) => r.workerId), ['clara', 'theo'])
|
||
assert.deepEqual(role('ali').caps, ['web_search', 'web_fetch', 'places', 'osm']); assert.deepEqual(role('otto').caps, ['plan', 'report'])
|
||
})
|
||
test('assertCap: erlaubt → still; verboten/unbekannt → CapError mit workerId und cap', () => {
|
||
assertCap('max', 'send_mail')
|
||
assert.throws(() => assertCap('kim', 'send_mail'), (e) => e instanceof CapError && /kim/.test(e.message) && /send_mail/.test(e.message))
|
||
assert.throws(() => assertCap('niemand', 'plan'), CapError)
|
||
assert.equal(hasCap('rita', 'web_fetch'), true); assert.equal(hasCap('kim', 'web_search'), false)
|
||
})
|
||
test('Räume', () => {
|
||
assert.deepEqual(SEARCH_ROOMS, ['netz', 'kleinanzeigen', 'portale', 'foren', 'aerzte', 'handwerk', 'selbst', 'laden'])
|
||
assert.equal(workerForRoom('handwerk'), 'hanna'); assert.equal(workerForRoom('gibtsnicht'), null)
|
||
assert.equal(roomLabel('aerzte'), 'Ärzte & Praxen'); assert.equal(roomLabel('foren'), 'Foren & Fragen'); assert.equal(roomLabel('laden'), 'Läden & Gastro')
|
||
})
|
||
```
|
||
- [ ] **Step 2: Fehlschlag sehen.** `cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/roles.test.js` → FAIL `ERR_MODULE_NOT_FOUND`.
|
||
- [ ] **Step 3: Implementieren.** `ROLES` (eingefroren) wörtlich aus Übersicht §3 / Fixture; `SEARCH_ROOMS` = Räume der Etagen `anfragen` und `ohne_website` in `ROLES`-Reihenfolge; `roomLabel`: Tabelle aus Spec §5 (netz „Netz-Radar“, kleinanzeigen „Kleinanzeigen“, portale „Portale“, foren „Foren & Fragen“, aerzte „Ärzte & Praxen“, handwerk „Handwerk“, selbst „Selbstständige“, laden „Läden & Gastro“, übrige: Raum-ID); `CapError.message` = `Worker <id> darf <cap> nicht`.
|
||
- [ ] **Step 4: Test laufen lassen.** Gleicher Befehl → PASS (6 Tests).
|
||
- [ ] **Step 5: Commit.** `git add src/roles.js test/fixtures/workers-roster.json test/roles.test.js && git commit -m "P1-T05: Rollen-Register mit Fähigkeiten-Sperre" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"`
|
||
|
||
---
|
||
|
||
### P1-T06: Ereignisstrom `emit`
|
||
|
||
**Files:**
|
||
- Create: `src/events.js`
|
||
- Test: `test/events.test.js`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `deps.db`, `deps.clock`, `ROLES` (T05).
|
||
- Produces: `emit(deps, { workerId, kind, text, jobId?, prospectId?, link?, to?, tokensIn?, tokensOut?, costUsd?, meta? }) → Dokument`; `EVENT_KINDS` (26 Arten aus Übersicht §5).
|
||
|
||
- [ ] **Step 1: Test schreiben.**
|
||
```js
|
||
const KINDS = ['auftrag', 'leerlauf', 'gelesen', 'fund', 'doppelt', 'uebergabe', 'bewertet', 'top3', 'botlead', 'entwurf', 'pult', 'gesendet', 'eingang', 'sortiert', 'bau_start', 'bau_fertig', 'pruefung_zurueck', 'pruefung_ok', 'vorschau_link', 'angebot', 'deal', 'gesperrt', 'budget_warnung', 'budget_stopp', 'pause', 'fehler']
|
||
test('EVENT_KINDS = Übersicht §5', () => assert.deepEqual(EVENT_KINDS, KINDS))
|
||
test('schreibt alle Felder; ts aus der Uhr; meta als JSON', async () => {
|
||
const deps = makeDeps()
|
||
const e = await emit(deps, { workerId: 'kim', kind: 'gelesen', text: 'Kim hat 3 Suchaufträge gelesen.', prospectId: 'p1', to: 'bea', tokensIn: 10, tokensOut: 5, costUsd: 0.002, meta: { count: 3 } })
|
||
assert.equal(e.ts, '2026-10-19T09:00:00.000Z'); assert.equal(e.metaJson, '{"count":3}'); assert.equal(e.to, 'bea'); assert.equal(e.workerId, 'kim')
|
||
})
|
||
test('unbekannte Art, Worker oder Ziel → Fehler, nichts geschrieben; system ist erlaubt', async () => {
|
||
const deps = makeDeps()
|
||
await assert.rejects(emit(deps, { workerId: 'kim', kind: 'quatsch', text: 'x' }), /Ereignisart/)
|
||
await assert.rejects(emit(deps, { workerId: 'niemand', kind: 'fehler', text: 'x' }), /Worker/)
|
||
await assert.rejects(emit(deps, { workerId: 'kim', kind: 'uebergabe', text: 'x', to: 'niemand' }), /Ziel/)
|
||
assert.equal(deps.db.all('workersEvents').length, 0)
|
||
await emit(deps, { workerId: 'system', kind: 'pause', text: 'Pause.' })
|
||
})
|
||
test('text > 300 wird auf genau 300 gekürzt (… am Ende); zu große meta wird {"truncated":true}', async () => {
|
||
const deps = makeDeps()
|
||
const e = await emit(deps, { workerId: 'otto', kind: 'leerlauf', text: 'ä'.repeat(400), meta: { x: 'y'.repeat(5000) } })
|
||
assert.equal(e.text.length, 300); assert.ok(e.text.endsWith('…')); assert.equal(e.metaJson, '{"truncated":true}')
|
||
})
|
||
test('gleiche Millisekunde → ts strikt aufsteigend (sonst verliert der Poll "after=" Ereignisse)', async () => {
|
||
const deps = makeDeps({ now: '2026-10-19T09:00:00.000Z' })
|
||
const ts = []; for (let i = 0; i < 3; i++) ts.push((await emit(deps, { workerId: 'otto', kind: 'leerlauf', text: 'a' })).ts)
|
||
assert.deepEqual(ts, ['2026-10-19T09:00:00.000Z', '2026-10-19T09:00:00.001Z', '2026-10-19T09:00:00.002Z'])
|
||
deps.clock.set('2026-10-19T09:00:05.000Z'); assert.equal((await emit(deps, { workerId: 'otto', kind: 'leerlauf', text: 'b' })).ts, '2026-10-19T09:00:05.000Z')
|
||
})
|
||
```
|
||
- [ ] **Step 2: Fehlschlag sehen.** `cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/events.test.js` → FAIL `ERR_MODULE_NOT_FOUND`.
|
||
- [ ] **Step 3: Implementieren.** Fehlertexte enthalten `Ereignisart`, `Worker`, `Ziel`. `ts` = `max(clock.now(), letztes ts dieser DB + 1)` (letztes `ts` je `deps.db` in einer `WeakMap`); `text` > 300 → erste 299 Zeichen + `…`; `link` auf 1024 kürzen; `metaJson` nur wenn `meta` gesetzt und serialisiert ≤ 4000, sonst `{"truncated":true}`; nicht gesetzte optionale Felder weglassen; Rückgabe = angelegtes Dokument.
|
||
- [ ] **Step 4: Test laufen lassen.** Gleicher Befehl → PASS (5 Tests).
|
||
- [ ] **Step 5: Commit.** `git add src/events.js test/events.test.js && git commit -m "P1-T06: Ereignisstrom emit mit strikt aufsteigendem ts" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"`
|
||
|
||
---
|
||
|
||
### P1-T07: Fehlerklassen und Einstellungen
|
||
|
||
**Files:**
|
||
- Create: `src/errors.js`, `src/settings.js`
|
||
- Test: `test/settings.test.js`
|
||
|
||
**Interfaces:**
|
||
- Produces: `HttpError(status, message)`, `ValidationError` (400), `NotFoundError` (404); `DEFAULTS`; `getSettings(deps) → Einstellungen` (Standardwerte §4.1, gespeichertes Dokument darüber gelegt, ohne `$`-Felder); `updateSettings(deps, patch, by) → Einstellungen`; `publicSettings(settings)` (ohne `previewsSent`) (Übersicht §6).
|
||
- Consumes: `ROLES`/`SEARCH_ROOMS` (T05), `deps.db`, `deps.clock`.
|
||
|
||
- [ ] **Step 1: Test schreiben.**
|
||
```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.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']])
|
||
assert.deepEqual([s.previewGate, s.previewGateN, s.previewsSent, s.offerProductKey], ['erste_n', 10, 0, 'hosting'])
|
||
assert.equal(s.offerText, 'Gratis-Vorschau, unverbindlich. Wenn sie gefällt, ist die Website im Hosting-Abo enthalten: 20 € im Monat, 12 Monate Mindestlaufzeit, ohne MwSt. nach § 19 UStG.')
|
||
assert.deepEqual([s.pultStaleDays, s.retentionDays, s.followupDays, s.previewReminderDays, s.previewDeleteDays], [3, 90, 7, 5, 60])
|
||
assert.equal(s.reportEmail, 'kenso.gri@gmail.com')
|
||
})
|
||
test('updateSettings legt Dokument "main" an und aktualisiert danach; setzt updatedBy/updatedAt', async () => {
|
||
const deps = makeDeps(); const s = await updateSettings(deps, { dailyBudgetUsd: 12 }, 'u1')
|
||
assert.equal(s.dailyBudgetUsd, 12); assert.equal(s.updatedBy, 'u1'); assert.equal(deps.db.all('workersSettings')[0].$id, 'main')
|
||
await updateSettings(deps, { paused: true }, 'u2'); assert.equal(deps.db.all('workersSettings').length, 1)
|
||
assert.deepEqual([(await getSettings(deps)).dailyBudgetUsd, (await getSettings(deps)).paused], [12, true])
|
||
})
|
||
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/],
|
||
[{ runsPerNight: 3 }, /runsPerNight/], [{ activeRooms: ['nirgends'] }, /activeRooms/], [{ connectedRooms: ['nirgends'] }, /connectedRooms/], [{ previewGate: 'vielleicht' }, /previewGate/],
|
||
[{ previewsSent: 5 }, /previewsSent/], [{ unbekannt: 1 }, /unbekannt/], [{ reportEmail: 'kein-mail' }, /reportEmail/], [{ offerProductKey: 'gratis' }, /offerProductKey/], [{ regions: [] }, /regions/]])
|
||
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'] }, 'u1') // 3 ist erlaubt; connectedRooms darf jeden bekannten Raum enthalten
|
||
})
|
||
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])
|
||
})
|
||
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–1000 Zeichen; `offerProductKey` ∈ {hosting, wartung}; `pultStaleDays` 1–30; `retentionDays` 7–365; `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.
|
||
- [ ] **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>"`
|
||
|
||
---
|
||
|
||
### P1-T08: Zeitplan (Berliner Zeit, Nachtfenster, Sendefenster)
|
||
|
||
**Files:**
|
||
- Create: `src/schedule.js`
|
||
- Test: `test/schedule.test.js`
|
||
|
||
**Interfaces:**
|
||
- Produces: `berlinNow(clock) → { ts, day:'YYYY-MM-DD', hour, minute, weekday:1..7 }`; `berlinStartOfDay(day) → ISO-UTC`; `nightKey(now) → 'YYYY-MM-DD'`; `runKey(kind, now) → string|null`; `isDue(kind, now, last) → boolean`; `inSendWindow(now, settings) → boolean` (Übersicht §6 + E4).
|
||
- Arten: täglich `bericht` 06:30, `kuerzen` 03:00, `pultAlt` 07:00; Nacht `nachtplan` (22:00–04:59), `nacht2` (01:00–04:59); Intervall `posteingang` 2 min, `alarme` 10 min, `senden` 5 min, `batches` 5 min, `aufraeumen` 5 min (intern, T26).
|
||
|
||
- [ ] **Step 1: Test schreiben.**
|
||
```js
|
||
const at = (iso) => berlinNow({ now: () => Date.parse(iso) })
|
||
test('berlinNow: Sommer- und Winterzeit, Wochentag', () => {
|
||
const p = ({ day, hour, minute, weekday }) => ({ day, hour, minute, weekday })
|
||
assert.deepEqual(p(at('2026-10-17T20:00:00Z')), { day: '2026-10-17', hour: 22, minute: 0, weekday: 6 }) // Samstag, MESZ
|
||
assert.deepEqual(p(at('2026-11-17T21:00:00Z')), { day: '2026-11-17', hour: 22, minute: 0, weekday: 2 }) // Dienstag, MEZ
|
||
})
|
||
test('berlinStartOfDay', () => {
|
||
assert.equal(berlinStartOfDay('2026-10-01'), '2026-09-30T22:00:00.000Z'); assert.equal(berlinStartOfDay('2026-11-01'), '2026-10-31T23:00:00.000Z')
|
||
})
|
||
test('täglich: bericht ab 06:30, einmal pro Tag, auch an der Zeitumstellung', () => {
|
||
assert.equal(isDue('bericht', at('2026-10-18T04:29:00Z'), null), false); assert.equal(isDue('bericht', at('2026-10-18T04:30:00Z'), null), true)
|
||
assert.equal(isDue('bericht', at('2026-10-18T09:00:00Z'), '2026-10-18'), false)
|
||
assert.equal(isDue('bericht', at('2026-10-25T05:29:00Z'), null), false); assert.equal(isDue('bericht', at('2026-10-25T05:30:00Z'), null), true) // 25.10.: MEZ, 06:30 = 05:30Z
|
||
})
|
||
test('Nacht: nachtplan 22:00–04:59 mit Nacht-Tag; nacht2 ab 01:00', () => {
|
||
assert.equal(isDue('nachtplan', at('2026-10-17T19:59:00Z'), null), false) // 21:59
|
||
assert.equal(isDue('nachtplan', at('2026-10-17T20:00:00Z'), null), true)
|
||
assert.equal(isDue('nachtplan', at('2026-10-17T20:00:00Z'), '2026-10-17'), false)
|
||
assert.equal(runKey('nachtplan', at('2026-10-17T22:30:00Z')), '2026-10-17') // 00:30 am 18. gehört zur Nacht des 17.
|
||
assert.equal(isDue('nachtplan', at('2026-10-17T22:30:00Z'), null), true) // Nachholen nach Neustart
|
||
assert.equal(isDue('nachtplan', at('2026-10-18T03:00:00Z'), null), false) // 05:00: Fenster vorbei
|
||
assert.equal(isDue('nacht2', at('2026-10-17T22:59:00Z'), null), false); assert.equal(isDue('nacht2', at('2026-10-17T23:00:00Z'), null), true)
|
||
})
|
||
test('Intervall: last ist ms-Zeitstempel', () => {
|
||
const n = at('2026-10-19T08:00:00Z')
|
||
assert.equal(isDue('posteingang', n, null), true); assert.equal(isDue('posteingang', n, n.ts - 119999), false); assert.equal(isDue('posteingang', n, n.ts - 120000), true)
|
||
assert.equal(isDue('batches', n, n.ts - 299999), false); assert.equal(isDue('aufraeumen', n, n.ts - 300000), true); assert.equal(runKey('posteingang', n), null)
|
||
assert.throws(() => isDue('quatsch', n, null), /Unbekannte Art/)
|
||
})
|
||
test('inSendWindow: Mo–Fr 09:00 inklusive bis 16:00 exklusive', () => {
|
||
const s = { sendWindowStart: '09:00', sendWindowEnd: '16:00', sendWeekdaysOnly: true }
|
||
assert.equal(inSendWindow(at('2026-10-19T06:59:00Z'), s), false); assert.equal(inSendWindow(at('2026-10-19T07:00:00Z'), s), true) // Montag 08:59 / 09:00
|
||
assert.equal(inSendWindow(at('2026-10-19T13:59:00Z'), s), true); assert.equal(inSendWindow(at('2026-10-19T14:00:00Z'), s), false) // 15:59 / 16:00
|
||
assert.equal(inSendWindow(at('2026-10-17T09:00:00Z'), s), false) // Samstag
|
||
assert.equal(inSendWindow(at('2026-10-17T09:00:00Z'), { ...s, sendWeekdaysOnly: false }), true)
|
||
})
|
||
```
|
||
- [ ] **Step 2: Fehlschlag sehen.** `cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/schedule.test.js` → FAIL `ERR_MODULE_NOT_FOUND`.
|
||
- [ ] **Step 3: Implementieren.** `Intl.DateTimeFormat('en-CA', { timeZone: 'Europe/Berlin', hourCycle: 'h23', … })` wie `serverpuls/src/schedule.js`. `berlinStartOfDay(day)`: UTC-Mitternacht von `day` minus 2 h bzw. 1 h – derjenige Kandidat, dessen `berlinNow` genau `day 00:00` ergibt. `nightKey(now)` = Berliner Tag von `now.ts − 5 h`. `runKey`: täglich → `now.day`; Nachtarten → `nightKey`; Intervall → `null`. `isDue`: täglich/Nacht → Zeitfenster (in Minuten seit Mitternacht; `nachtplan`: ≥ 22:00 oder < 05:00; `nacht2`: ≥ 01:00 und < 05:00) und `last !== runKey`; Intervall → `last == null || now.ts − last ≥ Intervall`; unbekannte Art → `Error('Unbekannte Art: …')`. `inSendWindow` vergleicht `HH:MM` aus den Einstellungen als Minuten.
|
||
- [ ] **Step 4: Test laufen lassen.** Gleicher Befehl → PASS (6 Tests).
|
||
- [ ] **Step 5: Commit.** `git add src/schedule.js test/schedule.test.js && git commit -m "P1-T08: Zeitplan mit Berliner Zeit und Nachtfenster" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"`
|
||
|
||
---
|
||
|
||
### P1-T09: Tageszähler `daily.js` (serialisiert, Lauf-Marker)
|
||
|
||
**Files:**
|
||
- Create: `src/daily.js`
|
||
- Test: `test/daily.test.js`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `deps.db`, `berlinStartOfDay` (T08).
|
||
- Produces: Tagesobjekt `{ $id, costUsd, mailsCold, mailsReply, searches, found, contacted, deals, warned, stopped, counters }` (`counters` = geparstes `countersJson`, fehlt es → `{}`); `getDaily(deps, day) → Tagesobjekt|null`; `updateDaily(deps, day, mutate) → Tagesobjekt` (legt fehlenden Tag mit Nullwerten an, `mutate(obj)` ändert in place, alle Aufrufe je `deps.db` laufen nacheinander); `monthDocs(deps, now) → Tagesobjekt[]` (Tage des Berliner Monats von `now.day`); `markRun(deps, kind, key)`; `lastRun(deps, kind, key) → string|null` (E4).
|
||
|
||
- [ ] **Step 1: Test schreiben.**
|
||
```js
|
||
test('getDaily: fehlender Tag → null; updateDaily legt mit Nullwerten an und liefert geparste counters', async () => {
|
||
const deps = makeDeps(); assert.equal(await getDaily(deps, '2026-10-19'), null)
|
||
const d = await updateDaily(deps, '2026-10-19', (x) => { x.found += 2; x.counters.kim = { gelesen: 3 } })
|
||
assert.deepEqual([d.$id, d.costUsd, d.found, d.warned, d.stopped, d.counters], ['2026-10-19', 0, 2, false, false, { kim: { gelesen: 3 } }])
|
||
assert.equal(deps.db.all('workersDaily')[0].countersJson, '{"kim":{"gelesen":3}}')
|
||
})
|
||
test('vier gleichzeitige Updates verlieren nichts (Sperre je DB)', async () => {
|
||
const deps = makeDeps()
|
||
await Promise.all([1, 2, 3, 4].map(() => updateDaily(deps, '2026-10-19', (d) => { d.costUsd += 0.5; d.counters.kim = { gelesen: (d.counters.kim?.gelesen ?? 0) + 1 } })))
|
||
const d = await getDaily(deps, '2026-10-19'); assert.equal(d.costUsd, 2); assert.equal(d.counters.kim.gelesen, 4)
|
||
})
|
||
test('ein Fehler in mutate gibt die Sperre frei und schreibt nichts', async () => {
|
||
const deps = makeDeps(); await assert.rejects(updateDaily(deps, '2026-10-19', () => { throw new Error('kaputt') }), /kaputt/)
|
||
await updateDaily(deps, '2026-10-19', (d) => { d.found = 1 }); assert.equal((await getDaily(deps, '2026-10-19')).found, 1)
|
||
})
|
||
test('Zähler-JSON über 4000 Zeichen → Fehler statt stiller Kürzung', async () => {
|
||
await assert.rejects(updateDaily(makeDeps(), '2026-10-19', (d) => { d.counters.x = 'y'.repeat(4000) }), /countersJson/)
|
||
})
|
||
test('monthDocs: nur Tage des Berliner Monats (Grenze 00:00 Berlin)', async () => {
|
||
const deps = makeDeps({ now: '2026-09-30T21:59:59.000Z' }); await updateDaily(deps, '2026-09-30', (d) => { d.costUsd = 1 }) // 23:59:59 Berlin = September
|
||
deps.clock.set('2026-09-30T22:00:00.000Z'); await updateDaily(deps, '2026-10-01', (d) => { d.costUsd = 2 }) // 00:00 Berlin = Oktober
|
||
deps.clock.set('2026-10-19T09:00:00.000Z'); await updateDaily(deps, '2026-10-19', (d) => { d.costUsd = 3 })
|
||
assert.deepEqual((await monthDocs(deps, berlinNow(deps.clock))).map((d) => d.$id).sort(), ['2026-10-01', '2026-10-19'])
|
||
})
|
||
test('Lauf-Marker: pro Art und Schlüssel, überlebt einen Neustart (neues deps mit gleicher DB)', async () => {
|
||
const deps = makeDeps(); assert.equal(await lastRun(deps, 'bericht', '2026-10-19'), null)
|
||
await markRun(deps, 'bericht', '2026-10-19'); await markRun(deps, 'nachtplan', '2026-10-19')
|
||
const neu = { ...deps }; assert.equal(await lastRun(neu, 'bericht', '2026-10-19'), '2026-10-19'); assert.equal(await lastRun(neu, 'kuerzen', '2026-10-19'), null)
|
||
assert.deepEqual((await getDaily(neu, '2026-10-19')).counters.__runs, { bericht: '2026-10-19', nachtplan: '2026-10-19' })
|
||
})
|
||
```
|
||
- [ ] **Step 2: Fehlschlag sehen.** `cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/daily.test.js` → FAIL `ERR_MODULE_NOT_FOUND` (`src/daily.js`).
|
||
- [ ] **Step 3: Implementieren.** Sperre: `WeakMap<db, Promise>`-Kette, Fehler brechen die Kette nicht. `updateDaily`: `get` → fehlt: `create(…, day)` mit Nullwerten (`countersJson: '{}'`, bei `ConflictError` neu lesen) → `mutate` auf dem Objekt → `costUsd` auf 6 Nachkommastellen runden → `JSON.stringify(counters)` > 4000 Zeichen wirft `Error('countersJson zu groß')` → `update` mit **nur** den Schema-Feldern (`costUsd, mailsCold, mailsReply, searches, found, contacted, deals, countersJson, warned, stopped`), nie mit `$…`-Feldern oder `counters`. `monthDocs`: `list('workersDaily', [Query.greaterThanEqual('$createdAt', berlinStartOfDay(<Monatsersten>)), Query.limit(40)])`. `markRun` schreibt `counters.__runs[kind] = key` in das Dokument `key`.
|
||
- [ ] **Step 4: Test laufen lassen.** Gleicher Befehl → PASS (6 Tests).
|
||
- [ ] **Step 5: Commit.** `git add src/daily.js test/daily.test.js && git commit -m "P1-T09: Tageszähler mit Sperre und Lauf-Markern" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"`
|
||
|
||
---
|
||
|
||
### P1-T10: Kostenformel `costOf`
|
||
|
||
**Files:**
|
||
- Create: `src/budget.js` (zunächst nur Kostenteil)
|
||
- Test: `test/budgetCost.test.js`
|
||
|
||
**Interfaces:**
|
||
- Produces: `costOf(model, usage, { batch = false }) → USD (Zahl)` (Übersicht §6); `priceOf(model) → { in, out }` ($/MTok, Normaltarif); `estimateUsd(model, chars, maxTokens) → USD` (Eingabe grob `ceil(chars/3)` Tokens zum Normaltarif + `maxTokens` Ausgabe).
|
||
- `usage` = Anthropic-Usage-Objekt (`input_tokens`, `output_tokens`, `cache_read_input_tokens`, `cache_creation_input_tokens`, `cache_creation:{ephemeral_5m_input_tokens, ephemeral_1h_input_tokens}`, `server_tool_use:{web_search_requests, web_fetch_requests}`); fehlende Felder zählen 0.
|
||
|
||
- [ ] **Step 1: Test schreiben.**
|
||
```js
|
||
// near aus test/support/helpers.js (T01)
|
||
test('Grundpreise je MTok', () => {
|
||
near(costOf('claude-opus-5-5', { input_tokens: 1e6, output_tokens: 1e5 }), 6)
|
||
near(costOf('claude-sonnet-5-5', { input_tokens: 1e6, output_tokens: 1e6 }), 12)
|
||
near(costOf('claude-haiku-5-5', { input_tokens: 1e6, output_tokens: 1e6 }), 0.6)
|
||
})
|
||
test('Cache: Lesen ×0,05 (Haiku ×0,1), Schreiben 5 min ×1,25, 1 h ×2; ohne Aufschlüsselung = 5 min', () => {
|
||
near(costOf('claude-opus-5-5', { cache_read_input_tokens: 1e6 }), 0.2); near(costOf('claude-sonnet-5-5', { cache_read_input_tokens: 1e6 }), 0.1); near(costOf('claude-haiku-5-5', { cache_read_input_tokens: 1e6 }), 0.01)
|
||
near(costOf('claude-opus-5-5', { cache_creation: { ephemeral_5m_input_tokens: 1e6 } }), 5); near(costOf('claude-opus-5-5', { cache_creation: { ephemeral_1h_input_tokens: 1e6 } }), 8)
|
||
near(costOf('claude-sonnet-5-5', { cache_creation_input_tokens: 1e6 }), 2.5)
|
||
})
|
||
test('Batch halbiert Tokens, nicht die Suche', () => {
|
||
near(costOf('claude-sonnet-5-5', { input_tokens: 1e6, output_tokens: 1e6 }, { batch: true }), 6)
|
||
near(costOf('claude-haiku-5-5', { input_tokens: 1e6, server_tool_use: { web_search_requests: 2 } }, { batch: true }), 0.07)
|
||
})
|
||
test('Websuche 0,01 $, Web-Fetch kostenlos', () => near(costOf('claude-haiku-5-5', { input_tokens: 10000, output_tokens: 2000, server_tool_use: { web_search_requests: 3, web_fetch_requests: 5 } }), 0.032))
|
||
test('Haiku-Langprompt: > 100 000 Prompt-Tokens (inkl. Cache) → 0,50/2,50; genau 100 000 noch Normaltarif', () => {
|
||
near(costOf('claude-haiku-5-5', { input_tokens: 150000, output_tokens: 10000 }), 0.1); near(costOf('claude-haiku-5-5', { input_tokens: 100000, output_tokens: 10000 }), 0.015)
|
||
near(costOf('claude-haiku-5-5', { input_tokens: 50000, cache_read_input_tokens: 60000 }), 0.028) // Prompt 110 000 inkl. Cache → Langtarif: 50 000×0,50 + 60 000×0,05 je MTok
|
||
})
|
||
test('unbekanntes Modell wirft; leeres usage = 0; priceOf/estimateUsd', () => {
|
||
assert.throws(() => costOf('gpt-x', {}), /Unbekanntes Modell/); near(costOf('claude-opus-5-5', {}), 0)
|
||
assert.deepEqual(priceOf('claude-haiku-5-5'), { in: 0.1, out: 0.5 }); near(estimateUsd('claude-sonnet-5-5', 3000, 1000), 1000 * 2 / 1e6 + 1000 * 10 / 1e6)
|
||
})
|
||
```
|
||
- [ ] **Step 2: Fehlschlag sehen.** `cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/budgetCost.test.js` → FAIL `ERR_MODULE_NOT_FOUND` (`src/budget.js`).
|
||
- [ ] **Step 3: Implementieren.** Preistabelle als Konstante (Opus 4/20, Sonnet 2/10, Haiku 0,1/0,5; Cache-Lesefaktor 0,05 bzw. 0,1 für Haiku). Haiku-Langprompt: Prompt-Tokens = `input + cache_read + cache_creation (5m+1h bzw. cache_creation_input_tokens)`; > 100 000 ⇒ Eingabe-/Ausgabepreis 0,5/2,5 (Cache-Faktoren wirken auf diesen Eingabepreis). Batch: alle Token-Anteile ×0,5, Suchanteil nicht. Ergebnis auf 1e-9 genau (nicht runden).
|
||
- [ ] **Step 4: Test laufen lassen.** Gleicher Befehl → PASS (6 Tests).
|
||
- [ ] **Step 5: Commit.** `git add src/budget.js test/budgetCost.test.js && git commit -m "P1-T10: Kostenformel costOf" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"`
|
||
|
||
---
|
||
|
||
### P1-T11: Budget-Wächter `checkBudget`, `book`, `markStopped`
|
||
|
||
**Files:**
|
||
- Modify: `src/budget.js`
|
||
- Test: `test/budget.test.js`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `getSettings` (T07), `getDaily`/`updateDaily`/`monthDocs` (T09), `berlinNow` (T08), `emit` (T06), optional `deps.notify`.
|
||
- Produces: `checkBudget(deps, estUsd, { reply = false }) → { ok, reason:'tag'|'monat'|'pause'|null, warn }`; `book(deps, { workerId, usd, counters = {} }) → { costUsd, warned, stopped }`; `markStopped(deps, reason)`; `formatUsd(n) → '8,12 $'` (E5).
|
||
|
||
- [ ] **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 })
|
||
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('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 })
|
||
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 })
|
||
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)
|
||
})
|
||
test('Console-Stopp: markStopped sperrt auch Antworten, Ereignis nur einmal', async () => {
|
||
const deps = makeDeps(); await markStopped(deps, 'console'); await markStopped(deps, 'console')
|
||
assert.equal(deps.db.all('workersEvents').filter((e) => e.kind === 'budget_stopp').length, 1); assert.equal((await checkBudget(deps, 0.01, { reply: true })).ok, false)
|
||
})
|
||
test('book: counters je Worker; Sonderschlüssel auch in den Tagesfeldern; costUsd je Worker', async () => {
|
||
const deps = makeDeps(); await book(deps, { workerId: 'rita', usd: 0.02, counters: { searches: 2, gelesen: 5, tokensIn: 1000 } })
|
||
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)
|
||
assert.equal(deps.db.all('workersEvents').filter((e) => e.kind === 'budget_warnung').length, 1)
|
||
})
|
||
```
|
||
- [ ] **Step 2: Fehlschlag sehen.** `cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/budget.test.js` → FAIL (`checkBudget is not a function` bzw. fehlender Export).
|
||
- [ ] **Step 3: Implementieren.** `checkBudget`: Reihenfolge Pause → (Console-Stopp: `stopped && counters.__stop === 'console'` ⇒ `tag`, auch für `reply`) → Tag (`costUsd + est > dailyBudgetUsd` oder `stopped`; entfällt bei `reply`) → Monat (Summe `monthDocs` + est > `monthlyBudgetUsd`); `warn` = Tag oder Monat ≥ `warnPct` %. `book`: ein `updateDaily` addiert `usd` (je Worker `costUsd`, 6 Nachkommastellen) und `counters` (additiv; Sonderschlüssel `mailsCold, mailsReply, searches, found, contacted, deals` zusätzlich in die Tagesfelder); danach entscheidet ein zweiter `updateDaily` anhand des **dann aktuellen** Tagesdokuments (nicht des Rückgabewerts des ersten Schritts; Monatssumme vorher per `monthDocs` gelesen), ob `warned`/`stopped` zu setzen sind – **nur wenn das Flag in der Sperre wirklich von `false` auf `true` wechselt**, folgt Ereignis (`otto`, `budget_warnung`/`budget_stopp`, deutscher Satz mit `formatUsd`, `meta:{scope:'tag'|'monat',usd,limit}`) und `deps.notify?.({ subject:'Budget …', text })` (Fehler der Mail nur loggen); Stopp setzt `warned` mit, ohne eigene Warnung. `markStopped(deps, reason)`: `stopped = true`, `counters.__stop = reason`, `budget_stopp` nur beim Wechsel. `formatUsd`: `Intl.NumberFormat('de-DE', 2 Nachkommastellen) + ' $'`.
|
||
- [ ] **Step 4: Tests laufen lassen.** Gleicher Befehl und `test/budgetCost.test.js` → PASS (9 + 6 Tests).
|
||
- [ ] **Step 5: Commit.** `git add src/budget.js test/budget.test.js && git commit -m "P1-T11: Budget-Wächter mit Warnung, Stopp und Antwort-Ausnahme" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"`
|
||
|
||
---
|
||
|
||
### P1-T12: Interner Mailer `notify`
|
||
|
||
**Files:**
|
||
- Create: `src/notify.js`
|
||
- Test: `test/notify.test.js`
|
||
|
||
**Interfaces:**
|
||
- Produces: `createNotifier({ smtp, getTo, createTransport = nodemailer.createTransport, clock, log }) → { notify({ subject, text }) → Promise<{ sent:boolean, reason?:string }> }`; `smtp` = `config.reportSmtp` oder `null`; `getTo()` → Empfängeradresse (E3). Reason: `nicht_konfiguriert`, `kein_empfaenger`, `smtp_auth`, `smtp_fehler`.
|
||
|
||
- [ ] **Step 1: Test schreiben.**
|
||
```js
|
||
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 () => {
|
||
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.cfg.host, t.cfg.port, t.cfg.requireTLS, t.cfg.connectionTimeout, t.cfg.auth], ['mail.example', 587, true, 20000, { user: 'u', pass: 'geheim-pw' }])
|
||
})
|
||
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 }
|
||
assert.deepEqual(await createNotifier({ smtp: null, getTo: async () => 'k@example.de', ...base }).notify({ subject: 's', text: 't' }), { sent: false, reason: 'nicht_konfiguriert' })
|
||
assert.deepEqual(await createNotifier({ smtp: SMTP, getTo: async () => '', ...base }).notify({ subject: 's', text: 't' }), { sent: false, reason: 'kein_empfaenger' }); assert.equal(t.attempts, 0)
|
||
})
|
||
test('Login-Fehler (EAUTH/535): genau ein Versuch pro Berliner Tag, Grund ohne Passwort', async () => {
|
||
const deps = makeDeps(); const t = mk({ fail: Object.assign(new Error('Invalid login geheim-pw'), { code: 'EAUTH', responseCode: 535 }) }); const logs = []
|
||
const n = createNotifier({ smtp: SMTP, getTo: async () => 'k@example.de', createTransport: t.createTransport, clock: deps.clock, log: (m) => logs.push(m) })
|
||
for (let i = 0; i < 3; i++) assert.deepEqual(await n.notify({ subject: 's', text: 't' }), { sent: false, reason: 'smtp_auth' })
|
||
assert.equal(t.attempts, 1); assert.ok(!logs.join('\n').includes('geheim-pw'))
|
||
deps.clock.advance(24 * 3600 * 1000); await n.notify({ subject: 's', text: 't' }); assert.equal(t.attempts, 2)
|
||
})
|
||
test('anderer Fehler: kein internes Wiederholen, reason smtp_fehler', async () => {
|
||
const deps = makeDeps(); const t = mk({ fail: Object.assign(new Error('timeout'), { code: 'ETIMEDOUT' }) })
|
||
const n = createNotifier({ smtp: SMTP, getTo: async () => 'k@example.de', createTransport: t.createTransport, clock: deps.clock, log: deps.log })
|
||
assert.deepEqual(await n.notify({ subject: 's', text: 't' }), { sent: false, reason: 'smtp_fehler' }); assert.equal(t.attempts, 1)
|
||
})
|
||
```
|
||
- [ ] **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 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>"`
|
||
|
||
---
|
||
|
||
### P1-T13: Claude-Hülle `ask` mit Budget-Erkennung
|
||
|
||
**Files:**
|
||
- Create: `src/claude.js`, `test/support/fakeSdk.js`
|
||
- Test: `test/claudeAsk.test.js`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `checkBudget`, `book`, `markStopped`, `costOf`, `estimateUsd` (T10/T11), `ROLES`/`MODELS` (T05).
|
||
- Produces: `createClaude({ apiKey, sdk, searchModel = '' }) → { ask, search, batch }` (hier zunächst `ask`; `sdk` = Client mit `.messages.create`, Standard `new Anthropic({ apiKey, maxRetries: 2, timeout: 300000 })` – bewusst wenige Wiederholungen und ein langes Zeitlimit: Suchläufe mit Serverwerkzeugen dauern bis zu mehreren Minuten, und jede wiederholte, nicht gebuchte Anfrage wäre unbemerkte Ausgabe; die Console-Grenze fängt den Rest ab); `ask(deps, { workerId, model?, system, input, schema?, images?, maxTokens, effort?, reply? }) → { data?, text, usage, costUsd, model }`; `BudgetStop` (`.reason` ∈ `tag|monat|pause|console`); `ClaudeRefusal`; `isBudgetError(err) → boolean`.
|
||
- `fakeSdk(script)` → `{ calls, messages:{ create, batches } }` (`create` liefert die Einträge von `script` der Reihe nach, der letzte wiederholt sich; ein `Error` wird geworfen; `calls` enthält Kopien der Anfragen); `msg(over)` (Standard: `claude-haiku-5-5`, `end_turn`, Text `ok`, usage 1000/200); `apiError(status, body)` (Fehler mit `status` und `error = body`, Meldung `<status> <JSON>` wie das SDK).
|
||
|
||
- [ ] **Step 1: Tests schreiben.**
|
||
```js
|
||
const SCHEMA_J = { type: 'object', properties: { klasse: { type: 'string' } }, required: ['klasse'], additionalProperties: false }
|
||
test('Anfrageform: Rollenmodell, gecachter System-Block, Schema in output_config, keine Sampling-Parameter', async () => {
|
||
const sdk = fakeSdk([msg({ content: [{ type: 'text', text: '{"klasse":"ja"}' }] })]); const deps = makeDeps()
|
||
const r = await createClaude({ apiKey: 'k', sdk }).ask(deps, { workerId: 'nora', system: 'SYS', input: 'IN', schema: SCHEMA_J, maxTokens: 500, effort: 'low' })
|
||
assert.deepEqual(r.data, { klasse: 'ja' }); const q = sdk.calls[0]
|
||
assert.deepEqual([q.model, q.max_tokens], ['claude-haiku-5-5', 500])
|
||
assert.deepEqual(q.system, [{ type: 'text', text: 'SYS', cache_control: { type: 'ephemeral' } }]); assert.deepEqual(q.messages, [{ role: 'user', content: 'IN' }])
|
||
assert.deepEqual(q.output_config, { effort: 'low', format: { type: 'json_schema', schema: SCHEMA_J } })
|
||
for (const k of ['temperature', 'top_p', 'top_k']) assert.ok(!(k in q))
|
||
})
|
||
test('Bilder stehen vor dem Text; explizites model überschreibt das Rollenmodell', async () => {
|
||
const sdk = fakeSdk([msg()]); await createClaude({ apiKey: 'k', sdk }).ask(makeDeps(), { workerId: 'paul', model: 'claude-sonnet-5-5', system: 's', input: 'IN', images: [{ mediaType: 'image/png', data: 'AAA' }], maxTokens: 10 })
|
||
assert.equal(sdk.calls[0].model, 'claude-sonnet-5-5'); assert.deepEqual(sdk.calls[0].messages[0].content, [{ type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'AAA' } }, { type: 'text', text: 'IN' }])
|
||
})
|
||
test('bucht Kosten und Tokens (Haiku 1000/200 = 0,0002 $) in Tag und Worker-Zähler', async () => {
|
||
const deps = makeDeps(); const r = await createClaude({ apiKey: 'k', sdk: fakeSdk([msg()]) }).ask(deps, { workerId: 'nora', system: 's', input: 'i', maxTokens: 100 })
|
||
near(r.costUsd, 0.0002); const d = await getDaily(deps, '2026-10-19'); near(d.costUsd, 0.0002); assert.deepEqual(d.counters.nora, { calls: 1, tokensIn: 1000, tokensOut: 200, costUsd: 0.0002 })
|
||
})
|
||
test('Verweigerung, abgeschnittene Antwort, kaputtes JSON werfen – nach dem Buchen', async () => {
|
||
for (const [res, re] of [[msg({ stop_reason: 'refusal' }), ClaudeRefusal], [msg({ stop_reason: 'max_tokens' }), /abgeschnitten/], [msg({ content: [{ type: 'text', text: 'kein json' }] }), /JSON/]]) {
|
||
const deps = makeDeps(); const c = createClaude({ apiKey: 'k', sdk: fakeSdk([res]) })
|
||
await assert.rejects(c.ask(deps, { workerId: 'nora', system: 's', input: 'i', schema: SCHEMA_J, maxTokens: 10 }), re); assert.ok((await getDaily(deps, '2026-10-19')).costUsd > 0)
|
||
}
|
||
})
|
||
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')
|
||
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 () => {
|
||
const b400 = { type: 'error', error: { type: 'invalid_request_error', message: 'You have reached your specified workspace API usage limits. You will regain access on 2026-11-01 at 00:00 UTC.' } }
|
||
const b429 = { type: 'error', error: { type: 'rate_limit_error', message: 'Monthly spend limit reached', details: { error_code: 'enforced_spend_limit_reached' } } }
|
||
for (const err of [apiError(400, b400), apiError(429, b429)]) {
|
||
const deps = makeDeps(); const sdk = fakeSdk([err]); const c = createClaude({ apiKey: 'k', sdk })
|
||
await assert.rejects(c.ask(deps, { workerId: 'nora', system: 's', input: 'i', maxTokens: 10 }), (e) => e instanceof BudgetStop && e.reason === 'console')
|
||
await assert.rejects(c.ask(deps, { workerId: 'nora', system: 's', input: 'i', maxTokens: 10, reply: true }), BudgetStop)
|
||
assert.equal(sdk.calls.length, 1); assert.equal(deps.db.all('workersEvents').filter((e) => e.kind === 'budget_stopp').length, 1)
|
||
}
|
||
})
|
||
test('gewöhnliche Fehler bleiben unverändert: normales 429 und fremdes 400', async () => {
|
||
const e429 = apiError(429, { type: 'error', error: { type: 'rate_limit_error', message: 'slow down' } }); const e400 = apiError(400, { type: 'error', error: { type: 'invalid_request_error', message: 'max_tokens too large' } })
|
||
assert.equal(isBudgetError(e429), false); assert.equal(isBudgetError(e400), false)
|
||
const deps = makeDeps(); await assert.rejects(createClaude({ apiKey: 'k', sdk: fakeSdk([e429]) }).ask(deps, { workerId: 'nora', system: 's', input: 'i', maxTokens: 10 }), (e) => e === e429)
|
||
assert.equal((await getDaily(deps, '2026-10-19'))?.stopped ?? false, false)
|
||
})
|
||
```
|
||
- [ ] **Step 2: Fehlschlag sehen.** `cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/claudeAsk.test.js` → FAIL `ERR_MODULE_NOT_FOUND` (`src/claude.js`).
|
||
- [ ] **Step 3: Implementieren.** `ask`: Modell = `opts.model ?? MODELS[role(workerId).model]` (unbekannter Worker → Fehler); `checkBudget(deps, estimateUsd(model, system.length + input.length, maxTokens), { reply })` nicht ok ⇒ `BudgetStop(reason)`; Anfrage genau wie im Test (`output_config` nur, wenn `effort` oder `schema` gesetzt); SDK-Fehler: `isBudgetError(err)` ⇒ `markStopped(deps, 'console')` und `BudgetStop('console')`, sonst unverändert werfen. `isBudgetError`: Text = `err.error?.error?.message ?? err.message`; Status 400 und Text enthält `You have reached your specified` und `API usage limits`, oder Status 429 und `JSON.stringify(err.error ?? {})` enthält `enforced_spend_limit_reached` (nicht am Textanfang verankern: das SDK stellt „400 {…}“ voran). Nach der Antwort **zuerst** `book(deps, { workerId, usd, counters:{ calls:1, tokensIn: input+cache_read+cache_creation, tokensOut: output } })`, dann `stop_reason` prüfen (`refusal` → `ClaudeRefusal`, `max_tokens` → `Error('Antwort abgeschnitten (max_tokens)')`), dann bei `schema` den ersten Text-Block mit `JSON.parse` (Fehler → `Error('Antwort war kein gültiges JSON')`).
|
||
- [ ] **Step 4: Test laufen lassen.** Gleicher Befehl → PASS (7 Tests).
|
||
- [ ] **Step 5: Commit.** `git add src/claude.js test/support/fakeSdk.js test/claudeAsk.test.js && git commit -m "P1-T13: Claude-Hülle ask mit Budget-Erkennung" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"`
|
||
|
||
---
|
||
|
||
### P1-T14: Claude `search` (pause_turn), `batch`, Modell-Prüfskript
|
||
|
||
**Files:**
|
||
- Modify: `src/claude.js`
|
||
- Create: `scripts/check-modelle.mjs`
|
||
- Test: `test/claudeSearch.test.js`, `test/checkModelle.test.js`
|
||
|
||
**Interfaces:**
|
||
- Produces: `search(deps, { workerId, system, task, maxUses, allowedDomains?, blockedDomains?, maxTokens = 8000, reply? }) → { text, sources:[{url,title}], usage, costUsd, incomplete, toolErrors:[{tool,code,url?}] }` (Übersicht §6 + E6); `batch = { create(deps, requests), retrieve(batchId), results(batchId) → [{ customId, type, text?, usage?, costUsd? }] }`; `BOT_VERBOT_DOMAINS = ['kleinanzeigen.de','ebay-kleinanzeigen.de','facebook.com','fb.com','instagram.com','linkedin.com','xing.com','upwork.com','myhammer.de','freelancermap.de','freelance.de']` (Export aus `src/claude.js`; E11); Skript `scripts/check-modelle.mjs`: `evaluate(models) → { exit:0|1|2, searchModel:string, lines:string[] }` (`models` = Antworten von `models.retrieve` je Modell-ID) und `evaluateProbe(probe) → { exit:0|1, searchModel:string, lines:string[] }` (`probe[modellId] = { ok:boolean, reason? }`, Ergebnis je einer echten Probe-Suche; `models.retrieve` liefert den Zweig `server_tools.web_search` womöglich gar nicht, dann entscheidet nur die Probe).
|
||
- Consumes: `assertCap`/`hasCap` (T05), `ask`-Bausteine aus T13.
|
||
|
||
- [ ] **Step 1: Tests schreiben.**
|
||
```js
|
||
const pause = (id) => msg({ stop_reason: 'pause_turn', content: [{ type: 'server_tool_use', id, name: 'web_search', input: { query: 'q' } }], usage: { input_tokens: 1000, output_tokens: 100, server_tool_use: { web_search_requests: 1 } } })
|
||
const done = msg({ content: [{ type: 'text', text: 'Fund A' }, { type: 'web_search_tool_result', tool_use_id: 's1', content: [{ type: 'web_search_result', url: 'https://a.de', title: 'A' }, { type: 'web_search_result', url: 'https://a.de', title: 'A' }] }], usage: { input_tokens: 500, output_tokens: 50 } })
|
||
test('Werkzeuge: web_search_20260318 + web_fetch_20260318 mit allowed_callers direct; Rolle ohne web_search → CapError ohne Aufruf', async () => {
|
||
const sdk = fakeSdk([done]); const c = createClaude({ apiKey: 'k', sdk })
|
||
await c.search(makeDeps(), { workerId: 'rita', system: 'S', task: 'T', maxUses: 3, blockedDomains: ['facebook.com'] })
|
||
assert.deepEqual(sdk.calls[0].tools, [{ type: 'web_search_20260318', name: 'web_search', max_uses: 3, allowed_callers: ['direct'], blocked_domains: ['facebook.com'] }, { type: 'web_fetch_20260318', name: 'web_fetch', max_uses: 3, allowed_callers: ['direct'], blocked_domains: BOT_VERBOT_DOMAINS }])
|
||
assert.ok(!('output_config' in sdk.calls[0]))
|
||
await assert.rejects(c.search(makeDeps(), { workerId: 'kim', system: 'S', task: 'T', maxUses: 1 }), CapError)
|
||
await assert.rejects(c.search(makeDeps(), { workerId: 'rita', system: 'S', task: 'T', maxUses: 1, allowedDomains: ['a.de'], blockedDomains: ['b.de'] }), /entweder/)
|
||
})
|
||
test('Plattformen mit Bot-Verbot werden nie abgerufen (Übersicht §2.11): web_fetch sperrt sie immer, auch bei allowedDomains', async () => {
|
||
for (const d of ['kleinanzeigen.de', 'facebook.com', 'upwork.com', 'myhammer.de', 'freelancermap.de', 'freelance.de']) assert.ok(BOT_VERBOT_DOMAINS.includes(d), d)
|
||
const run = async (extra) => { const sdk = fakeSdk([done]); await createClaude({ apiKey: 'k', sdk }).search(makeDeps(), { workerId: 'rita', system: 'S', task: 'T', maxUses: 2, ...extra }); return sdk.calls[0].tools }
|
||
let tools = await run({}); assert.deepEqual(tools[1].blocked_domains, BOT_VERBOT_DOMAINS); assert.ok(!('blocked_domains' in tools[0]))
|
||
tools = await run({ blockedDomains: ['x.de'] }); assert.deepEqual(tools[1].blocked_domains, [...BOT_VERBOT_DOMAINS, 'x.de'])
|
||
tools = await run({ allowedDomains: ['a.de', 'kleinanzeigen.de', 'm.facebook.com'] }); assert.deepEqual(tools[0].allowed_domains, ['a.de', 'kleinanzeigen.de', 'm.facebook.com']); assert.deepEqual(tools[1].allowed_domains, ['a.de']); assert.ok(!('blocked_domains' in tools[1]))
|
||
tools = await run({ allowedDomains: ['kleinanzeigen.de'] }); assert.deepEqual(tools.map((t) => t.name), ['web_search']) // nichts Abrufbares übrig → kein web_fetch
|
||
})
|
||
test('pause_turn: Fortsetzungen mit angehängtem Assistenten-Inhalt, Kosten und Suchen summiert, Quellen ohne Doppelte', async () => {
|
||
const sdk = fakeSdk([pause('s0'), pause('s1'), done]); const deps = makeDeps()
|
||
const r = await createClaude({ apiKey: 'k', sdk }).search(deps, { workerId: 'rita', system: 'S', task: 'T', maxUses: 5 })
|
||
assert.equal(sdk.calls.length, 3); assert.equal(sdk.calls[2].messages.length, 3); assert.equal(r.text, 'Fund A'); assert.deepEqual(r.sources, [{ url: 'https://a.de', title: 'A' }]); assert.equal(r.incomplete, false)
|
||
const d = await getDaily(deps, '2026-10-19'); assert.equal(d.searches, 2); near(r.costUsd, d.costUsd)
|
||
})
|
||
test('höchstens 5 Fortsetzungen: 6 Aufrufe, incomplete=true', async () => {
|
||
const sdk = fakeSdk([pause('s')]); const r = await createClaude({ apiKey: 'k', sdk }).search(makeDeps(), { workerId: 'rita', system: 'S', task: 'T', maxUses: 5 })
|
||
assert.equal(sdk.calls.length, 6); assert.equal(r.incomplete, true)
|
||
})
|
||
test('robots/Zugriffsfehler eines Tools: toolErrors statt Wurf, kein Zusatzaufruf', async () => {
|
||
const res = msg({ content: [{ type: 'server_tool_use', id: 'srv1', name: 'web_fetch', input: { url: 'https://x.de' } }, { type: 'web_fetch_tool_result', tool_use_id: 'srv1', content: { type: 'web_fetch_tool_result_error', error_code: 'url_not_allowed' } }, { type: 'text', text: 'nichts' }] })
|
||
const sdk = fakeSdk([res]); const r = await createClaude({ apiKey: 'k', sdk }).search(makeDeps(), { workerId: 'rita', system: 'S', task: 'T', maxUses: 2 })
|
||
assert.deepEqual(r.toolErrors, [{ tool: 'web_fetch', code: 'url_not_allowed', url: 'https://x.de' }]); assert.equal(sdk.calls.length, 1)
|
||
})
|
||
test('searchModel überschreibt das Rollenmodell und bestimmt die Kosten', async () => {
|
||
const sdk = fakeSdk([done]); const r = await createClaude({ apiKey: 'k', sdk, searchModel: 'claude-sonnet-5-5' }).search(makeDeps(), { workerId: 'rita', system: 'S', task: 'T', maxUses: 1 })
|
||
assert.equal(sdk.calls[0].model, 'claude-sonnet-5-5'); near(r.costUsd, costOf('claude-sonnet-5-5', done.usage))
|
||
})
|
||
test('Budget: Pause → BudgetStop ohne Aufruf; Console-Fehler in der Fortsetzung → Kosten der ersten Antwort bleiben gebucht', async () => {
|
||
const deps = makeDeps(); await updateSettings(deps, { paused: true }, 't'); const sdk = fakeSdk([done])
|
||
await assert.rejects(createClaude({ apiKey: 'k', sdk }).search(deps, { workerId: 'rita', system: 'S', task: 'T', maxUses: 1 }), BudgetStop); assert.equal(sdk.calls.length, 0)
|
||
const d2 = makeDeps(); const b400 = apiError(400, { type: 'error', error: { type: 'invalid_request_error', message: 'You have reached your specified workspace API usage limits.' } })
|
||
await assert.rejects(createClaude({ apiKey: 'k', sdk: fakeSdk([pause('s'), b400]) }).search(d2, { workerId: 'rita', system: 'S', task: 'T', maxUses: 1 }), (e) => e instanceof BudgetStop && e.reason === 'console')
|
||
assert.ok((await getDaily(d2, '2026-10-19')).costUsd > 0)
|
||
})
|
||
test('batch: create reicht Anfragen durch (mit Budget-Vorabprüfung); results liefert Kosten mit Batch-Tarif', async () => {
|
||
const sdk = fakeSdk([]); const created = []
|
||
sdk.messages.batches = { create: async (b) => { created.push(b); return { id: 'b1', processing_status: 'in_progress' } }, retrieve: async () => ({ id: 'b1', processing_status: 'ended' }),
|
||
results: async () => (async function* () { yield { custom_id: 'a', result: { type: 'succeeded', message: msg({ model: 'claude-sonnet-5-5', usage: { input_tokens: 1e6, output_tokens: 1e6 }, content: [{ type: 'text', text: 'T' }] }) } }; yield { custom_id: 'b', result: { type: 'errored' } } })() }
|
||
const c = createClaude({ apiKey: 'k', sdk }); assert.equal((await c.batch.create(makeDeps(), [{ custom_id: 'a', params: { model: 'claude-haiku-5-5', max_tokens: 100, messages: [] } }])).id, 'b1'); assert.equal(created[0].requests.length, 1)
|
||
const res = await c.batch.results('b1'); near(res[0].costUsd, 6); assert.deepEqual([res[0].customId, res[0].text, res[1].type, res[1].costUsd], ['a', 'T', 'errored', undefined])
|
||
})
|
||
```
|
||
`checkModelle.test.js`:
|
||
```js
|
||
const cap = (supported) => ({ capabilities: { server_tools: { web_search: { supported } } } })
|
||
const all = (h, s) => ({ 'claude-haiku-5-5': h, 'claude-sonnet-5-5': s, 'claude-opus-5-5': cap(true) })
|
||
test('Haiku kann suchen → keine Änderung', () => { const r = evaluate(all(cap(true), cap(true))); assert.deepEqual([r.exit, r.searchModel], [0, '']) })
|
||
test('Haiku kann nicht → Fallback Sonnet mit .env-Zeile', () => {
|
||
const r = evaluate(all(cap(false), cap(true))); assert.deepEqual([r.exit, r.searchModel], [0, 'claude-sonnet-5-5']); assert.ok(r.lines.includes('WORKERS_SEARCH_MODEL=claude-sonnet-5-5'))
|
||
})
|
||
test('Angabe fehlt → Exit 2 (echte Probe-Suche nötig); weder Haiku noch Sonnet → Exit 1', () => {
|
||
assert.equal(evaluate(all({}, cap(true))).exit, 2); assert.equal(evaluate(all(cap(false), cap(false))).exit, 1)
|
||
})
|
||
test('Probe-Suche (--probe): Haiku ok → keine Änderung; Haiku verweigert + Sonnet ok → Fallback mit .env-Zeile; beide verweigert → Exit 1', () => {
|
||
const h = evaluateProbe({ 'claude-haiku-5-5': { ok: true } }); assert.deepEqual([h.exit, h.searchModel], [0, ''])
|
||
const f = evaluateProbe({ 'claude-haiku-5-5': { ok: false, reason: 'does not support web_search' }, 'claude-sonnet-5-5': { ok: true } })
|
||
assert.deepEqual([f.exit, f.searchModel], [0, 'claude-sonnet-5-5']); assert.ok(f.lines.includes('WORKERS_SEARCH_MODEL=claude-sonnet-5-5'))
|
||
assert.equal(evaluateProbe({ 'claude-haiku-5-5': { ok: false }, 'claude-sonnet-5-5': { ok: false } }).exit, 1)
|
||
})
|
||
```
|
||
- [ ] **Step 2: Fehlschlag sehen.** `cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/claudeSearch.test.js test/checkModelle.test.js` → FAIL (`search is not a function`, `ERR_MODULE_NOT_FOUND` für das Skript).
|
||
- [ ] **Step 3: Implementieren.** `search`: `assertCap(workerId, 'web_search')`; `allowedDomains` und `blockedDomains` zusammen ⇒ `Error('Entweder allowedDomains oder blockedDomains')`; Modell = `searchModel || MODELS[role]`; `web_fetch` nur, wenn `hasCap(workerId,'web_fetch')`, und immer mit `blocked_domains` = `BOT_VERBOT_DOMAINS` + eigene `blockedDomains` (ohne Doppelte, Reihenfolge wie im Test); bei `allowedDomains` trägt `web_fetch` stattdessen `allowed_domains` = dieselbe Liste ohne Einträge, die gleich einer `BOT_VERBOT_DOMAINS`-Domain oder deren Subdomain sind (bleibt nichts übrig ⇒ kein `web_fetch`-Werkzeug; `web_search` behält die Liste unverändert, Suchtreffer sind erlaubt); Vorabprüfung `checkBudget(deps, maxUses*0.01 + estimateUsd(...), { reply })`. Schleife: Antwort **sofort buchen** (`book`, `counters:{ searches, fetches, calls:1, tokensIn, tokensOut }`); `pause_turn` ⇒ `messages = [...messages, { role:'assistant', content }]` und erneut, höchstens 5 Fortsetzungen, danach `incomplete = true`; Kosten pro Antwort mit `costOf` und summiert. `text` = alle Text-Blöcke aller Antworten; `sources` aus `web_search_tool_result`-Einträgen (`web_search_result`), doppelte URLs entfernt; `toolErrors` aus Blöcken `web_search_tool_result`/`web_fetch_tool_result`, deren `content.type` auf `_tool_result_error` endet (`tool` = Blocktyp ohne `_tool_result`, `code` = `content.error_code`, `url` aus dem `server_tool_use`-Block mit gleicher `tool_use_id`); SDK-Fehler wie in `ask` (gemeinsamer Helfer). `batch.create`: Budget-Vorabprüfung mit halbierter Schätzung, dann `sdk.messages.batches.create({ requests })`; `results`: `for await` über `sdk.messages.batches.results(id)`, Kosten `costOf(message.model, message.usage, { batch:true })`, gebucht wird vom Aufrufer. `scripts/check-modelle.mjs`: `evaluate(models)` rein (Logik: Haiku `supported === true` ⇒ Exit 0; Angabe bei Haiku fehlt ⇒ Exit 2 „unklar – mit `--probe` prüfen“; Haiku `false` und Sonnet `true` ⇒ Exit 0 mit Zeile `WORKERS_SEARCH_MODEL=claude-sonnet-5-5` und `searchModel` gesetzt; sonst Exit 1); `evaluateProbe(probe)` rein (Haiku `ok` ⇒ Exit 0, `searchModel ''`; Haiku nicht ok und Sonnet ok ⇒ Exit 0 mit derselben `.env`-Zeile; sonst Exit 1); `main` ruft `new Anthropic({ apiKey }).models.retrieve(id)` für die drei Modelle (Schlüssel aus `ANTHROPIC_API_KEY`, nie ausgeben) und druckt nur die Zeilen. Mit `--probe` (nur bei Exit 2 nötig, kostet etwa 0,02 $, wird nicht gebucht) folgt je Modell – erst Haiku, nur bei Fehlschlag Sonnet – ein echter Aufruf `messages.create` mit `max_tokens: 300`, Eingabe `Suche kurz: Öffnungszeiten Rathaus Kaiserslautern.` und `tools: [{ type: 'web_search_20260318', name: 'web_search', max_uses: 1, allowed_callers: ['direct'] }]`; `ok` = Antwort ohne Fehler und `usage.server_tool_use.web_search_requests ≥ 1`, bei einem API-Fehler `ok:false` mit auf 120 Zeichen gekürzter Meldung (`reason`), das Ergebnis geht an `evaluateProbe`.
|
||
- [ ] **Step 4: Tests laufen lassen.** Gleicher Befehl → PASS (8 + 4 Tests).
|
||
- [ ] **Step 5: Commit.** `git add src/claude.js scripts/check-modelle.mjs test/claudeSearch.test.js test/checkModelle.test.js && git commit -m "P1-T14: Claude search mit pause_turn, batch, Modell-Prüfskript" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"`
|
||
|
||
---
|
||
|
||
### P1-T15: Auftragswarteschlange `jobs.js`
|
||
|
||
**Files:**
|
||
- Create: `src/jobs.js`
|
||
- Test: `test/jobs.test.js`
|
||
|
||
**Interfaces:**
|
||
- Produces: `enqueue(deps, { kind, workerId, prospectId?, threadId?, previewId?, payload }) → Job`; `next(deps, { kinds }) → Job|null` (ältester `offen`-Job der Arten, setzt `laeuft`, `startedAt`, `attempts+1`); `finish(deps, jobId, result)`; `fail(deps, jobId, error, { retry = true })`; `reclaimStale(deps, { olderThanMs }) → Anzahl`; `JOB_KINDS` (aus `SCHEMA`); `Job = { id, kind, workerId, status, prospectId, threadId, previewId, attempts, payload, createdAt }` (Übersicht §6 + E8).
|
||
|
||
- [ ] **Step 1: Test schreiben.**
|
||
```js
|
||
test('enqueue: offener Job, payload als JSON; ungültige Art/Worker/zu großes payload werfen', async () => {
|
||
const deps = makeDeps(); const j = await enqueue(deps, { kind: 'suche', workerId: 'ali', payload: { room: 'aerzte' } })
|
||
assert.deepEqual([j.status, j.attempts, j.payload], ['offen', 0, { room: 'aerzte' }]); assert.equal(deps.db.all('workersJobs')[0].payloadJson, '{"room":"aerzte"}')
|
||
await assert.rejects(enqueue(deps, { kind: 'quatsch', workerId: 'ali', payload: {} }), /Auftragsart/)
|
||
await assert.rejects(enqueue(deps, { kind: 'suche', workerId: 'niemand', payload: {} }), /Worker/)
|
||
await assert.rejects(enqueue(deps, { kind: 'suche', workerId: 'ali', payload: { x: 'y'.repeat(16000) } }), /zu groß/)
|
||
})
|
||
test('next: ältester offener Job der gewünschten Arten; fremde Arten bleiben offen', async () => {
|
||
const deps = makeDeps(); await enqueue(deps, { kind: 'bau', workerId: 'bob', payload: {} }); deps.clock.advance(1000)
|
||
const a = await enqueue(deps, { kind: 'suche', workerId: 'ali', payload: { n: 1 } }); deps.clock.advance(1000); await enqueue(deps, { kind: 'suche', workerId: 'hanna', payload: { n: 2 } })
|
||
const got = await next(deps, { kinds: ['suche'] }); assert.equal(got.id, a.id); assert.deepEqual([got.status, got.attempts], ['laeuft', 1])
|
||
assert.equal((await next(deps, { kinds: ['suche'] })).payload.n, 2); assert.equal(await next(deps, { kinds: ['suche'] }), null)
|
||
assert.equal(deps.db.all('workersJobs').find((d) => d.kind === 'bau').status, 'offen')
|
||
})
|
||
test('finish speichert resultJson; fail: Wiederholung bis 3 Versuche, dann fehler; retry:false sofort', async () => {
|
||
const deps = makeDeps(); const a = await enqueue(deps, { kind: 'suche', workerId: 'ali', payload: {} })
|
||
await next(deps, { kinds: ['suche'] }); await finish(deps, a.id, { found: 3 }); let d = deps.db.all('workersJobs')[0]; assert.deepEqual([d.status, d.resultJson], ['fertig', '{"found":3}']); assert.ok(d.finishedAt)
|
||
const b = await enqueue(deps, { kind: 'suche', workerId: 'ali', payload: {} })
|
||
for (let i = 1; i <= 3; i++) { await next(deps, { kinds: ['suche'] }); await fail(deps, b.id, 'x'.repeat(3000)); d = deps.db.all('workersJobs')[1]; assert.equal(d.status, i < 3 ? 'offen' : 'fehler') }
|
||
assert.equal(d.error.length, 2000)
|
||
const c = await enqueue(deps, { kind: 'suche', workerId: 'ali', payload: {} }); await next(deps, { kinds: ['suche'] }); await fail(deps, c.id, 'weg', { retry: false }); assert.equal(deps.db.all('workersJobs')[2].status, 'fehler')
|
||
})
|
||
test('reclaimStale: hängender laeuft-Job wird wieder offen, jüngerer und warten_batch nicht', async () => {
|
||
const deps = makeDeps(); const old = await enqueue(deps, { kind: 'suche', workerId: 'ali', payload: {} }); await next(deps, { kinds: ['suche'] })
|
||
deps.clock.advance(40 * 60000); const young = await enqueue(deps, { kind: 'suche', workerId: 'hanna', payload: {} }); await next(deps, { kinds: ['suche'] })
|
||
deps.db.seed('workersJobs', { kind: 'bau', workerId: 'bob', status: 'warten_batch', startedAt: '2026-10-19T00:00:00.000Z' })
|
||
assert.equal(await reclaimStale(deps, { olderThanMs: 30 * 60000 }), 1)
|
||
const st = (id) => deps.db.all('workersJobs').find((d) => d.$id === id).status; assert.deepEqual([st(old.id), st(young.id)], ['offen', 'laeuft']); assert.equal(deps.db.all('workersJobs').find((d) => d.kind === 'bau').status, 'warten_batch')
|
||
})
|
||
```
|
||
- [ ] **Step 2: Fehlschlag sehen.** `cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/jobs.test.js` → FAIL `ERR_MODULE_NOT_FOUND`.
|
||
- [ ] **Step 3: Implementieren.** `next`: `list('workersJobs', [Query.equal('status',['offen']), Query.equal('kind', kinds), Query.orderAsc('$createdAt'), Query.limit(1)])` (nutzt Index `idx_kind_status`); Anspruch per `update` (ein Prozess, kein verteiltes Locking). `payloadJson`/`resultJson` > 16000 Zeichen ⇒ `Error('payload zu groß')`. `fail`: `attempts < 3 && retry` ⇒ `offen` (Fehlertext gespeichert), sonst `fehler` mit `finishedAt`. `reclaimStale`: `laeuft`-Jobs mit `startedAt` älter als `olderThanMs` ⇒ `offen`, `error='neu gestartet nach Hänger'`.
|
||
- [ ] **Step 4: Test laufen lassen.** Gleicher Befehl → PASS (4 Tests).
|
||
- [ ] **Step 5: Commit.** `git add src/jobs.js test/jobs.test.js && git commit -m "P1-T15: Auftragswarteschlange" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"`
|
||
|
||
---
|
||
|
||
### P1-T16: Normalisierung – Port aus WOMS mit „(0)“-Fix
|
||
|
||
**Files:**
|
||
- Create: `src/register/normalize.js`
|
||
- Test: `test/normalize.test.js`
|
||
|
||
**Interfaces:**
|
||
- Produces: `normalizeName(name)`, `normalizePhone(phone)`, `normalizeEmail(email)`, `companyDomain(email)`, `isFreemail(domain)` – Kopie von `/home/webklar/apps/ticket.webklar.com/dev-workers/src/lib/leadDuplicates.js` (nur diese Funktionen und `FREEMAIL`/`LEGAL_WORDS`, ohne `findDuplicates` und Absage-Funktionen), mit Fix in `normalizePhone` (Übersicht §6).
|
||
|
||
- [ ] **Step 1: Test schreiben** (die Fälle aus WOMS `tests/unit/leadDuplicates.test.mjs` plus die neuen):
|
||
```js
|
||
test('normalizeName wie WOMS', () => {
|
||
assert.equal(normalizeName('Bäckerei Müller GmbH & Co. KG'), 'baeckerei mueller'); assert.equal(normalizeName('Café Rosé'), 'cafe rose')
|
||
assert.equal(normalizeName('Friseur Amin e.K.'), 'friseur amin'); assert.equal(normalizeName('Straße-Bau UG (haftungsbeschränkt)'), 'strasse bau')
|
||
assert.equal(normalizeName(''), ''); assert.equal(normalizeName(undefined), '')
|
||
})
|
||
test('companyDomain/normalizeEmail/isFreemail wie WOMS', () => {
|
||
assert.equal(companyDomain('Info@Baecker-Mueller.de'), 'baecker-mueller.de'); assert.equal(companyDomain('x@gmail.com'), ''); assert.equal(companyDomain('x@web.de'), ''); assert.equal(companyDomain('kaputt'), '')
|
||
assert.equal(normalizeEmail(' A@B.DE '), 'a@b.de'); assert.equal(isFreemail('gmx.de'), true); assert.equal(isFreemail('mueller.de'), false)
|
||
})
|
||
test('normalizePhone: alle Schreibweisen derselben Nummer ergeben dieselbe Zahl (inkl. "(0)"-Fix)', () => {
|
||
for (const p of ['0631 123456', '0631/123456', '+49 631 123456', '0049 631 123456', '+49 (0) 631 123456', '+49(0)631/123456', '0049 (0)631 123456', '+49 0631 123456', '0631-123456'])
|
||
assert.equal(normalizePhone(p), '0631123456', p)
|
||
assert.equal(normalizePhone('12 34'), ''); assert.equal(normalizePhone(null), ''); assert.equal(normalizePhone('+43 1 234567'), '431234567') // Ausland bleibt unverändert (ohne Plus)
|
||
})
|
||
```
|
||
- [ ] **Step 2: Fehlschlag sehen.** `cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/normalize.test.js` → FAIL `ERR_MODULE_NOT_FOUND`.
|
||
- [ ] **Step 3: Implementieren.** Funktionskörper 1:1 aus `leadDuplicates.js` übernehmen (Kommentar mit Quelle und Stand `d288301`), `FREEMAIL` zusätzlich über `isFreemail(domain)` exportieren. Fix in `normalizePhone`: vor dem Ziffern-Ziehen `(0)` (`/\(\s*0\s*\)/g`) entfernen; nach erkannter deutscher Landesvorwahl (`0049…` oder `+49…`) den Rest nehmen, **eine** führende `0` davon entfernen und `0` voranstellen (damit `+49 0631…` nicht `00631…` wird); sonst Verhalten unverändert (unter 6 Ziffern ⇒ `''`).
|
||
- [ ] **Step 4: Test laufen lassen.** Gleicher Befehl → PASS (3 Tests).
|
||
- [ ] **Step 5: Commit.** `git add src/register/normalize.js test/normalize.test.js && git commit -m "P1-T16: Normalisierung aus WOMS mit (0)-Fix" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"`
|
||
|
||
---
|
||
|
||
### P1-T17: Domain- und Schlüssel-Normalisierung
|
||
|
||
**Files:**
|
||
- Modify: `src/register/normalize.js`
|
||
- Test: `test/normalizeDomain.test.js`
|
||
|
||
**Interfaces:**
|
||
- Produces: `normalizeDomain(input) → string` (eTLD+1 ohne `www.`, `''` für Müll/Verzeichnisse/Soziale/IPs; Shared-Hosting behält den vollen Host); `normalizePhoneE164(input) → string`; `emailKey(input) → string`; `placeIdKey(input) → string`; `namePlzKey(name, plz) → string` (Übersicht §6; `''` = kein Schlüssel).
|
||
|
||
- [ ] **Step 1: Test schreiben.**
|
||
```js
|
||
test('normalizeDomain: Müll, Markdown, Pfade, IDN, Subdomains', () => {
|
||
const cases = [['https://www.Mueller-Backt.de/kontakt?x=1', 'mueller-backt.de'], ['[Müller](https://www.mueller-backt.de/)', 'mueller-backt.de'], ['mueller-backt.de', 'mueller-backt.de'], ['Mueller-Backt.DE.', 'mueller-backt.de'],
|
||
['shop.example.co.uk/x', 'example.co.uk'], ['http://blog.mueller-backt.de', 'mueller-backt.de'], ['keine', ''], ['nur IDENTICA-Ketten-Profilseite', ''], ['', ''], [null, ''], ['192.168.1.1', ''], ['http://localhost', '']]
|
||
for (const [i, o] of cases) assert.equal(normalizeDomain(i), o, String(i))
|
||
assert.match(normalizeDomain('http://bäckerei-müller.de'), /^xn--[a-z0-9-]+\.de$/)
|
||
})
|
||
test('Verzeichnisse und soziale Netze liefern keinen Schlüssel', () => {
|
||
for (const u of ['https://www.facebook.com/baeckerei', 'instagram.com/x', 'https://g.page/x', 'https://www.google.com/maps/place/x', 'https://www.gelbeseiten.de/x', 'dasoertliche.de', 'https://www.11880.com/x', 'cylex.de', 'golocal.de', 'meinestadt.de', 'https://de.linkedin.com/in/x', 'xing.com/profile/x', 'youtube.com/@x'])
|
||
assert.equal(normalizeDomain(u), '', u)
|
||
})
|
||
test('Shared-Hosting: volle Subdomain statt Plattform-Domain', () => {
|
||
assert.equal(normalizeDomain('https://meinbetrieb.jimdofree.com/start'), 'meinbetrieb.jimdofree.com'); assert.equal(normalizeDomain('x.wixsite.com/shop'), 'x.wixsite.com')
|
||
assert.equal(normalizeDomain('https://betrieb.business.site'), 'betrieb.business.site'); assert.equal(normalizeDomain('shop.myshopify.com'), 'shop.myshopify.com'); assert.equal(normalizeDomain('jimdofree.com'), '')
|
||
})
|
||
test('normalizePhoneE164: gleiche Nummer, gleiche Zahl', () => {
|
||
for (const p of ['0631 123456', '0631/123456', '+49 (0) 631 123456', '0049 631 123456']) assert.equal(normalizePhoneE164(p), '+49631123456', p)
|
||
assert.equal(normalizePhoneE164('0043 1 234567'), '+431234567'); assert.equal(normalizePhoneE164('+43 1 234567'), '+431234567'); assert.equal(normalizePhoneE164('12 34'), '')
|
||
})
|
||
test('emailKey', () => {
|
||
for (const [i, o] of [[' Info@Mueller.DE ', 'info@mueller.de'], ['mailto:info@x.de', 'info@x.de'], ['Max <Max@X.de>', 'max@x.de'], ['kaputt', ''], ['a@b', ''], ['a@b@c.de', ''], [undefined, '']]) assert.equal(emailKey(i), o, String(i))
|
||
})
|
||
test('placeIdKey', () => {
|
||
const id = 'ChIJN1t_tDeuEmsRUsoyG83frY4'; assert.equal(placeIdKey(id), id)
|
||
assert.equal(placeIdKey(`https://www.google.com/maps/place/?q=place_id:${id}&x=1`), id); assert.equal(placeIdKey('https://www.google.com/maps/search/?api=1&query=Lackwerk'), ''); assert.equal(placeIdKey('abc'), '')
|
||
})
|
||
test('namePlzKey: nur mit Namen (mind. ein Wort) und fünfstelliger PLZ', () => {
|
||
assert.equal(namePlzKey('Bäckerei Müller GmbH', '67655'), 'baeckerei mueller|67655'); assert.equal(namePlzKey('Bäckerei Müller', '67655 Kaiserslautern'), 'baeckerei mueller|67655')
|
||
assert.equal(namePlzKey('Bäckerei Müller', ''), ''); assert.equal(namePlzKey('& Co. GmbH', '67655'), ''); assert.equal(namePlzKey('Bäckerei Müller', 'Kaiserslautern'), '')
|
||
})
|
||
```
|
||
- [ ] **Step 2: Fehlschlag sehen.** `cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/normalizeDomain.test.js` → FAIL `does not provide an export named 'normalizeDomain'`.
|
||
- [ ] **Step 3: Implementieren.** `normalizeDomain`: Markdown `[x](url)` → `url`; Text mit Leerzeichen ohne Schema ⇒ `''`; ohne Schema `http://` voranstellen; `new URL()` (Fehler ⇒ `''`, Punycode kommt gratis); Host klein, `www.` und Schlusspunkt ab, IPv4/IPv6 und Hosts ohne Punkt ⇒ `''`; Verzeichnis-/Soziale-Liste `DIRECTORY` (eTLD+1-Domains `facebook.com, fb.com, instagram.com, g.page, gelbeseiten.de, dasoertliche.de, 11880.com, cylex.de, golocal.de, meinestadt.de, linkedin.com, xing.com, youtube.com, tiktok.com, twitter.com, x.com` plus Labelregel `google.*`) ⇒ `''`; Shared-Hosting-Suffixe `SHARED_HOSTS` (`jimdofree.com, jimdosite.com, wixsite.com, business.site, myshopify.com, weebly.com, wordpress.com, blogspot.com, webnode.page, godaddysites.com, site123.me, bplaced.net, npage.de`): Host mit Label davor bleibt voll, der nackte Suffix ⇒ `''`; sonst eTLD+1 mit kleiner Mehrlabel-Suffixliste (`co.uk, org.uk, ac.uk, gov.uk, com.au, co.at, or.at, co.nz, com.br, co.jp, com.tr`). `normalizePhoneE164`: leer, wenn `normalizePhone` leer; `+` am Anfang und nicht `+49` ⇒ `+` + Ziffern; `00…` (nicht `0049`) ⇒ `+` + Ziffern ohne `00`; sonst `+49` + Nummer ohne führende 0. `emailKey`: `mailto:` und `<…>` auflösen, trimmen, klein, genau ein `@`, Domain mit Punkt. `placeIdKey`: Regex `/^ChIJ[\w-]{10,}$/` auf den Wert oder auf `place_id[:=](ChIJ[^&\s]+)`. `namePlzKey`: `normalizeName(name)` nicht leer und erste `\b\d{5}\b` aus `plz`, Ergebnis `<name>|<plz>`.
|
||
- [ ] **Step 4: Test laufen lassen.** Gleicher Befehl und `test/normalize.test.js` → PASS (7 + 3 Tests).
|
||
- [ ] **Step 5: Commit.** `git add src/register/normalize.js test/normalizeDomain.test.js && git commit -m "P1-T17: Domain-, Telefon-, Mail-, Place-ID- und Name+PLZ-Schlüssel" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"`
|
||
|
||
---
|
||
|
||
### P1-T18: Register – `keysFor`, `keyId`, `claimKeys`
|
||
|
||
**Files:**
|
||
- Create: `src/register/keys.js`
|
||
- Test: `test/keys.test.js`
|
||
|
||
**Interfaces:**
|
||
- Produces: `keysFor(p) → [{ kind, value }]` (`p` = `{ website, email, phone, placeId, adId, name, postalCode }`, die ersten drei und `name` auch als Arrays; Reihenfolge `dom` (Website), `dom` (Mail-Domain ohne Freemail), `mail`, `tel`, `pid`, `ad`, `namplz`; leere und doppelte entfallen); `keyId(kind, value) → 32 Hex-Zeichen`; `claimKeys(deps, keys, { prospectId = '', origin, blocked = false }) → { fresh, hits:[{kind,prospectId,blocked,origin}], hints:[…] }` (Übersicht §6 + E7); `prospectIdFor(keys) → string` (E10: `'p'` + erste 30 Zeichen von `keyId` des ersten Schlüssels mit `kind` ≠ `namplz`; ohne harten Schlüssel `''`).
|
||
|
||
- [ ] **Step 1: Test schreiben.**
|
||
```js
|
||
const P1 = { website: 'https://www.mueller-backt.de/', email: 'Info@mueller-backt.de', phone: '0631 123456', placeId: 'ChIJN1t_tDeuEmsRUsoyG83frY4', name: 'Bäckerei Müller GmbH', postalCode: '67655', adId: 'kleinanzeigen:2987654321' }
|
||
test('keyId: sha256(kind:value), 32 Hex-Zeichen', () => assert.equal(keyId('dom', 'example.de'), createHash('sha256').update('dom:example.de').digest('hex').slice(0, 32)))
|
||
test('prospectIdFor: deterministisch aus dem ersten harten Schlüssel (31 Zeichen); ohne harten Schlüssel leer', () => {
|
||
const id = prospectIdFor(keysFor(P1)); assert.equal(id, `p${keyId('dom', 'mueller-backt.de').slice(0, 30)}`); assert.equal(id.length, 31); assert.equal(prospectIdFor(keysFor(P1)), id)
|
||
assert.equal(prospectIdFor(keysFor({ name: 'Bäckerei Müller', postalCode: '67655' })), ''); assert.equal(prospectIdFor([]), '')
|
||
})
|
||
test('keysFor: alle Arten, ohne Leere und Doppelte; Freemail liefert keine Domain; Arrays erlaubt', () => {
|
||
assert.deepEqual(keysFor(P1), [{ kind: 'dom', value: 'mueller-backt.de' }, { kind: 'mail', value: 'info@mueller-backt.de' }, { kind: 'tel', value: '+49631123456' }, { kind: 'pid', value: 'ChIJN1t_tDeuEmsRUsoyG83frY4' }, { kind: 'ad', value: 'kleinanzeigen:2987654321' }, { kind: 'namplz', value: 'baeckerei mueller|67655' }])
|
||
assert.deepEqual(keysFor({ email: 'x@gmail.com' }), [{ kind: 'mail', value: 'x@gmail.com' }]); assert.deepEqual(keysFor({}), [])
|
||
assert.deepEqual(keysFor({ website: ['a.de', 'https://b.de'], phone: ['0631 1', '0631 123456'] }).map((k) => k.value), ['a.de', 'b.de', '+49631123456'])
|
||
})
|
||
test('claimKeys: erster gewinnt; Rest des abgelehnten Kandidaten wird trotzdem eingetragen; Dokument ohne Klartext', async () => {
|
||
const deps = makeDeps(); const a = await claimKeys(deps, keysFor(P1), { prospectId: 'pA', origin: 'workers' })
|
||
assert.deepEqual([a.fresh, a.hits, a.hints], [true, [], []])
|
||
assert.deepEqual(Object.keys(deps.db.all('workersKeys')[0]).filter((k) => !k.startsWith('$')).sort(), ['blocked', 'createdAt', 'hint', 'kind', 'origin', 'prospectId'])
|
||
const b = await claimKeys(deps, [{ kind: 'mail', value: 'info@mueller-backt.de' }, { kind: 'tel', value: '+4963199999' }], { prospectId: 'pB', origin: 'workers' })
|
||
assert.equal(b.fresh, false); assert.deepEqual(b.hits, [{ kind: 'mail', prospectId: 'pA', blocked: false, origin: 'workers' }]); assert.ok(await deps.db.get('workersKeys', keyId('tel', '+4963199999')))
|
||
})
|
||
test('Wiederholung nach Absturz: gleiche prospectId ist wieder fresh, fremde prospectId bleibt Treffer', async () => {
|
||
const deps = makeDeps(); await claimKeys(deps, keysFor(P1), { prospectId: 'pA', origin: 'workers' })
|
||
const again = await claimKeys(deps, keysFor(P1), { prospectId: 'pA', origin: 'workers' }); assert.deepEqual([again.fresh, again.hits], [true, []])
|
||
assert.equal((await claimKeys(deps, keysFor(P1), { prospectId: 'pZ', origin: 'workers' })).fresh, false)
|
||
})
|
||
test('namplz ist nur Hinweis: fresh bleibt true, hints gefüllt (auch mit blocked)', async () => {
|
||
const deps = makeDeps(); await claimKeys(deps, [{ kind: 'namplz', value: 'baeckerei mueller|67655' }], { origin: 'seed_kunde', blocked: true })
|
||
const c = await claimKeys(deps, [{ kind: 'namplz', value: 'baeckerei mueller|67655' }], { prospectId: 'pC', origin: 'workers' })
|
||
assert.deepEqual([c.fresh, c.hits, c.hints.length, c.hints[0].blocked], [true, [], 1, true])
|
||
})
|
||
test('blocked:true hebt bestehende Schlüssel auf gesperrt (Besitzer und Origin bleiben)', async () => {
|
||
const deps = makeDeps(); await claimKeys(deps, [{ kind: 'mail', value: 'a@b.de' }], { prospectId: 'pA', origin: 'workers' })
|
||
const r = await claimKeys(deps, [{ kind: 'mail', value: 'a@b.de' }], { origin: 'seed_kunde', blocked: true })
|
||
const d = await deps.db.get('workersKeys', keyId('mail', 'a@b.de')); assert.deepEqual([d.blocked, d.origin, d.prospectId], [true, 'workers', 'pA']); assert.deepEqual(r.hits, [{ kind: 'mail', prospectId: 'pA', blocked: true, origin: 'workers' }])
|
||
})
|
||
test('Startbestand (prospectId leer) zweimal: zweiter Lauf = nur Treffer; leere Werte entfallen; ungültige origin wirft', async () => {
|
||
const deps = makeDeps(); const ks = [{ kind: 'mail', value: 'a@firma.de' }, { kind: 'tel', value: '' }]
|
||
assert.equal((await claimKeys(deps, ks, { origin: 'seed_alt' })).fresh, true); assert.equal(deps.db.all('workersKeys').length, 1)
|
||
const second = await claimKeys(deps, ks, { origin: 'seed_alt' }); assert.equal(second.fresh, false); assert.equal(second.hits.length, 1); assert.equal(deps.db.all('workersKeys').length, 1)
|
||
await assert.rejects(claimKeys(deps, ks, { origin: 'irgendwer' }), /origin/)
|
||
})
|
||
```
|
||
- [ ] **Step 2: Fehlschlag sehen.** `cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/keys.test.js` → FAIL `ERR_MODULE_NOT_FOUND` (`src/register/keys.js`).
|
||
- [ ] **Step 3: Implementieren.** `claimKeys` je Schlüssel nacheinander (nie abbrechen): `create('workersKeys', { kind, prospectId?, origin, blocked, hint: kind === 'namplz', createdAt }, keyId(kind,value))`; bei `ConflictError` bestehendes Dokument lesen; ist `blocked` angefragt und bestehend nicht gesperrt ⇒ `update({ blocked:true })` (und im Ergebnis `blocked:true`); gehört der Schlüssel derselben nicht-leeren `prospectId` ⇒ kein Treffer; sonst `hits.push(…)` (`namplz` ⇒ `hints`). `fresh = hits.length === 0`. `origin` ∉ {workers, seed_alt, seed_kunde} ⇒ Fehler. Leere Werte überspringen; `prospectId` leer ⇒ Feld weglassen. `prospectIdFor` wie in den Interfaces.
|
||
- [ ] **Step 4: Test laufen lassen.** Gleicher Befehl → PASS (8 Tests).
|
||
- [ ] **Step 5: Commit.** `git add src/register/keys.js test/keys.test.js && git commit -m "P1-T18: Nie-doppelt-Register mit claimKeys" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"`
|
||
|
||
---
|
||
|
||
### P1-T19: Sperrliste `suppress` / `isSuppressed`
|
||
|
||
**Files:**
|
||
- Modify: `src/register/keys.js`
|
||
- Test: `test/suppression.test.js`
|
||
|
||
**Interfaces:**
|
||
- Produces: `suppress(deps, { email?, domain?, phone? }, reason, source) → { created: ['mail','dom','tel'…] }` (idempotent; `reason` ∈ abmeldung, widerspruch, nein, beschwerde, bounce, kunde, manuell); `isSuppressed(deps, { email?, domain?, phone? }) → { suppressed, reason }` (Übersicht §6 + E7). Dokument `workersSuppression` mit `$id = keyId(kind, wert)` (`mail` = `emailKey`, `dom` = `normalizeDomain`, `tel` = `normalizePhoneE164`).
|
||
|
||
- [ ] **Step 1: Test schreiben.**
|
||
```js
|
||
test('Freemail-Widerspruch sperrt nur die Adresse, nie gmail.com', async () => {
|
||
const deps = makeDeps(); await suppress(deps, { email: 'Max@Gmail.com' }, 'widerspruch', 'nora')
|
||
assert.deepEqual(deps.db.all('workersSuppression').map((d) => d.kind), ['mail'])
|
||
assert.equal((await isSuppressed(deps, { email: 'max@gmail.com' })).suppressed, true)
|
||
assert.equal((await isSuppressed(deps, { email: 'andere@gmail.com' })).suppressed, false); assert.equal((await isSuppressed(deps, { domain: 'gmail.com' })).suppressed, false)
|
||
})
|
||
test('Firmen-Adresse sperrt Adresse und Domain; Telefon in jeder Schreibweise', async () => {
|
||
const deps = makeDeps(); const r = await suppress(deps, { email: 'info@mueller.de', phone: '0631 123456' }, 'abmeldung', 'mensch:u1')
|
||
assert.deepEqual(r.created.sort(), ['dom', 'mail', 'tel']); assert.ok(await deps.db.get('workersSuppression', keyId('dom', 'mueller.de')))
|
||
assert.deepEqual(await isSuppressed(deps, { email: 'chef@mueller.de' }), { suppressed: true, reason: 'abmeldung' })
|
||
assert.equal((await isSuppressed(deps, { phone: '+49 (0) 631 123456' })).suppressed, true); assert.equal((await isSuppressed(deps, { domain: 'https://www.mueller.de/kontakt' })).suppressed, true)
|
||
})
|
||
test('idempotent: zweiter Aufruf legt nichts an, erster Grund bleibt', async () => {
|
||
const deps = makeDeps(); await suppress(deps, { email: 'a@firma.de' }, 'nein', 'nora'); const r = await suppress(deps, { email: 'a@firma.de' }, 'beschwerde', 'nora')
|
||
assert.deepEqual(r.created, []); assert.equal((await isSuppressed(deps, { email: 'a@firma.de' })).reason, 'nein')
|
||
})
|
||
test('Register-Sperren zählen mit: Kunde und Alt-Lead "kontaktiert" gesperrt, normaler Alt-Lead nicht', async () => {
|
||
const deps = makeDeps()
|
||
await claimKeys(deps, [{ kind: 'mail', value: 'kunde@firma.de' }], { origin: 'seed_kunde', blocked: true }); await claimKeys(deps, [{ kind: 'tel', value: '+49631111111' }], { origin: 'seed_alt', blocked: true })
|
||
await claimKeys(deps, [{ kind: 'mail', value: 'neu@lead.de' }], { origin: 'seed_alt' })
|
||
assert.deepEqual(await isSuppressed(deps, { email: 'kunde@firma.de' }), { suppressed: true, reason: 'kunde' }); assert.deepEqual(await isSuppressed(deps, { phone: '0631 111111' }), { suppressed: true, reason: 'manuell' })
|
||
assert.equal((await isSuppressed(deps, { email: 'neu@lead.de' })).suppressed, false)
|
||
})
|
||
test('ungültiger Grund wirft; leere Eingabe → nicht gesperrt', async () => { await assert.rejects(suppress(makeDeps(), { email: 'a@b.de' }, 'egal', 'x'), /Grund/); assert.deepEqual(await isSuppressed(makeDeps(), {}), { suppressed: false, reason: null }) })
|
||
```
|
||
- [ ] **Step 2: Fehlschlag sehen.** `cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/suppression.test.js` → FAIL (`suppress is not a function`).
|
||
- [ ] **Step 3: Implementieren.** Schlüsselableitung: `mail` = `emailKey(email)`; `dom` = `normalizeDomain(domain)` und zusätzlich `normalizeDomain(companyDomain(email))` (Freemail ⇒ nichts); `tel` = `normalizePhoneE164(phone)`. `suppress`: je Schlüssel `create('workersSuppression', { kind, reason, createdAt, source }, keyId(kind, wert))`, `ConflictError` ignorieren. `isSuppressed`: erst `workersSuppression` (Reihenfolge mail, dom, tel), dann `workersKeys` mit `blocked === true` für dieselben Schlüssel (Reason: Origin `seed_kunde` ⇒ `kunde`, sonst `manuell`).
|
||
- [ ] **Step 4: Test laufen lassen.** Gleicher Befehl und `test/keys.test.js` → PASS (5 + 8 Tests).
|
||
- [ ] **Step 5: Commit.** `git add src/register/keys.js test/suppression.test.js && git commit -m "P1-T19: Sperrliste mit Freemail-Schutz" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"`
|
||
|
||
---
|
||
|
||
### P1-T20: Otto – Nachtplan
|
||
|
||
**Files:**
|
||
- Create: `src/otto/nachtplan.js`
|
||
- Test: `test/nachtplan.test.js`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `getSettings`/`updateSettings` (T07), `checkBudget` (T11), `enqueue` (T15), `emit` (T06), `assertCap`/`workerForRoom`/`roomLabel`/`ROLES` (T05), `nightKey`/`berlinNow` (T08), `Query` (T04).
|
||
- Produces: `planNight(deps, { run = 1 }) → { planned:[{ room, workerId, region, jobId }], skipped:null|'pause'|'budget'|'keine_raeume' }`. Job: `kind 'suche'`, `payload { night, run, room, region, candidates, entrance }`; Ereignis `auftrag` (`workerId 'otto'`, `to`, `jobId`, Text `Otto schickt <Name> los: <Raumname>.` für Anfragen-Räume, `Otto schickt <Name> los: <Raumname> in <Region> (<n> Kandidaten).` für Branchen-Räume).
|
||
|
||
- [ ] **Step 1: Tests schreiben.**
|
||
```js
|
||
const logAt = (deps, room, region, at) => deps.db.seed('workersSearchLog', { workerId: workerForRoom(room), room, region, query: 'q', at, found: 10, fresh: 8 })
|
||
const pick = (r) => r.planned.map((p) => [p.workerId, p.room, p.region])
|
||
test('Lauf 1: Rita und Finn jede Nacht + ein Branchen-Raum nach Suchprotokoll; Lauf 2 nimmt die nächste Kombination', async () => {
|
||
const deps = makeDeps({ now: '2026-10-19T20:00:00.000Z' }) // Montag 22:00 Berlin
|
||
await updateSettings(deps, { connectedRooms: ['netz', 'foren', 'aerzte', 'handwerk'], regions: ['Rheinland-Pfalz', 'Saarland'], runsPerNight: 2 }, 't')
|
||
logAt(deps, 'aerzte', 'Rheinland-Pfalz', '2026-10-18T22:00:00.000Z')
|
||
assert.deepEqual(pick(await planNight(deps, { run: 1 })), [['rita', 'netz', ''], ['finn', 'foren', ''], ['ali', 'aerzte', 'Saarland']])
|
||
assert.deepEqual(pick(await planNight(deps, { run: 2 })), [['hanna', 'handwerk', 'Rheinland-Pfalz']])
|
||
const jobs = deps.db.all('workersJobs'); assert.equal(jobs.length, 4); assert.ok(jobs.every((j) => j.kind === 'suche' && j.status === 'offen'))
|
||
assert.deepEqual(JSON.parse(jobs[2].payloadJson), { night: '2026-10-19', run: 1, room: 'aerzte', region: 'Saarland', candidates: 10, entrance: 'ohne_website' })
|
||
assert.equal(JSON.parse(jobs[0].payloadJson).entrance, 'anfrage')
|
||
const ev = deps.db.all('workersEvents').filter((e) => e.kind === 'auftrag'); assert.equal(ev.length, 4)
|
||
assert.deepEqual([ev[2].workerId, ev[2].to, ev[2].jobId, ev[2].text], ['otto', 'ali', jobs[2].$id, 'Otto schickt Ali los: Ärzte & Praxen in Saarland (10 Kandidaten).'])
|
||
})
|
||
test('alle Kombinationen schon gesucht: die älteste gewinnt', async () => {
|
||
const deps = makeDeps({ now: '2026-10-19T20:00:00.000Z' }); await updateSettings(deps, { connectedRooms: ['aerzte', 'handwerk'], regions: ['Rheinland-Pfalz', 'Saarland'] }, 't')
|
||
logAt(deps, 'aerzte', 'Rheinland-Pfalz', '2026-10-10T20:00:00.000Z'); logAt(deps, 'aerzte', 'Saarland', '2026-10-12T20:00:00.000Z'); logAt(deps, 'handwerk', 'Rheinland-Pfalz', '2026-10-09T20:00:00.000Z'); logAt(deps, 'handwerk', 'Saarland', '2026-10-11T20:00:00.000Z')
|
||
assert.deepEqual(pick(await planNight(deps, { run: 1 })), [['hanna', 'handwerk', 'Rheinland-Pfalz']])
|
||
})
|
||
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 })
|
||
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' })
|
||
assert.deepEqual(deps.db.all('workersEvents').map((e) => [e.workerId, e.kind]), [['otto', 'leerlauf']])
|
||
deps = makeDeps(); await updateSettings(deps, { activeRooms: ['aerzte'], connectedRooms: ['netz', 'aerzte'] }, 't'); assert.deepEqual(pick(await planNight(deps, { run: 1 })).map((x) => x[1]), ['aerzte'])
|
||
})
|
||
```
|
||
- [ ] **Step 2: Fehlschlag sehen.** `cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/nachtplan.test.js` → FAIL `ERR_MODULE_NOT_FOUND` (`src/otto/nachtplan.js`).
|
||
- [ ] **Step 3: Implementieren.** Ablauf: `assertCap('otto','plan')`; Einstellungen lesen, `paused` ⇒ `{ planned:[], skipped:'pause' }` ohne Schreibzugriff; `checkBudget(deps, 1)` (Mindestrest 1 $) nicht ok ⇒ `skipped:'budget'`; Räume = `SEARCH_ROOMS` ∩ `activeRooms` ∩ `connectedRooms`. Anfragen: nur in `run === 1` die Räume `netz` (Rita) und `foren` (Finn), `region ''`, `entrance 'anfrage'` (Kim/Pia treibt der Tick `alarme`, E8). Ohne Websites: je Aufruf genau **eine** Kombination aus den Räumen der Etage `ohne_website` × `settings.regions`: sortiert nach „zuletzt gesucht“ aufsteigend (nie gesucht zuerst), Gleichstand nach Rollenreihenfolge, dann Regionsreihenfolge; „zuletzt gesucht“ = jüngstes `at` aus `workersSearchLog` (je Raum: `Query.equal('room',[room])`, `orderDesc('at')`, `limit(100)`) **oder** `$createdAt` eines `suche`-Jobs der letzten 48 h (Payload `room`+`region`), damit Lauf 2 nicht dieselbe Kombination plant. Je Eintrag erst `enqueue`, dann `emit`. Ist nach der Auswahl nichts geplant, genau ein `leerlauf`-Ereignis (`Otto: Kein Raum angeschlossen, nichts zu planen.`) und `skipped:'keine_raeume'`.
|
||
- [ ] **Step 4: Test laufen lassen.** Gleicher Befehl → PASS (3 Tests).
|
||
- [ ] **Step 5: Commit.** `git add src/otto/nachtplan.js test/nachtplan.test.js && git commit -m "P1-T20: Otto plant die Nacht" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"`
|
||
|
||
---
|
||
|
||
### P1-T21: Otto – Morgenbericht
|
||
|
||
**Files:**
|
||
- Create: `src/otto/bericht.js`
|
||
- Test: `test/bericht.test.js`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `getSettings` (T07), `getDaily`/`monthDocs` (T09), `berlinNow` (T08), `formatUsd` (T11), `roomLabel` (T05), `emit` (T06), `deps.db.listAll/count`, `deps.notify` (E3).
|
||
- Produces: `buildReport(deps) → { subject, text }`; `sendReport(deps) → notify-Ergebnis` (bei `sent:false` zusätzlich Ereignis `fehler`, `otto`, `Morgenbericht nicht gesendet: <reason>`).
|
||
|
||
- [ ] **Step 1: Tests schreiben.**
|
||
```js
|
||
test('Bericht: Zählzeilen, Kosten, Wartendes, Fehler – nur Ereignisse der letzten 24 h', async () => {
|
||
const deps = makeDeps({ now: '2026-10-19T04:30:00.000Z' }) // Montag 06:30 Berlin
|
||
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')
|
||
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')
|
||
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\.\)/,
|
||
/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 () => {
|
||
const deps = makeDeps({ now: '2026-10-19T04:30:00.000Z' }); await updateSettings(deps, { paused: true, connectedRooms: ['netz'] }, 't')
|
||
deps.db.seed('workersEvents', { ts: '2026-10-19T01:00:00.000Z', workerId: 'otto', kind: 'budget_warnung', text: 'Budget-Warnung' })
|
||
const { text } = await buildReport(deps); assert.match(text, /Pause: aktiv/); assert.match(text, /Budget: Warnung ausgelöst/); assert.match(text, /Die Nacht war ruhig/); assert.ok(!/Netz-Radar/.test(text))
|
||
})
|
||
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}$/)
|
||
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 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>"`
|
||
|
||
---
|
||
|
||
### P1-T22: Seed-Bausteine für Alt-Leads und Kunden
|
||
|
||
**Files:**
|
||
- Create: `src/register/seed.js`, `test/fixtures/seed-backup.json`, `test/fixtures/seed-master.json`
|
||
- Test: `test/seed.test.js`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `keysFor` (T18), `normalizeDomain` (T17).
|
||
- Produces: `leadEntries(backup, master) → Entry[]` (`backup` = geparstes `appwrite-backup.json`, nur `.leads`; `master` = geparstes `master.json`, nur `.leads`); `customerEntries(customers, projects) → Entry[]`; `Entry = { origin:'seed_alt'|'seed_kunde', blocked:boolean, keys:[{kind,value}] }`.
|
||
|
||
- [ ] **Step 1: Fixtures und Test schreiben.** Fixtures mit erfundenen Daten: `seed-backup.json` = `{ "leads": [A, B, C], "customers": [ { "name": "Fake", "portalPassword": "GEHEIM-PW-3" } ] }` mit
|
||
A = `{ leadId:'lack-demo-eins', firma:'Lack Demo Eins GmbH', email:'info@lack-demo-eins.de', telefon:'+49 (0) 631 111111', website:'[lack-demo-eins.de](https://www.lack-demo-eins.de/)', adresse:'Musterstr. 1, 67655 Kaiserslautern', googleMapsLink:'https://www.google.com/maps/search/?api=1&query=Lack+Demo', templateData:'{"GOOGLE_PLACE_ID":"ChIJdemoPlaceId0001","PLZ":"67655"}', crmStatus:'neu', portalLogin:'x', portalPasswort:'GEHEIM-PW-1' }`,
|
||
B = `{ leadId:'lack-demo-zwei', firma:'Zwei Lackierer', email:'zwei@gmx.de', telefon:'0631 222222', website:'keine', adresse:'Weg 2, 80331 München', templateData:'{}', crmStatus:'kontaktiert', portalPasswort:'GEHEIM-PW-2' }`,
|
||
C = `{ leadId:'lack-demo-drei', firma:'Drei Lack', email:'', telefon:'', website:'https://drei-lack.jimdofree.com/start', adresse:'Allee 3, 85551 Kirchheim', templateData:'{"PLZ":"85551"}', crmStatus:'neu' }`;
|
||
`seed-master.json` = `{ "leads": [ …dieselben drei als snake_case (lead_id, firma, email, telefon, website, adresse, google_maps_link, template_data als Objekt, portal_passwort:'GEHEIM-PW-5'), wobei der dritte email:'kontakt@drei-lack.de', telefon:'08991 333333' hat ] }`.
|
||
```js
|
||
test('Alt-Leads: nur "kontaktiert" blockiert; Schlüssel bereinigt; Lücken aus master gefüllt; backup.customers ignoriert', () => {
|
||
const e = leadEntries(backup, master)
|
||
assert.deepEqual(e.map((x) => [x.origin, x.blocked]), [['seed_alt', false], ['seed_alt', true], ['seed_alt', false]])
|
||
assert.deepEqual(e[0].keys, [{ kind: 'dom', value: 'lack-demo-eins.de' }, { kind: 'mail', value: 'info@lack-demo-eins.de' }, { kind: 'tel', value: '+49631111111' }, { kind: 'pid', value: 'ChIJdemoPlaceId0001' }, { kind: 'namplz', value: 'lack demo eins|67655' }])
|
||
assert.deepEqual(e[1].keys, [{ kind: 'mail', value: 'zwei@gmx.de' }, { kind: 'tel', value: '+49631222222' }, { kind: 'namplz', value: 'zwei lackierer|80331' }])
|
||
assert.deepEqual(e[2].keys, [{ kind: 'dom', value: 'drei-lack.jimdofree.com' }, { kind: 'dom', value: 'drei-lack.de' }, { kind: 'mail', value: 'kontakt@drei-lack.de' }, { kind: 'tel', value: '+498991333333' }, { kind: 'namplz', value: 'drei lack|85551' }])
|
||
})
|
||
test('unbekannter crmStatus gilt als gesperrt (nur leer oder "neu" ist frei)', () => {
|
||
const b = structuredClone(backup); b.leads[0].crmStatus = 'verloren'; assert.equal(leadEntries(b, master)[0].blocked, true); b.leads[0].crmStatus = ''; assert.equal(leadEntries(b, master)[0].blocked, false)
|
||
})
|
||
const customers = [
|
||
{ $id: 'c1', name: 'Bäckerei Müller', companyName: 'Bäckerei Müller GmbH', email: 'info@mueller-backt.de; chef@gmail.com', phone: '0631 123456', postalCode: '67655', customerStatus: 'customer', portalPassword: 'GEHEIM-PW-4' },
|
||
{ $id: 'c2', name: 'Alter Lead', email: 'alt@lead.de', customerStatus: 'lead' }, { $id: 'c3', name: 'Abgesagt', email: 'weg@abgesagt.de', customerStatus: 'lost', archived: true }]
|
||
const projects = [{ customerId: 'c1', liveDomain: 'mueller-backt.de' }, { customerId: 'c1', liveDomain: 'baeckerei.project.webklar.com' }]
|
||
test('Kunden: alle Status (auch lost, archiviert, Lead) gesperrt; Mehrfachadressen; Vorschau-Hosts und eigene Domain ausgelassen', () => {
|
||
const e = customerEntries(customers, projects)
|
||
assert.equal(e.length, 3); assert.ok(e.every((x) => x.origin === 'seed_kunde' && x.blocked === true))
|
||
assert.deepEqual(e[0].keys, [{ kind: 'dom', value: 'mueller-backt.de' }, { kind: 'mail', value: 'info@mueller-backt.de' }, { kind: 'mail', value: 'chef@gmail.com' }, { kind: 'tel', value: '+49631123456' }, { kind: 'namplz', value: 'baeckerei mueller|67655' }])
|
||
assert.deepEqual(e[1].keys, [{ kind: 'dom', value: 'lead.de' }, { kind: 'mail', value: 'alt@lead.de' }])
|
||
})
|
||
test('Passwörter tauchen in keinem Ergebnis auf', () => { assert.ok(!JSON.stringify([leadEntries(backup, master), customerEntries(customers, projects)]).includes('GEHEIM')) })
|
||
```
|
||
- [ ] **Step 2: Fehlschlag sehen.** `cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/seed.test.js` → FAIL `ERR_MODULE_NOT_FOUND` (`src/register/seed.js`).
|
||
- [ ] **Step 3: Implementieren.** Felder **nur per Whitelist** lesen, nie das Objekt weiterreichen. Lead: je `leadId` (Backup zuerst, danach nur in `master` vorhandene) aus Backup `firma, email, telefon, website, adresse, googleMapsLink, templateData (JSON-String, bei Parse-Fehler {}), crmStatus` mit Lücken (leer) aus `master` (`firma, email, telefon, website, adresse, google_maps_link, template_data`-Objekt); `placeId` = `templateData.GOOGLE_PLACE_ID` sonst `placeIdKey(googleMapsLink)`; PLZ = `templateData.PLZ` sonst erste `\b\d{5}\b` aus `adresse`; Schlüssel über `keysFor({ website, email, phone, placeId, name:firma, postalCode })`; `blocked = crmStatus ∉ {'', 'neu', undefined}`. Kunde: `email` an `;`/`,` teilen, `name: [name, companyName]`, `website` = alle `liveDomain` des Kunden aus `projects` (nach `customerId`), danach `dom`-Schlüssel `webklar.com` (eigene Vorschau-Hosts) entfernen; jeder Kunde (egal welcher `customerStatus`, auch `archived`) ⇒ `blocked: true`, `origin: 'seed_kunde'`; Einträge ohne Schlüssel entfallen nicht (leere `keys` erlaubt).
|
||
- [ ] **Step 4: Test laufen lassen.** Gleicher Befehl → PASS (4 Tests).
|
||
- [ ] **Step 5: Commit.** `git add src/register/seed.js test/fixtures/seed-backup.json test/fixtures/seed-master.json test/seed.test.js && git commit -m "P1-T22: Seed-Bausteine für Alt-Leads und Kunden" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"`
|
||
|
||
---
|
||
|
||
### P1-T23: Seed-Skript `seed-register.mjs`
|
||
|
||
**Files:**
|
||
- Create: `scripts/seed-register.mjs`
|
||
- Test: `test/seedRunner.test.js`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `leadEntries`/`customerEntries` (T22), `claimKeys`/`keyId` (T18), `createDb`/`GESPERRTE_SAMMLUNGEN`/`Query` (T04).
|
||
- Produces: `main({ argv, deps, readJson, log }) → { created, existing }`; CLI `node scripts/seed-register.mjs [--dry-run] [--backup <pfad>] [--master <pfad>]` (Standard `/root/reports/leads-auf-eis-2026-09-30/appwrite-backup.json`, `/opt/webklar-automation/master.json`). Ausgabezeilen: `TROCKENLAUF – es wird nichts geschrieben.`, `Alte Leads: n (blockiert: m)`, `Kunden: n (customer a, lead b, lost c, archiviert d)`, `Schlüssel neu: n · schon vorhanden: m` – nie Adressen, Nummern oder Namen.
|
||
|
||
- [ ] **Step 1: Tests schreiben** (`backup`/`master` = die Fixtures aus T22, per `readFileSync` + `JSON.parse` geladen; Kunden und `websiteProjects` kommen über `deps.db.seed`, `readJson` liefert die Fixtures):
|
||
```js
|
||
const prepareCustomers = (deps) => {
|
||
deps.db.seed('customers', { name: 'Bäckerei Müller', email: 'info@mueller-backt.de', phone: '0631 123456', postalCode: '67655', customerStatus: 'customer', portalPassword: 'GEHEIM-PW-4' }, 'c1')
|
||
deps.db.seed('customers', { name: 'Altes Lead', email: 'alt@lead.de', customerStatus: 'lead' }, 'c2'); deps.db.seed('websiteProjects', { customerId: 'c1', liveDomain: 'mueller-backt.de' }, 'p1')
|
||
}
|
||
const run = async (deps, argv = []) => { const out = []; const files = { '/b.json': backup, '/m.json': master }
|
||
const r = await main({ argv: ['--backup', '/b.json', '--master', '/m.json', ...argv], deps, readJson: async (p) => files[p], log: (l) => out.push(l) }); return { r, text: out.join('\n') } }
|
||
test('Trockenlauf schreibt nichts; echter Lauf legt an; zweiter Lauf legt nichts mehr an; Ausgabe ohne Adressen', async () => {
|
||
const deps = makeDeps(); prepareCustomers(deps)
|
||
const dry = await run(deps, ['--dry-run']); assert.equal(deps.db.all('workersKeys').length, 0); assert.match(dry.text, /TROCKENLAUF/); assert.match(dry.text, /^Alte Leads: 3 \(blockiert: 1\)$/m)
|
||
const real = await run(deps); const n = deps.db.all('workersKeys').length; assert.ok(n > 0); assert.equal(real.r.created, n); assert.equal(dry.r.created, n)
|
||
const again = await run(deps); assert.equal(again.r.created, 0); assert.equal(deps.db.all('workersKeys').length, n)
|
||
assert.ok(![dry.text, real.text, again.text].join('\n').includes('@'))
|
||
})
|
||
test('Kunden zuerst: gemeinsamer Schlüssel von Kunde und Alt-Lead bleibt gesperrt mit Origin seed_kunde', async () => {
|
||
const deps = makeDeps(); deps.db.seed('customers', { name: 'Drei Lack GmbH', email: 'kontakt@drei-lack.de', customerStatus: 'customer' }, 'c9'); await run(deps)
|
||
const d = await deps.db.get('workersKeys', keyId('mail', 'kontakt@drei-lack.de')); assert.deepEqual([d.blocked, d.origin], [true, 'seed_kunde'])
|
||
})
|
||
test('Kunden werden nur mit select-Whitelist geladen (portalPassword nie abgefragt) und nie beschrieben', async () => {
|
||
const deps = makeDeps(); prepareCustomers(deps); await run(deps)
|
||
const sel = deps.db.calls.find((c) => c.col === 'customers' && c.queries).queries.map((s) => JSON.parse(s)).find((q) => q.method === 'select')
|
||
assert.ok(sel.values.includes('email') && sel.values.includes('customerStatus')); assert.ok(!sel.values.includes('portalPassword'))
|
||
assert.ok(deps.db.calls.every((c) => !GESPERRTE_SAMMLUNGEN.includes(c.col) || ['get', 'list', 'listAll', 'count'].includes(c.op))) // Übersicht §2.13: nur lesen
|
||
})
|
||
test('fehlende Datei → Fehler mit Pfad, nichts geschrieben', async () => {
|
||
const deps = makeDeps(); const readJson = async (p) => { throw Object.assign(new Error('ENOENT'), { path: p }) }
|
||
await assert.rejects(main({ argv: ['--backup', '/weg.json', '--master', '/m.json'], deps, readJson, log: () => {} }), /\/weg\.json/); assert.equal(deps.db.all('workersKeys').length, 0)
|
||
})
|
||
```
|
||
- [ ] **Step 2: Fehlschlag sehen.** `cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/seedRunner.test.js` → FAIL `ERR_MODULE_NOT_FOUND` (`scripts/seed-register.mjs`).
|
||
- [ ] **Step 3: Implementieren.** `main`: Argumente parsen; Dateien per `readJson` (CLI: `JSON.parse(readFile)`); Kunden: `listAll('customers', [Query.select(['name','companyName','contactName','email','phone','postalCode','customerStatus','archived'])])`, Projekte: `listAll('websiteProjects', [Query.select(['customerId','liveDomain'])])`; Reihenfolge **Kunden zuerst**, dann Alt-Leads; je Eintrag `claimKeys(deps, keys, { prospectId:'', origin, blocked })`. Trockenlauf: keine Schreibzugriffe, stattdessen je Schlüssel `get('workersKeys', keyId(…))` plus ein `Set` der in diesem Lauf schon gesehenen Schlüssel (zählt als „vorhanden“). `created` = Schlüssel − Treffer − Hinweise (nur neu angelegte). Ausgabe nur Zähler (Format siehe Interfaces); nicht lesbare Datei ⇒ `Error('Datei nicht lesbar: <pfad>')`, bevor irgendetwas geschrieben wird. CLI-Rahmen (nur bei direktem Start): Konfiguration `APPWRITE_ENDPOINT` (Standard `https://appwrite.webklar.com/v1`), `APPWRITE_PROJECT_ID` (Standard `6a1058610003c5a13a05`), `APPWRITE_API_KEY` (fehlt ⇒ Name nennen, Exit 2), `deps = { db: createDb(…), clock: { now: () => Date.now() }, log: () => {} }`, Exit 1 bei Fehler.
|
||
- [ ] **Step 4: Test laufen lassen.** Gleicher Befehl → PASS (4 Tests).
|
||
- [ ] **Step 5: Commit.** `git add scripts/seed-register.mjs test/seedRunner.test.js && git commit -m "P1-T23: Seed-Skript für den Startbestand" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"`
|
||
|
||
---
|
||
|
||
### P1-T24: HTTP-API – Kern (Auth, `/health`, `/pause`, `/settings`)
|
||
|
||
**Files:**
|
||
- Create: `src/api.js`, `test/support/apiBoot.js`
|
||
- Test: `test/apiAuth.test.js`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `getSettings`/`updateSettings`/`publicSettings` (T07), `emit` (T06), `HttpError` (T07).
|
||
- Produces: `createApi({ deps, appwrite:{endpoint,projectId}, version, fetchImpl = fetch, authTtlMs = 60000 }) → http.Server`; Basis `/api/workers`; Routen laut Übersicht §6.2: `GET /health` (offen), `POST /pause`, `GET|PUT /settings` (Admin); Fehler `{ error }` (E9). `boot({ labels = ['mitarbeiter'], status = 200, fetchThrows = false, deps = makeDeps() })` (Testhelfer in `test/support/apiBoot.js`, Version `0.1.0`) → `{ req(path, { method = 'GET', jwt = 'a.b.c', body }), calls, deps, close() }` mit Fake-`/account` (`{ $id:'u1', name:'Kenso', labels }`).
|
||
|
||
- [ ] **Step 1: Tests schreiben** (`apiBoot.js`: Server auf Port 0, `fetchImpl` liefert `{ $id:'u1', name:'Kenso', labels }` bzw. Status/Fehler).
|
||
```js
|
||
test('/health ohne JWT', async () => { const t = await boot(); const r = await t.req('/health', { jwt: null }); assert.deepEqual([r.status, await r.json()], [200, { ok: true, version: '0.1.0' }]); await t.close() })
|
||
test('ohne oder mit unlesbarem JWT → 401, Appwrite wird nicht gefragt', async () => {
|
||
const t = await boot(); let r = await t.req('/settings', { jwt: null }); assert.deepEqual([r.status, (await r.json()).error], [401, 'Nicht angemeldet.'])
|
||
r = await t.req('/pause', { method: 'POST', jwt: 'abc', body: { paused: true } }); assert.equal(r.status, 401); assert.equal(t.calls.length, 0); await t.close()
|
||
})
|
||
test('Portalkunde (Label fehlt oder customer) bekommt 403 ohne Daten', async () => {
|
||
for (const labels of [[], ['customer']]) { const t = await boot({ labels })
|
||
for (const [p, m] of [['/settings', 'GET'], ['/pause', 'POST'], ['/settings', 'PUT']]) { const r = await t.req(p, { method: m, body: m === 'GET' ? undefined : { paused: true } }); assert.equal(r.status, 403); assert.deepEqual(await r.json(), { error: 'Kein Zugriff.' }) }
|
||
assert.equal(t.deps.db.all('workersSettings').length, 0); await t.close() }
|
||
})
|
||
test('Mitarbeiter: /pause ja, /settings nur für Admin', async () => {
|
||
const t = await boot({ labels: ['mitarbeiter'] }); assert.equal((await t.req('/pause', { method: 'POST', body: { paused: true } })).status, 200)
|
||
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 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()
|
||
})
|
||
test('Appwrite: 401 → "Sitzung abgelaufen.", nicht erreichbar → 502; Cache: gleiches JWT = ein /account-Aufruf', async () => {
|
||
let t = await boot({ status: 401 }); let r = await t.req('/settings'); assert.deepEqual([r.status, (await r.json()).error], [401, 'Sitzung abgelaufen.']); await t.close()
|
||
t = await boot({ fetchThrows: true }); r = await t.req('/settings'); assert.deepEqual([r.status, (await r.json()).error], [502, 'Anmeldedienst nicht erreichbar.']); await t.close()
|
||
t = await boot({ labels: ['admin'] }); await t.req('/settings'); await t.req('/settings'); assert.equal(t.calls.length, 1); await t.req('/settings', { jwt: 'x.y.z' }); assert.equal(t.calls.length, 2); await t.close()
|
||
})
|
||
test('/pause: setzt, Ereignis nur bei Änderung; ungültiger Body 400, Riesen-Body 413; unbekannter Pfad 404, falsche Methode 405', async () => {
|
||
const t = await boot({ labels: ['mitarbeiter'] }); await t.req('/pause', { method: 'POST', body: { paused: true } }); await t.req('/pause', { method: 'POST', body: { paused: true } })
|
||
const ev = t.deps.db.all('workersEvents'); assert.equal(ev.length, 1); assert.deepEqual([ev[0].workerId, ev[0].kind], ['system', 'pause']); assert.match(ev[0].text, /Kenso/); assert.equal(JSON.parse(ev[0].metaJson).paused, true)
|
||
assert.equal((await t.req('/pause', { method: 'POST', body: { paused: 'ja' } })).status, 400); assert.equal((await t.req('/pause', { method: 'POST', body: { x: 'y'.repeat(20000) } })).status, 413)
|
||
assert.equal((await t.req('/gibtsnicht')).status, 404); assert.equal((await t.req('/pause', { method: 'DELETE' })).status, 405); await t.close()
|
||
})
|
||
```
|
||
- [ ] **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 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>"`
|
||
|
||
---
|
||
|
||
### P1-T25: API-Sichten `/state`, `/events`, `/worker/:id/trace`
|
||
|
||
**Files:**
|
||
- Create: `src/view.js`
|
||
- Modify: `src/api.js`
|
||
- Test: `test/view.test.js` (nutzt `boot` aus `test/support/apiBoot.js`, T24)
|
||
|
||
**Interfaces:**
|
||
- Consumes: `getSettings`, `getDaily`/`monthDocs`, `ROLES`/`MODELS`, `Query`, `NotFoundError`/`ValidationError`.
|
||
- Produces (Übersicht §6.2, E9): `buildState(deps) → { paused, workers:[{ workerId, status:'arbeitet'|'wartet'|'leerlauf'|'grau'|'fehler', task, counters, connected }], kpis:{ newAbosMonth, mrrNewEur, dealsMonth, openThreads, pultOpen, botLeads, apiTodayUsd, apiMonthUsd, dailyBudgetUsd, monthlyBudgetUsd }, lastEventTs }`; `buildEvents(deps, { after?, limit? }) → { events:[{ id, ts, workerId, kind, text, link, to, prospectId, costUsd, meta }] }` (aufsteigend; Limit 1–200, Standard 100); `buildTrace(deps, workerId, { limit? }) → { worker:{ …ROLES-Eintrag, modelId }, today:{ counters, tokens:{in,out}, costUsd }, events }` (neueste zuerst, Limit 1–50, Standard 20). Routen `GET /state`, `GET /events?after=&limit=`, `GET /worker/:workerId/trace?limit=` für Team.
|
||
|
||
- [ ] **Step 1: Tests schreiben** (`test/view.test.js`; Welt per `deps.db.seed(...)`):
|
||
```js
|
||
async function world(deps) {
|
||
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')
|
||
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' })
|
||
deps.db.seed('workersOffers', { status: 'bezahlt', productKey: 'hosting', paidAt: '2026-10-10T10:00:00.000Z' }); deps.db.seed('workersOffers', { status: 'offen' })
|
||
}
|
||
test('buildState: Rangfolge grau > fehler > arbeitet > wartet > leerlauf; Zähler ohne Token-/Kostenfelder; Kennzahlen', async () => {
|
||
const deps = makeDeps(); await world(deps); const s = await buildState(deps); const w = Object.fromEntries(s.workers.map((x) => [x.workerId, x]))
|
||
assert.equal(s.workers.length, 19); assert.equal(s.paused, false)
|
||
assert.deepEqual([w.rita.status, w.rita.connected, w.rita.counters], ['arbeitet', true, { gelesen: 5 }])
|
||
assert.deepEqual([w.ali.status, w.ali.task], ['fehler', 'Ali: Quelle nicht erreichbar.']); assert.equal(w.sven.status, 'grau'); assert.equal(w.sven.task, 'noch nicht angeschlossen')
|
||
assert.equal(w.finn.status, 'leerlauf'); assert.deepEqual([w.otto.status, w.otto.task, w.otto.connected], ['wartet', 'Wartet auf Start: Bericht', true])
|
||
assert.deepEqual(s.kpis, { newAbosMonth: 1, mrrNewEur: 20, dealsMonth: 3, openThreads: 1, pultOpen: 2, botLeads: 3, apiTodayUsd: 2.5, apiMonthUsd: 8.9, dailyBudgetUsd: 10, monthlyBudgetUsd: 250 })
|
||
assert.equal(s.lastEventTs, '2026-10-19T08:55:00.000Z')
|
||
})
|
||
test('buildState auf leerer DB: nichts angeschlossen = alles grau außer Otto, Kennzahlen 0, lastEventTs null', async () => {
|
||
const s = await buildState(makeDeps()); assert.equal(s.workers.filter((w) => w.status === 'grau').length, 18); 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)
|
||
})
|
||
test('buildEvents: aufsteigend ab after (strikt größer), Limit geklemmt, ohne after die letzten N aufsteigend, meta geparst; ungültiges Datum → ValidationError', async () => {
|
||
const deps = makeDeps(); for (let i = 1; i <= 5; i++) deps.db.seed('workersEvents', { ts: `2026-10-19T08:00:0${i}.000Z`, workerId: 'otto', kind: 'leerlauf', text: `e${i}`, metaJson: `{"n":${i}}` }, `e${i}`)
|
||
assert.deepEqual((await buildEvents(deps, { after: '2026-10-19T08:00:02.000Z' })).events.map((e) => e.id), ['e3', 'e4', 'e5'])
|
||
assert.deepEqual((await buildEvents(deps, { after: '2026-10-19T08:00:02.000+00:00' })).events.map((e) => e.id), ['e3', 'e4', 'e5']) // Offset-Schreibweise gleichwertig
|
||
const last2 = (await buildEvents(deps, { limit: 2 })).events; assert.deepEqual(last2.map((e) => e.id), ['e4', 'e5']); assert.deepEqual(last2[1].meta, { n: 5 })
|
||
assert.equal((await buildEvents(deps, { limit: 999 })).events.length, 5); await assert.rejects(buildEvents(deps, { after: 'gestern' }), ValidationError)
|
||
})
|
||
test('Zeitstempel werden immer als ISO mit Z ausgegeben (Appwrite liefert "+00:00")', async () => {
|
||
const deps = makeDeps(); deps.db.seed('workersEvents', { ts: '2026-10-19T08:00:06.000+00:00', workerId: 'otto', kind: 'leerlauf', text: 'x' })
|
||
assert.equal((await buildEvents(deps, {})).events[0].ts, '2026-10-19T08:00:06.000Z'); assert.equal((await buildState(deps)).lastEventTs, '2026-10-19T08:00:06.000Z')
|
||
})
|
||
test('buildTrace: unbekannter Worker → NotFoundError; Worker-Block mit modelId; today; neueste zuerst', async () => {
|
||
const deps = makeDeps(); await world(deps)
|
||
deps.db.seed('workersEvents', { ts: '2026-10-19T08:01:00.000Z', workerId: 'rita', kind: 'gelesen', text: 'alt' }); deps.db.seed('workersEvents', { ts: '2026-10-19T08:02:00.000Z', workerId: 'rita', kind: 'fund', text: 'neu' })
|
||
const t = await buildTrace(deps, 'rita', {})
|
||
assert.deepEqual([t.worker.workerId, t.worker.model, t.worker.modelId], ['rita', 'haiku', 'claude-haiku-5-5']); assert.deepEqual(t.today, { counters: { gelesen: 5 }, tokens: { in: 100, out: 50 }, costUsd: 0.1 }); assert.deepEqual(t.events.map((e) => e.text), ['neu', 'alt'])
|
||
await assert.rejects(buildTrace(deps, 'niemand', {}), NotFoundError)
|
||
})
|
||
test('Routen: Team 200, Portalkunde 403, unbekannter Worker 404, ungültiges after 400', async () => {
|
||
const t = await boot({ labels: ['mitarbeiter'] }); await world(t.deps)
|
||
for (const p of ['/state', '/events?limit=999', '/worker/rita/trace?limit=999']) assert.equal((await t.req(p)).status, 200)
|
||
assert.equal((await (await t.req('/state')).json()).workers.length, 19); assert.equal((await t.req('/worker/niemand/trace')).status, 404); assert.equal((await t.req('/events?after=gestern')).status, 400); await t.close()
|
||
const k = await boot({ labels: ['customer'] }); assert.equal((await k.req('/state')).status, 403); await k.close()
|
||
})
|
||
```
|
||
- [ ] **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; 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` = `room === 'bruecke' || connectedRooms.includes(room)`; 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: `newAbosMonth`/`mrrNewEur` aus `workersOffers` (Abfrage nur über den Index `status = 'bezahlt'`, `limit 500`; `paidAt` ≥ Monatsanfang Berlin wird im Code gefiltert; Preise `{ hosting: 20, wartung: 50 }`), `dealsMonth` = Summe `deals` aus `monthDocs`, `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 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>"`
|
||
|
||
---
|
||
|
||
### P1-T26: Tick, `main.js`, Abnahme-Skript `jetzt.mjs`
|
||
|
||
**Files:**
|
||
- Create: `src/tick.js`, `src/main.js`, `scripts/jetzt.mjs`
|
||
- Test: `test/tick.test.js`, `test/main.test.js`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `isDue`/`runKey`/`berlinNow` (T08), `markRun`/`lastRun` (T09), `getSettings`, `emit`, `reclaimStale` (T15), `planNight`, `sendReport`, `createApi`, `createDb`, `createClaude`, `createNotifier`, `loadConfig`.
|
||
- Produces: `createTick({ deps, jobs }) → { tick() }` mit `jobs: [{ kind, workerId, pauseSafe, enabled?(settings), run(deps, { now, settings }) }]` (spätere Pläne hängen weitere Einträge an); `DEFAULT_JOBS` = `nachtplan` (planNight run 1), `nacht2` (run 2, nur `runsPerNight ≥ 2`), `bericht` (pauseSafe), `aufraeumen` (Intervall 5 min, pauseSafe: `reclaimStale` 30 min); `buildDeps(config, over = {}) → { db, clock, log, claude, notify }`; `start(env = process.env, over = {}) → Promise<{ server, stop(), tick }>` (`over`: `config`, `deps`, `port`); `runJetzt(art, deps)` (Export aus `scripts/jetzt.mjs`, Arten `bericht`, `nachtplan`, `nachtplan2`; sonst `Error('Unbekannte Art: …')`). Imports in den Tests: `createTick, DEFAULT_JOBS` aus `src/tick.js`, `start` aus `src/main.js`, `runJetzt` aus `scripts/jetzt.mjs`, `pkg` = geparste `package.json`.
|
||
|
||
- [ ] **Step 1: Tests schreiben.**
|
||
```js
|
||
const mkJobs = (log) => [
|
||
{ kind: 'nachtplan', workerId: 'otto', pauseSafe: false, run: async () => log.push('nachtplan') },
|
||
{ kind: 'nacht2', workerId: 'otto', pauseSafe: false, enabled: (s) => s.runsPerNight >= 2, run: async () => log.push('nacht2') },
|
||
{ kind: 'bericht', workerId: 'otto', pauseSafe: true, run: async () => log.push('bericht') },
|
||
{ kind: 'posteingang', workerId: 'nora', pauseSafe: true, run: async () => log.push('posteingang') }]
|
||
const count = (log, k) => log.filter((x) => x === k).length
|
||
test('Nachtplan läuft pro Nacht genau einmal – auch nach Neustart mitten in der Nacht', async () => {
|
||
const log = []; const deps = makeDeps({ now: '2026-10-19T19:59:00Z' }); const t1 = createTick({ deps, jobs: mkJobs(log) }) // 21:59 Berlin
|
||
await t1.tick(); assert.equal(count(log, 'nachtplan'), 0)
|
||
deps.clock.set('2026-10-19T20:00:00Z'); await t1.tick(); deps.clock.advance(60000); await t1.tick(); assert.equal(count(log, 'nachtplan'), 1)
|
||
const t2 = createTick({ deps, jobs: mkJobs(log) }); deps.clock.set('2026-10-19T21:30:00Z'); await t2.tick(); assert.equal(count(log, 'nachtplan'), 1) // Neustart 23:30
|
||
deps.clock.set('2026-10-20T20:00:00Z'); await t2.tick(); assert.equal(count(log, 'nachtplan'), 2)
|
||
})
|
||
test('Pause: nur pauseSafe-Arten laufen; Bericht ab 06:30 einmal pro Tag; nacht2 nur bei runsPerNight 2', async () => {
|
||
const log = []; const deps = makeDeps({ now: '2026-10-20T04:29:00Z' }); await updateSettings(deps, { paused: true }, 't'); const t = createTick({ deps, jobs: mkJobs(log) })
|
||
await t.tick(); assert.equal(count(log, 'bericht'), 0); deps.clock.set('2026-10-20T04:30:00Z'); await t.tick(); await t.tick(); assert.equal(count(log, 'bericht'), 1); assert.equal(count(log, 'nachtplan'), 0)
|
||
await updateSettings(deps, { paused: false }, 't'); deps.clock.set('2026-10-20T23:30:00Z'); await t.tick(); assert.equal(count(log, 'nacht2'), 0) // 01:30, runsPerNight 1
|
||
await updateSettings(deps, { runsPerNight: 2 }, 't'); await t.tick(); assert.equal(count(log, 'nacht2'), 1)
|
||
})
|
||
test('Intervallart läuft sofort und danach erst nach dem Intervall (2 min)', async () => {
|
||
const log = []; const deps = makeDeps({ now: '2026-10-19T08:00:00Z' }); const t = createTick({ deps, jobs: mkJobs(log) })
|
||
await t.tick(); deps.clock.advance(60000); await t.tick(); assert.equal(count(log, 'posteingang'), 1); deps.clock.advance(60000); await t.tick(); assert.equal(count(log, 'posteingang'), 2)
|
||
})
|
||
test('überlappende Ticks laufen nicht parallel', async () => {
|
||
let running = 0; let max = 0; const jobs = [{ kind: 'posteingang', workerId: 'nora', pauseSafe: true, run: async () => { running++; max = Math.max(max, running); await new Promise((r) => setTimeout(r, 20)); running-- } }]
|
||
const t = createTick({ deps: makeDeps(), jobs }); await Promise.all([t.tick(), t.tick()]); assert.equal(max, 1)
|
||
})
|
||
test('Fehler eines Jobs: andere laufen weiter, fehler-Ereignis mit Text, kein Absturz', async () => {
|
||
const log = []; const deps = makeDeps(); const jobs = [{ kind: 'bericht', workerId: 'otto', pauseSafe: true, run: async () => { throw new Error('Postfach kaputt') } }, mkJobs(log)[3]]
|
||
await createTick({ deps, jobs }).tick(); assert.equal(count(log, 'posteingang'), 1)
|
||
const e = deps.db.all('workersEvents'); assert.deepEqual([e[0].kind, e[0].workerId, e[0].text], ['fehler', 'otto', 'Fehler bei bericht: Postfach kaputt'])
|
||
})
|
||
test('DEFAULT_JOBS: nachtplan, nacht2, bericht, aufraeumen mit den erwarteten pauseSafe-Werten', () => assert.deepEqual(DEFAULT_JOBS.map((j) => [j.kind, j.pauseSafe]), [['nachtplan', false], ['nacht2', false], ['bericht', true], ['aufraeumen', true]]))
|
||
```
|
||
`main.test.js`:
|
||
```js
|
||
test('start: /health antwortet, Tick lief, stop() beendet sauber; ohne Pflicht-Env ConfigError', async () => {
|
||
const deps = makeDeps(); const app = await start({}, { config: { appwrite: { endpoint: 'http://aw/v1', projectId: 'p', apiKey: 'k' }, api: { port: 0 } }, deps, port: 0 })
|
||
const r = await fetch(`http://127.0.0.1:${app.server.address().port}/api/workers/health`); assert.deepEqual(await r.json(), { ok: true, version: pkg.version }); await app.stop()
|
||
await assert.rejects(start({}), (e) => e.missing.includes('APPWRITE_API_KEY'))
|
||
})
|
||
test('jetzt: bericht ruft notify, nachtplan plant (hier: leerlauf), unbekannte Art wirft', async () => {
|
||
const sent = []; const deps = makeDeps({ notify: async (m) => { sent.push(m); return { sent: true } } })
|
||
await runJetzt('bericht', deps); assert.equal(sent.length, 1); assert.deepEqual(await runJetzt('nachtplan', deps), { planned: [], skipped: 'keine_raeume' }); await assert.rejects(runJetzt('quatsch', deps), /Unbekannte Art/)
|
||
})
|
||
```
|
||
- [ ] **Step 2: Fehlschlag sehen.** `cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/tick.test.js test/main.test.js` → FAIL `ERR_MODULE_NOT_FOUND` (`src/tick.js`).
|
||
- [ ] **Step 3: Implementieren.** `tick()`: Guard gegen Überlappung; `now = berlinNow(clock)`; Einstellungen laden (Fehler ⇒ loggen, Abbruch); je Job in Tabellenreihenfolge: übersprungen bei `paused && !pauseSafe` oder `enabled(settings) === false`; `key = runKey(kind, now)`; `last` = `key ? await lastRun(deps, kind, key) : intervalLast.get(kind) ?? null`; nicht fällig ⇒ weiter; Marker **vor** dem Lauf setzen (`markRun` bzw. `intervalLast.set(kind, now.ts)`), damit ein fehlschlagender Lauf nicht jede Minute wiederholt wird; `run` in `try/catch`: Log + `emit({ workerId: job.workerId, kind:'fehler', text:'Fehler bei <kind>: <Message>' })` (selbst abgesichert, Text ohne Secrets/Stack). `DEFAULT_JOBS` (Tick-Tabelle, exportiert aus `tick.js`) wörtlich wie unter „Produces“; `workerId` `otto` (bei `aufraeumen`: `system`). `buildDeps`: `db = createDb(config.appwrite)`, `clock = { now: () => Date.now() }`, `log` = `console.log` mit ISO-Zeitstempel, `claude = createClaude({ apiKey: config.anthropicKey, searchModel: config.searchModel })`, `notify = createNotifier({ smtp: config.reportSmtp, getTo: async () => (await getSettings(base)).reportEmail, clock, log }).notify`; `over` überschreibt jedes dieser Teile. `start`: `loadConfig` (oder `over.config`), `createApi({ deps, appwrite: config.appwrite, version: package.json-Version })`, `server.listen(over.port ?? config.api.port)`, sofort ein Tick, danach `setInterval(tick, 60000).unref()`, `unhandledRejection` loggen statt abstürzen, `stop()` = Intervall löschen, `server.close`, nach höchstens 5 s Ausstieg; Signale `SIGTERM`/`SIGINT` rufen `stop` und beenden den Prozess (nur wenn direkt gestartet). Logzeile `workers listening on <port>`. `scripts/jetzt.mjs <bericht|nachtplan|nachtplan2>`: `loadConfig(process.env)` → `buildDeps` → `runJetzt` → druckt das Ergebnis als JSON (ohne Secrets); nur für die Abnahme, solange `connectedRooms` leer ist.
|
||
- [ ] **Step 4: Tests laufen lassen.** Gleicher Befehl → PASS (6 + 2 Tests).
|
||
- [ ] **Step 5: Commit.** `git add src/tick.js src/main.js scripts/jetzt.mjs test/tick.test.js test/main.test.js && git commit -m "P1-T26: Tick, Dienststart und Abnahme-Skript" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"`
|
||
|
||
---
|
||
|
||
### P1-T27: Container- und Betriebsbausteine
|
||
|
||
**Files:**
|
||
- Create: `Dockerfile`, `.dockerignore`, `deploy/compose-workers.yml`, `deploy/nginx-workers.conf`, `deploy/serverpuls-apps-eintrag.json`
|
||
- Test: `test/deploy.test.js`
|
||
|
||
**Interfaces:**
|
||
- Produces: Text-Bausteine, die Kenso nach der Betriebsanleitung einfügt (Compose-Dienst `workers`, nginx-`location`, Serverpuls-Gruppe); Werte exakt Übersicht §9.
|
||
|
||
- [ ] **Step 1: Test schreiben.**
|
||
```js
|
||
const read = (p) => readFileSync(new URL(`../${p}`, import.meta.url), 'utf8')
|
||
test('Compose-Block: gehärtet nach Übersicht §9, nicht öffentlich, ohne Geheimnisse', () => {
|
||
const c = read('deploy/compose-workers.yml')
|
||
for (const l of ['container_name: webklar-workers', 'image: webklar-workers:latest', 'build: ./workers', 'restart: unless-stopped', 'init: true', 'mem_limit: 768m', 'memswap_limit: 768m', 'cpus: 1.0', 'user: node', 'read_only: true', '- /tmp', '- ALL', 'no-new-privileges:true',
|
||
'max-size: "5m"', 'max-file: "2"', '- TZ=Europe/Berlin', '- NODE_OPTIONS=--max-old-space-size=384', '- APPWRITE_ENDPOINT=http://webklar-appwrite/v1', '- APPWRITE_PROJECT_ID=6a1058610003c5a13a05',
|
||
'- APPWRITE_API_KEY=${WORKERS_APPWRITE_API_KEY:-}', '- ANTHROPIC_API_KEY=${WORKERS_ANTHROPIC_API_KEY:-}', '- WORKERS_REPORT_SMTP_PASS=${WORKERS_REPORT_SMTP_PASS:-}', '- WORKERS_UNSUB_BASE=https://entwurf.project.webklar.com/abmelden', 'workers_data:/data', '- webklar-network']) assert.ok(c.includes(l), l)
|
||
assert.ok(!/^\s*ports:/m.test(c)); assert.ok(!/traefik/i.test(c)); assert.ok(!/sk-ant|=\s*[A-Za-z0-9]{30,}/.test(c))
|
||
})
|
||
test('nginx-Block: /api/workers/ → webklar-workers:8092 mit Resolver (wie /api/serverpuls/)', () => {
|
||
const n = read('deploy/nginx-workers.conf'); for (const l of ['location /api/workers/ {', 'resolver 127.0.0.11 valid=30s ipv6=off;', 'set $workers_upstream webklar-workers;', 'proxy_pass http://$workers_upstream:8092;', 'client_max_body_size 64k;']) assert.ok(n.includes(l), l)
|
||
})
|
||
test('Dockerfile: node:22-alpine, npm ci --omit=dev, läuft als node, kein Chromium (kommt mit P4)', () => {
|
||
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, '')))
|
||
})
|
||
test('Serverpuls-Eintrag', () => assert.deepEqual(JSON.parse(read('deploy/serverpuls-apps-eintrag.json')), { app: 'Workers', match: ['webklar-workers*'], folders: ['workers'] }))
|
||
```
|
||
- [ ] **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):
|
||
`Dockerfile`: `FROM node:22-alpine` · `ENV NODE_ENV=production` · `WORKDIR /app` · `COPY package.json package-lock.json ./` · `RUN npm ci --omit=dev && npm cache clean --force` · `COPY src ./src` · `COPY scripts ./scripts` · `RUN mkdir -p /data && chown node:node /data` · Kommentar `# Chromium (apk add chromium) kommt erst mit P4 (Paul)` · `USER node` · `EXPOSE 8092` · `CMD ["node", "src/main.js"]`. `.dockerignore`: `node_modules`, `test`, `.git`, `.env`, `deploy`, `docs`.
|
||
`deploy/compose-workers.yml` (Einrückung wie unter `services:` in `/home/webklar/apps/docker-compose.yml`, 2 Leerzeichen):
|
||
```yaml
|
||
workers:
|
||
build: ./workers
|
||
image: webklar-workers:latest
|
||
container_name: webklar-workers
|
||
restart: unless-stopped
|
||
init: true
|
||
mem_limit: 768m
|
||
memswap_limit: 768m
|
||
cpus: 1.0
|
||
user: node
|
||
read_only: true
|
||
tmpfs:
|
||
- /tmp
|
||
cap_drop:
|
||
- ALL
|
||
security_opt:
|
||
- no-new-privileges:true
|
||
logging:
|
||
driver: json-file
|
||
options:
|
||
max-size: "5m"
|
||
max-file: "2"
|
||
environment:
|
||
- TZ=Europe/Berlin
|
||
- NODE_OPTIONS=--max-old-space-size=384
|
||
- APPWRITE_ENDPOINT=http://webklar-appwrite/v1
|
||
- APPWRITE_PROJECT_ID=6a1058610003c5a13a05
|
||
- APPWRITE_API_KEY=${WORKERS_APPWRITE_API_KEY:-}
|
||
- ANTHROPIC_API_KEY=${WORKERS_ANTHROPIC_API_KEY:-}
|
||
- WORKERS_SEARCH_MODEL=${WORKERS_SEARCH_MODEL:-}
|
||
- WORKERS_REPORT_SMTP_HOST=${WORKERS_REPORT_SMTP_HOST:-}
|
||
- WORKERS_REPORT_SMTP_PORT=${WORKERS_REPORT_SMTP_PORT:-}
|
||
- WORKERS_REPORT_SMTP_USER=${WORKERS_REPORT_SMTP_USER:-}
|
||
- WORKERS_REPORT_SMTP_PASS=${WORKERS_REPORT_SMTP_PASS:-}
|
||
- WORKERS_REPORT_SMTP_FROM=${WORKERS_REPORT_SMTP_FROM:-}
|
||
# ab P3/P4/P6 genutzt, hier leer vorbereitet (ein Eingriff in die Compose-Datei genügt)
|
||
- WORKERS_SMTP_HOST=${WORKERS_SMTP_HOST:-}
|
||
- WORKERS_SMTP_PORT=${WORKERS_SMTP_PORT:-}
|
||
- WORKERS_SMTP_USER=${WORKERS_SMTP_USER:-}
|
||
- WORKERS_SMTP_PASS=${WORKERS_SMTP_PASS:-}
|
||
- WORKERS_SMTP_FROM=${WORKERS_SMTP_FROM:-}
|
||
- WORKERS_IMAP_HOST=${WORKERS_IMAP_HOST:-}
|
||
- WORKERS_IMAP_PORT=${WORKERS_IMAP_PORT:-}
|
||
- WORKERS_IMAP_USER=${WORKERS_IMAP_USER:-}
|
||
- WORKERS_IMAP_PASS=${WORKERS_IMAP_PASS:-}
|
||
- WORKERS_ALERT_IMAP_HOST=${WORKERS_ALERT_IMAP_HOST:-}
|
||
- WORKERS_ALERT_IMAP_PORT=${WORKERS_ALERT_IMAP_PORT:-}
|
||
- WORKERS_ALERT_IMAP_USER=${WORKERS_ALERT_IMAP_USER:-}
|
||
- WORKERS_ALERT_IMAP_PASS=${WORKERS_ALERT_IMAP_PASS:-}
|
||
- WORKERS_PLACES_API_KEY=${WORKERS_PLACES_API_KEY:-}
|
||
- WORKERS_UNSUB_BASE=https://entwurf.project.webklar.com/abmelden
|
||
- WORKERS_UNSUB_SECRET=${WORKERS_UNSUB_SECRET:-}
|
||
volumes:
|
||
- workers_data:/data
|
||
networks:
|
||
- webklar-network
|
||
```
|
||
dazu im Kommentar der Datei die Zeile `# Unter "volumes:" ergänzen: workers_data:`. `deploy/nginx-workers.conf`:
|
||
```nginx
|
||
# Workers (Kundenfindung, nur Mitarbeiter) - Same-Origin Proxy
|
||
location /api/workers/ {
|
||
resolver 127.0.0.11 valid=30s ipv6=off;
|
||
set $workers_upstream webklar-workers;
|
||
proxy_pass http://$workers_upstream:8092;
|
||
proxy_set_header Host $host;
|
||
proxy_set_header X-Real-IP $remote_addr;
|
||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||
proxy_set_header X-Forwarded-Proto $scheme;
|
||
proxy_read_timeout 60s;
|
||
client_max_body_size 64k;
|
||
}
|
||
```
|
||
`deploy/serverpuls-apps-eintrag.json`: `{ "app": "Workers", "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>"`
|
||
|
||
---
|
||
|
||
## Betriebsanleitung (Kenso)
|
||
|
||
Reihenfolge einhalten. **[Claude]** = Schritt, den der ausführende Agent mit dem Gitea-MCP erledigt; **[Kenso]** = deine Eingabe auf dem Server. Geheimnisse nie in den Chat und nie in Befehle schreiben, die sie ausgeben; `ANTHROPIC_API_KEY` nie in der Shell von root exportieren. Alle Skripte laufen auf dem Host aus dem Dev-Checkout (Node 24, `node_modules` aus T01) und bekommen den Schlüssel nur für diesen einen Befehl (Subshell).
|
||
|
||
1. **[Claude] Repo anlegen.** Gitea-MCP `create_repo` mit `name: "workers-dienst"`, `private: true`, `auto_init: false`, `default_branch: "main"`, **ohne** Organisation (Besitzer `knso`, nicht Org WEBklar: deren Webhook würde jeden Push als Kundenprojekt in WOMS anlegen). *Prüfen:* `list_my_repos` zeigt `knso/workers-dienst` als privat.
|
||
2. **[Kenso] Sicherungen.**
|
||
```
|
||
cd /home/webklar/apps
|
||
cp .env .env.bak-$(date +%F)-workers
|
||
cp docker-compose.yml docker-compose.yml.bak-$(date +%F)-workers
|
||
cp ticket.webklar.com/nginx/ticket.webklar.com.conf ticket.webklar.com/nginx/ticket.webklar.com.conf.bak-$(date +%F)-workers
|
||
```
|
||
*Prüfen:* `ls -l .env.bak-*-workers docker-compose.yml.bak-*-workers ticket.webklar.com/nginx/*.bak-*-workers` zeigt drei Dateien von heute.
|
||
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`.
|
||
5. **[Kenso] Berichtskonto** (intern, kein Kaltkontakt; Passwort wird aus Serverpuls übernommen, ohne es zu zeigen):
|
||
```
|
||
cd /home/webklar/apps
|
||
printf 'WORKERS_REPORT_SMTP_HOST=mail.your-server.de\nWORKERS_REPORT_SMTP_PORT=587\nWORKERS_REPORT_SMTP_USER=server@webklar.com\nWORKERS_REPORT_SMTP_FROM=server@webklar.com\nWORKERS_REPORT_SMTP_PASS=%s\n' "$(sed -n 's/^SERVERPULS_SMTP_PASS=//p' .env | tail -n1)" >> .env
|
||
```
|
||
*Prüfen:* `command grep -c '^WORKERS_REPORT_SMTP_' .env` → `5`. Der Bericht geht an `kenso.gri@gmail.com` (Standard von `reportEmail`); anderer Empfänger später per `PUT /settings` oder im WOMS-Dialog (P2).
|
||
6. **[Kenso] Push und Produktions-Checkout.**
|
||
```
|
||
cd /home/webklar/apps/workers-dev
|
||
git remote add origin https://git.webklar.com/knso/workers-dienst.git # Anmeldung wie bei deinen anderen Gitea-Repos (Token)
|
||
git push -u origin p1-fundament:main
|
||
git clone --branch main https://git.webklar.com/knso/workers-dienst.git /home/webklar/apps/workers
|
||
```
|
||
*Prüfen:* `git ls-remote origin main | cut -c1-12` und `git rev-parse HEAD | cut -c1-12` sind gleich; `ls /home/webklar/apps/workers/Dockerfile` existiert. (Scheitert die Anmeldung beim Klonen: `git clone --branch p1-fundament /home/webklar/apps/workers-dev /home/webklar/apps/workers`.)
|
||
7. **[Kenso] Sammlungen anlegen (erst prüfen).**
|
||
```
|
||
cd /home/webklar/apps/workers-dev
|
||
( export APPWRITE_API_KEY="$(sed -n 's/^WORKERS_APPWRITE_API_KEY=//p' /home/webklar/apps/.env | tail -n1)"; timeout 900 node --max-old-space-size=1024 scripts/setup-collections.mjs --check; echo "Exit $?" )
|
||
```
|
||
*Erwartet:* je Sammlung Zeilen mit `FEHLT`, `Exit 1`. Dann derselbe Befehl **ohne** `--check` (Ende `Fertig.`; es sind rund 180 Attribute, Appwrite legt sie nacheinander an, das dauert einige Minuten; bei einem Abbruch durch `timeout` den Befehl einfach wiederholen, er zieht Fehlendes nach), danach wieder mit `--check` → `Alles vorhanden.`, `Exit 0`. Erscheint „Row width limit reached“, in `src/schema.js` die Größe des genannten Felds auf `TEXT_SIZE` setzen und neu laufen lassen (Skript ist idempotent).
|
||
8. **[Kenso] Modell-Prüfung (manuell, kein Test).**
|
||
`( ANTHROPIC_API_KEY="$(sed -n 's/^WORKERS_ANTHROPIC_API_KEY=//p' /home/webklar/apps/.env | tail -n1)" timeout 120 node --max-old-space-size=1024 scripts/check-modelle.mjs; echo "Exit $?" )`
|
||
*Erwartet:* Haiku-Zeile „web_search: unterstützt“, `Exit 0`. Meldet das Skript den Fallback, die Zeile `WORKERS_SEARCH_MODEL=claude-sonnet-5-5` an `/home/webklar/apps/.env` anhängen (die Compose-Datei reicht sie schon durch); `Exit 2` heißt „unklar“ (die Modell-Auskunft nennt Websuche vermutlich gar nicht): denselben Befehl mit `--probe` wiederholen (eine echte Probe-Suche, etwa 0,02 $) und dessen Ergebnis wie oben behandeln; `Exit 1`: Suche ist mit diesen Modellen nicht möglich, P3 nicht starten.
|
||
9. **[Kenso] Startbestand.** Erst `--dry-run`:
|
||
`( export APPWRITE_API_KEY="$(sed -n 's/^WORKERS_APPWRITE_API_KEY=//p' /home/webklar/apps/.env | tail -n1)"; timeout 900 node --max-old-space-size=1024 scripts/seed-register.mjs --dry-run )`
|
||
*Erwartet:* `TROCKENLAUF …`, `Alte Leads: 95 (blockiert: 2)`, `Kunden: <Zahl>` (soll zur WOMS-Kundenliste inkl. Abgesagt/Archiviert passen), `Schlüssel neu: N · schon vorhanden: M`. Stimmt das, derselbe Befehl ohne `--dry-run`; ein zweiter Lauf meldet `Schlüssel neu: 0`. *Prüfen:* Appwrite-Konsole → `workersKeys` hat N Dokumente. Meldet dir jemand eine Antwort/einen Widerspruch eines der 95 alten Lackierer, sag Claude Bescheid (Eintrag in die Sperrliste, Spec §12).
|
||
10. **[Kenso] Container starten.**
|
||
```
|
||
cd /home/webklar/apps
|
||
# Inhalt von /home/webklar/apps/workers/deploy/compose-workers.yml unter "services:" einfügen (z. B. direkt nach dem Block "serverpuls:"),
|
||
# und unter dem Top-Level-"volumes:" die Zeile " workers_data:" ergänzen
|
||
docker compose config -q && echo KONFIG-OK
|
||
free -m | sed -n 2p
|
||
docker compose build workers
|
||
docker compose up -d --no-deps workers
|
||
```
|
||
*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"}`. 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:
|
||
`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
|
||
const j = await (await fetch('/v1/account/jwts', { method: 'POST', credentials: 'include', headers: { 'X-Appwrite-Project': '6a1058610003c5a13a05' } })).json()
|
||
for (const p of ['state', 'events?limit=5', 'settings']) { const r = await fetch('/api/workers/' + p, { headers: { 'X-Appwrite-JWT': j.jwt } }); console.log(p, r.status, await r.json()) }
|
||
```
|
||
*Erwartet:* `state` 200 mit 19 Workern (nur Otto nicht grau), `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.
|
||
|
||
**Update später:** `cd /home/webklar/apps/workers && git pull --ff-only && cd .. && docker compose build workers && docker compose up -d --no-deps workers`.
|
||
**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).
|
||
|