diff --git a/docs/superpowers/plans/2026-10-17-workers-00-uebersicht.md b/docs/superpowers/plans/2026-10-17-workers-00-uebersicht.md index f78f853..9df8080 100644 --- a/docs/superpowers/plans/2026-10-17-workers-00-uebersicht.md +++ b/docs/superpowers/plans/2026-10-17-workers-00-uebersicht.md @@ -1,6 +1,8 @@ -# Workers – Plan-Übersicht und gemeinsame Schnittstellen +# Hustler – Plan-Übersicht und gemeinsame Schnittstellen -> Dieses Dokument ist die **verbindliche Quelle** für alle Workers-Teilpläne P1–P6. Jeder Teilplan verweist hierauf und darf die hier festgelegten Namen, Felder, Pfade und Signaturen nicht abweichend verwenden. Wer etwas ändern muss, ändert es **hier** und in allen betroffenen Plänen. +> Name in der Oberfläche: **Hustler** [Kenso, 17.10.]; technische Namen bleiben workers* (Namensregel in §7). + +> Dieses Dokument ist die **verbindliche Quelle** für alle Hustler-Teilpläne P1–P6 (Dateien `…-workers-pN-…`). Jeder Teilplan verweist hierauf und darf die hier festgelegten Namen, Felder, Pfade und Signaturen nicht abweichend verwenden. Wer etwas ändern muss, ändert es **hier** und in allen betroffenen Plänen. **Spec:** `docs/superpowers/specs/2026-10-17-workers-design.md` (freigegeben 17.10.2026) **Code-Landkarten (Fakten zum Bestand):** `/root/reports/workers-2026-10-16/codekarten/{woms,portal,infra,mail-claude,daten-spiel}.md` @@ -13,10 +15,10 @@ | Plan | Datei | Inhalt | Repo / Arbeitsort | Hängt ab von | |---|---|---|---|---| | **P1** Dienst-Fundament | `2026-10-17-workers-p1-dienst-fundament.md` | Dienst-Gerüst, Appwrite-Sammlungen + Setup-Skript, Normalisierung + Nie-doppelt-Register + Startbestand, Ereignisstrom, Budget-Wächter + Claude-Hülle, Rollen-Register mit Werkzeug-/Kanal-Sperre, Zeitplan-Tick, Otto (Nachtplan + Morgenbericht), HTTP-API, Container + Betrieb | neues Repo `knso/workers-dienst` | – | -| **P2** Hochhaus in WOMS | `2026-10-17-workers-p2-hochhaus-woms.md` | Menüpunkt + Route `/workers`, WorkersPage, Pixel-Engine (Port aus der Vorlage, Etagen/Räume als Daten, Lift, Ereignis-Regie, Leerlauf-/Grau-Zustände), Polling-Feed, Worker-Karte mit Spur, Kennzahlen, Pausenschalter, Einstellungen (Admin) | WOMS `JUSN/tickte-system`, Worktree `dev-workers`, Branch `feature/workers` | P1 (API) | +| **P2** Hochhaus in WOMS | `2026-10-17-workers-p2-hochhaus-woms.md` | Menüpunkt „Hustler“ + Route `/hustler` (`/leads` und `/workers` leiten um), Seite `WorkersPage` (Dateiname bleibt), Pixel-Engine (Port aus der Vorlage, Etagen/Räume als Daten, Lift, Ereignis-Regie, Leerlauf-/Grau-Zustände), Polling-Feed, Worker-Karte mit Spur, Kennzahlen, Pausenschalter, Einstellungen (Admin) | WOMS `JUSN/tickte-system`, Worktree `dev-workers`, Branch `feature/workers` | P1 (API) | | **P3** Etage „Anfragen“ + Freigabe-Pult | `2026-10-17-workers-p3-anfragen-pult.md` | Rita/Kim/Pia/Finn, Alarm-Postfach lesen, Bea-Bewertung für Anfragen, Clara-Erstantwort, Pult im Dienst + Pult-UI in WOMS | Dienst + WOMS | P1, P2 | | **P4** Gespräche, Werkstatt, Prüfung | `2026-10-17-workers-p4-gespraeche-werkstatt.md` | Max als einziger Absender (SMTP-Tor), Nora-Posteingang (IMAP + Einordnung), Fäden, Theo, Bob/Bianca/Ben-Bau, Paul-Prüfung mit Screenshots, Vorschau-Server `entwurf.project.webklar.com`, Vorschau-Freigabe, Erinnerungen, Plattform-Folge-Antworten (Einfügen im Pult, Clara schreibt den nächsten Entwurf, A20) | Dienst (+ WOMS-Pult-Erweiterung) | P1–P3 | -| **P5** Kaufweg im Portal | `2026-10-17-workers-p5-portal-kaufweg.md` | „Website übernehmen“-Seite + Routen ohne Login, Stripe-Checkout ohne Kunde, Webhook-Zweig legt Kunde an, Nachweis, K44-Mail, Vorschau → Kundenprojekt, Status zurück an Workers | Portal `knso/Webklar-Kundenbereich`, Worktree `dev-workers-kauf`, Branch `feature/workers-kaufweg` | P1, P4 (Angebots-Doc) | +| **P5** Kaufweg im Portal | `2026-10-17-workers-p5-portal-kaufweg.md` | „Website übernehmen“-Seite + Routen ohne Login, Stripe-Checkout ohne Kunde, Webhook-Zweig legt Kunde an, Nachweis, K44-Mail, Vorschau → Kundenprojekt, Status zurück an den Workers-Dienst | Portal `knso/Webklar-Kundenbereich`, Worktree `dev-workers-kauf`, Branch `feature/workers-kaufweg` | P1, P4 (Angebots-Doc) | | **P6** Etage „Ohne Website“ + Kaltmail | `2026-10-17-workers-p6-ohne-website.md` | Ali/Hanna/Sven/Lea (Places/OSM/Impressum), Top-3 über alle Räume, Bot-Lead-Liste (Dienst + WOMS-Ansicht), Max-Kaltmail mit allen Pflichtangaben, Nachfassen, Aufbewahrung/Kürzung, Datenschutz-Abschnitt „Interessenten“ | Dienst + WOMS + webklar.com-Rechtstext | P1–P4 | **Reihenfolge der Umsetzung:** P1 → P2 → P3 → P4 → P5 → P6. P5 kann nach P1 parallel zu P3/P4 laufen (eigene Dateien im Portal), solange das Angebots-Dokument aus §4.12 feststeht. @@ -41,11 +43,11 @@ 6. **Dienst:** Node 22 (Container `node:22-alpine`), ESM, keine Semikolons, deutsche Kommentare, Tests `timeout 300 node --max-old-space-size=1024 --test test/*.test.js`. Jede äußere Abhängigkeit (Appwrite, Anthropic, SMTP, IMAP, Uhr) wird per Parameter injiziert, damit Tests ohne Netz laufen. **In Tests nie echte Anthropic-, SMTP- oder Appwrite-Aufrufe.** 7. **Abhängigkeiten des Dienstes (exakt):** `@anthropic-ai/sdk@0.135.0`, `node-appwrite@14.1.0` (wie Portal), `nodemailer@10.1.2`, `imapflow@2.5.2`, `mailparser@3.9.40`, `playwright-core@1.62.1` (passt zu Chromium im Image). Keine weiteren ohne Eintrag hier. 8. **Modelle:** `claude-opus-5-5` (Otto, Bob, Bianca, Ben), `claude-sonnet-5-5` (Bea, Max, Clara, Theo, Paul), `claude-haiku-5-5` (Rita, Kim, Pia, Finn, Ali, Hanna, Sven, Lea, Nora, Arne). Kein `temperature`/`top_p`. Strukturierte Antworten über `output_config.format` (json_schema). Suche und Strukturierung in **zwei** Aufrufen (Suche mit Werkzeugen → freier Text; danach werkzeugloser Aufruf mit Schema), weil Such-Zitate und `output_config.format` kollidieren können. -9. **Budget (Kenso):** 10 $/Tag, 250 $/Monat in den ersten 4 Wochen, Warnung bei 80 %. Workspace-Ausgabenlimit in der Console zusätzlich. Antwort „HTTP 400 … reached your specified … API usage limits“ oder 429 mit `enforced_spend_limit_reached` ⇒ sofort `budget_stopp`. +9. **Budget (Kenso):** 6 $/Tag (Ausreißer-Wächter), 150 $/Monat in den ersten 4 Wochen, Warnung bei 80 %. **[Kenso, 17.10.: max. 150 € im Monat]** Anthropic rechnet in USD: 150 $ ≈ 130 €, also unter 150 €, solange 1 € mindestens 1 $ wert ist. Workspace-Ausgabenlimit in der Console zusätzlich: 150 $/Monat. Eine Grenze über 150 € im Monat braucht eine neue Entscheidung von Kenso. Antwort „HTTP 400 … reached your specified … API usage limits“ oder 429 mit `enforced_spend_limit_reached` ⇒ sofort `budget_stopp`. 10. **Mail (Kenso + Spec §8):** Kaltmails **nie** über webklar.com/Hetzner. Höchstens 3 unverlangte Mails/Tag (Erst + Nachfass zusammen), Antworten an Schreibende + Vorschau-Links eigene Grenze 15/Tag, nur werktags 09:00–16:00 Europe/Berlin. Nur Max sendet. Sperrliste vor jedem Versand. Ein SMTP-Login-Fehler ⇒ Versand für den Tag aus (kein Wiederholen; Hetzner-IP-Sperre-Erfahrung gilt sinngemäß). 11. **Keine Umgehung (Kenso):** kein Captcha/Cloudflare-Umgehen, kein Login-Scraping, `robots.txt` beachten (`url_not_allowed` = auslassen). Plattformen mit Bot-Verbot (Kleinanzeigen, Facebook, Upwork, MyHammer, freelancermap-Login-Bereich, freelance.de-Login-Bereich): **nie** direkt abrufen; Lesen nur über deren Alarm-Mails im Alarm-Postfach oder Suchmaschinen-Treffer; Senden nur durch Menschen über das Pult. 12. **Texte nach außen:** Deutsch, „Sie“ in Kaltmails an Betriebe, „du“ nur, wenn der Anfragende duzt. Kein „Sehr geehrte Damen und Herren“, kein Ausrufezeichen im Betreff. Pflichtbausteine (KI-Hinweis, Quelle, Datenschutz-Link, Widerspruch, Abmelde-Link, Angebotssatz) werden **per Code** angehängt, nie dem Modell überlassen. -13. **Keine Bot-Leads in WOMS-Kunden:** Vor einem bezahlten Deal schreibt kein Teil von Workers in `customers`, `workorders`, `websiteProjects`, `customerPortalAccess` oder legt Appwrite-Benutzer an. +13. **Keine Bot-Leads in WOMS-Kunden:** Vor einem bezahlten Deal schreibt kein Teil von Hustler (Dienst, WOMS, Portal-Kaufweg) in `customers`, `workorders`, `websiteProjects`, `customerPortalAccess` oder legt Appwrite-Benutzer an. 14. **Zeit:** Dienst läuft mit `TZ=Europe/Berlin`; gespeichert wird ISO-8601 UTC. --- @@ -93,7 +95,7 @@ Alle Workers-Sammlungen: Rechte `[]` (nur Server-Schlüssel), `documentSecurity= Typ-Kürzel: `s(n)` String max. n, `i` Integer, `f` Float, `b` Boolean, `dt` Datetime, `e[…]` Enum, `[]` Array. `*` = Pflicht. ### 4.1 `workersSettings` (genau ein Dokument, `$id = "main"`) -`paused b*` · `dailyBudgetUsd f*` (10) · `monthlyBudgetUsd f*` (250) · `warnPct i*` (80) · `mailColdPerDay i*` (3) · `mailReplyCapPerDay i*` (15) · `sendWindowStart s(5)*` ("09:00") · `sendWindowEnd s(5)*` ("16:00") · `sendWeekdaysOnly b*` (true) · `runsPerNight i*` (1) · `candidatesPerRun i*` (10) · `activeRooms s(32)[]` (["netz","kleinanzeigen","portale","foren","aerzte","handwerk","selbst","laden"]) · `connectedRooms s(32)[]` (Such-Räume mit echter Quelle; leer bis eingerichtet; nur die Räume aus `SEARCH_ROOMS` können „nicht angeschlossen“ sein, alle anderen Räume gelten immer als angeschlossen, P1-E9) · `regions s(64)[]` (["Rheinland-Pfalz"]) · `previewGate e[erste_n,immer,nie]*` ("erste_n") · `previewGateN i*` (10) · `previewsSent i*` (0) · `offerText s(400)*` (Kensos Satz aus Spec §11.2; höchstens 400 Zeichen, damit eine Kaltmail unter 180 Wörter bleibt, H3) · `offerProductKey s(32)*` (nur "hosting", P5-E10) · `pultStaleDays i*` (3) · `retentionDays i*` (90; erlaubt 7–90, der Datenschutztext verspricht höchstens 90 Tage) · `followupDays i*` (7) · `previewReminderDays i*` (5) · `previewDeleteDays i*` (60) · `reportEmail s(256)*` (Kensos Adresse) · `privacyInteressentenLive b` (nicht Pflicht, Standard false; der **einzige** Datenschutz-Schalter, Gate für jede Erstnachricht; P3 legt an) · `sendingHalted b` (nicht Pflicht, Standard false; Bounce-Notbremse H4; P4 legt an) · `sendingHaltedReason s(300)` (nicht Pflicht, leer solange nicht gestoppt; P4) · `updatedAt dt` · `updatedBy s(64)` +`paused b*` · `dailyBudgetUsd f*` (6; Ausreißer-Wächter) · `monthlyBudgetUsd f*` (150; max. 150 € im Monat, [Kenso, 17.10.: max. 150 € im Monat]) · `warnPct i*` (80) · `mailColdPerDay i*` (3) · `mailReplyCapPerDay i*` (15) · `sendWindowStart s(5)*` ("09:00") · `sendWindowEnd s(5)*` ("16:00") · `sendWeekdaysOnly b*` (true) · `runsPerNight i*` (1) · `candidatesPerRun i*` (10) · `activeRooms s(32)[]` (["netz","kleinanzeigen","portale","foren","aerzte","handwerk","selbst","laden"]) · `connectedRooms s(32)[]` (Such-Räume mit echter Quelle; leer bis eingerichtet; nur die Räume aus `SEARCH_ROOMS` können „nicht angeschlossen“ sein, alle anderen Räume gelten immer als angeschlossen, P1-E9) · `regions s(64)[]` (["Rheinland-Pfalz"]) · `previewGate e[erste_n,immer,nie]*` ("erste_n") · `previewGateN i*` (10) · `previewsSent i*` (0) · `offerText s(400)*` (Kensos Satz aus Spec §11.2; höchstens 400 Zeichen, damit eine Kaltmail unter 180 Wörter bleibt, H3) · `offerProductKey s(32)*` (nur "hosting", P5-E10) · `pultStaleDays i*` (3) · `retentionDays i*` (90; erlaubt 7–90, der Datenschutztext verspricht höchstens 90 Tage) · `followupDays i*` (7) · `previewReminderDays i*` (5) · `previewDeleteDays i*` (60) · `reportEmail s(256)*` (Kensos Adresse) · `privacyInteressentenLive b` (nicht Pflicht, Standard false; der **einzige** Datenschutz-Schalter, Gate für jede Erstnachricht; P3 legt an) · `sendingHalted b` (nicht Pflicht, Standard false; Bounce-Notbremse H4; P4 legt an) · `sendingHaltedReason s(300)` (nicht Pflicht, leer solange nicht gestoppt; P4) · `updatedAt dt` · `updatedBy s(64)` Harte Grenzen im Code, auch bei von Hand geändertem Dokument: `mailColdPerDay` ≤ 3, `mailReplyCapPerDay` ≤ 15, `runsPerNight` ≤ 2. `sendingHalted` setzt der Dienst (`updatedBy = system`); aufheben (`false`, leert den Grund) kann nur ein Admin über `PUT /settings` im Einstellungs-Dialog. @@ -201,7 +203,7 @@ Pfade relativ zum Dienst-Repo. Alle Funktionen `async`, sofern sie I/O machen. ` | `src/claude.js` | `createClaude({apiKey, sdk, searchModel})` | → `{ ask(deps, opts), search(deps, opts), batch:{ create(deps, requests), retrieve(id), results(id) } }`. Immer `deps.claude.ask(deps, opts)`, nie `ask(opts)`. | | | `ask(deps, {workerId, model?, system, input, schema?, images?, maxTokens, effort?, reply?, stream?})` | prüft `checkBudget(…, {reply})`; ruft die Messages API (`stream:true` für den langen Opus-Bau, P4); bucht Kosten; → `{ data|text, usage, costUsd, model }`. Wirft `BudgetStop` (`.reason` ∈ tag, monat, pause, console) bei Budget-Antworten der API (§2.9), `ClaudeRefusal` bei Verweigerung. | | | `search(deps, {workerId, system, task, maxUses, allowedDomains?, blockedDomains?, maxTokens?, reply?})` | `assertCap(workerId,'web_search')`; `web_search_20260318` + `web_fetch_20260318` mit `allowed_callers:['direct']`; folgt `pause_turn` max. 5×; → `{ text, sources:[{url,title}], usage, costUsd, incomplete, toolErrors:[{tool,code,url?}] }` (`url_not_allowed` = robots.txt, Quelle auslassen). `BOT_VERBOT_DOMAINS` (`src/claude.js`, **einzige** Liste; P3s `BLOCKED_DOMAINS` ist derselbe Wert) sperrt `web_fetch` immer. | -| `src/settings.js` | `getSettings(deps)`, `updateSettings(deps, patch, by)`, `publicSettings(s)` | Standardwerte nach §4.1; `updateSettings` validiert den ganzen Patch zuerst (Budget > 0, `mailColdPerDay` ≤ 3, `mailReplyCapPerDay` ≤ 15, `runsPerNight` 1–2, `retentionDays` 7–90, `offerText` 1–400, `offerProductKey` nur `hosting`, `privacyInteressentenLive`/`sendingHalted` nur Boolean; `sendingHalted:false` leert `sendingHaltedReason`). | +| `src/settings.js` | `getSettings(deps)`, `updateSettings(deps, patch, by)`, `publicSettings(s)` | Standardwerte nach §4.1; `updateSettings` validiert den ganzen Patch zuerst (Budget > 0, `monthlyBudgetUsd` ≤ 150 und `dailyBudgetUsd` ≤ `monthlyBudgetUsd` – auch `getSettings` deckelt so –, `mailColdPerDay` ≤ 3, `mailReplyCapPerDay` ≤ 15, `runsPerNight` 1–2, `retentionDays` 7–90, `offerText` 1–400, `offerProductKey` nur `hosting`, `privacyInteressentenLive`/`sendingHalted` nur Boolean; `sendingHalted:false` leert `sendingHaltedReason`). | | `src/schedule.js` | `berlinNow(clock)`, `runKey(kind, now)`, `isDue(kind, now, last)`, `inSendWindow(now, settings)` | `now` = Ergebnis von `berlinNow(clock)`; Tick-Arten in §6.1; Muster Serverpuls. | | `src/priority.js` (H1) | `rankOf(kind)`, `JOB_RANKS` | Rang 0–5 für jede Job- und Tick-Art (§12 H1); unbekannte Art wirft. | | `src/jobs.js` | `enqueue(deps, {kind, workerId, prospectId?, threadId?, previewId?, payload}) → Job`, `next(deps, {kinds, workerIds?}) → Job oder null`, `finish(deps, jobId, result)`, `fail(deps, jobId, error, {retry=true})`, `reclaimStale(deps, {olderThanMs})` | `next` fragt die angeforderten Arten in `rankOf`-Reihenfolge ab und liefert je Art den ältesten `offen`-Job (H1); Job-Objekt §4.14; nach 3 Versuchen `fehler`. | @@ -268,20 +270,22 @@ Basis intern: `http://webklar-workers:8092`. Von WOMS über nginx `https://ticke | Datei | Export / Zweck | |---|---| -| `src/lib/navLinks.js` | Eintrag `{ key:'workers', label:'Workers', href:'/workers' }` ersetzt `leads` (nicht mehr `paused`). | -| `src/App.jsx` | Route `/workers` → `React.lazy(() => import('./pages/WorkersPage.jsx'))`; `/leads` leitet auf `/workers` um. | +| `src/lib/navLinks.js` | Eintrag `{ key:'hustler', label:'Hustler', href:'/hustler' }` ersetzt `leads` (nicht mehr `paused`). | +| `src/App.jsx` | Route `/hustler` → `React.lazy(() => import('./pages/WorkersPage.jsx'))` (Dateiname bleibt); `/leads` und `/workers` leiten beide per `` auf `/hustler` um, die Query bleibt erhalten (`/workers?tab=botleads&bot=` → `/hustler?tab=botleads&bot=`). | | `src/lib/workersApi.js` · `src/lib/workersClient.js` | `createWorkersApi({ getJwt, fetchImpl })` → `{ state(), events(after, limit?), trace(workerId, limit?), pause(paused), settings(), saveSettings(patch), pult(status = 'offen' oder Komma-Liste), pultAction(id, action, body?), botleads({ room, q, page, id }), prospect(id) }`; `action` ∈ claim, sent, discard, done, reply, approve-preview, paste; `pult(status)` nimmt einen Status oder eine Komma-Liste (`'offen,in_arbeit'`) bzw. ein Array und prüft jeden Eintrag gegen die sechs Pult-Status; `botleads({ room, q, page, id })` reicht `id` seit P2-T04 durch; `getJwt({ fresh }?)`; Header `X-Appwrite-JWT`; Basis `/api/workers`. `workersClient.js` exportiert den verdrahteten `workersApi`. | | `src/lib/workersRoster.js` | `ROLES`, `FLOORS`, `SEARCH_ROOMS` (Spiegel von §3 und `src/roles.js`), `hatForModel(model)`. | | `src/lib/workersView.js` | reine Ableitungen: `formatUsd`, `formatCount`, `workerStatusLabel`, `kpiTiles(kpis)`. | | `src/lib/workersEvents.js` | `reduceEvents(stateByWorker, events)` → `{ [workerId]: { status, task, lastTs } }`; `eventToSteps(event)` → Animationsschritte für die Regie. | | `src/hooks/useWorkersFeed.js` | Polling: `state` alle 15 s, `events?after=` alle 5 s, Pause bei `document.visibilityState==='hidden'`. | | `src/components/workers/engine/*` | `scene.js`: `mountTower(canvas, { onSelect }) → { destroy(), setState(state), pushEvents(events), select(workerId), setPaused(bool) }` + `sprites.js`, `props.js`, `layout.js`, `building.js`, `lift.js`, `director.js`, `fx.js` (Plain Canvas 2D, Vorlage `scene-turm.js`). | -| `src/components/workers/{WorkersStage,WorkersBoard,WorkerCard,WorkerList,WorkersKpis,WorkersSettingsDialog}.jsx` | UI-Teile (P2). `WorkersSettingsDialog` kennt `paused` nicht, zeigt `connectedRooms` nur an und enthält bei `sendingHalted` den Block „Versand wieder freigeben“ (H4, sendet `saveSettings({ sendingHalted: false })`); `WorkersBoard` zeigt bei `state.sendingHalted` eine Warnzeile. P3 ergänzt `PultList.jsx`, `ProspectPanel.jsx`, P4 erweitert `PultList.jsx` (u. a. „Antwort des Kunden einfügen“ bei Plattform-Einträgen), P6 ergänzt `BotLeadList.jsx`. | +| `src/components/workers/{WorkersStage,WorkersBoard,WorkerCard,WorkerList,WorkersKpis,WorkersSettingsDialog}.jsx` | UI-Teile (P2; Dateinamen bleiben, sichtbare Texte nennen „Hustler“, der Titel von `WorkersSettingsDialog` zum Beispiel „Hustler-Einstellungen“). `WorkersSettingsDialog` kennt `paused` nicht, zeigt `connectedRooms` nur an und enthält bei `sendingHalted` den Block „Versand wieder freigeben“ (H4, sendet `saveSettings({ sendingHalted: false })`); `WorkersBoard` zeigt bei `state.sendingHalted` eine Warnzeile. P3 ergänzt `PultList.jsx`, `ProspectPanel.jsx`, P4 erweitert `PultList.jsx` (u. a. „Antwort des Kunden einfügen“ bei Plattform-Einträgen), P6 ergänzt `BotLeadList.jsx`. | | `src/lib/workersSettingsForm.js` (P2) · `src/lib/workersPult.js`, `src/lib/workersProspect.js` (P3; P4 erweitert `workersPult.js`) · `src/lib/botleadsView.js` (P6) | reine Logik; `READONLY_KEYS` der Einstellungsform: `paused`, `previewsSent`, `updatedAt`, `updatedBy`, `offerProductKey`, `connectedRooms`, `privacyInteressentenLive`, `sendingHalted`, `sendingHaltedReason`. | -| `src/pages/WorkersPage.jsx` | `WORKERS_TABS` (Ids `hochhaus` · `freigabe` (P3) · `botleads` (P6), Beschriftung Hochhaus · Freigabe · Bot-Leads) und `const api = workersApi`; P2 legt nur `hochhaus` an (kein Platzhalter-Reiter), die Reiterleiste erscheint ab zwei Reitern; Auswahl über `?tab=`, unbekannter Wert fällt auf `hochhaus`. | +| `src/pages/WorkersPage.jsx` | Seitentitel (H1) und Browser-Tab-Titel „Hustler“; `WORKERS_TABS` (Ids `hochhaus` · `freigabe` (P3) · `botleads` (P6), Beschriftung Hochhaus · Freigabe · Bot-Leads) und `const api = workersApi`; P2 legt nur `hochhaus` an (kein Platzhalter-Reiter), die Reiterleiste erscheint ab zwei Reitern; Auswahl über `?tab=`, unbekannter Wert fällt auf `hochhaus`. | | `src/styles/workers.css` | Klassen mit Präfix `wk-`. Schrift „Pixelify Sans“ selbst gehostet (`public/fonts/PixelifySans-*.woff2`). | | `tests/fixtures/workers-roster.json` | gemeinsame Fixture (§3). | +**Namensregel (Kenso, 17.10.):** Der Bereich heißt für Mitarbeiter **Hustler**, nicht „Workers“. Das gilt für alles, was Mitarbeiter sehen: Menüpunkt, Seitentitel (H1), Browser-Tab-Titel, Route `/hustler`, Titel des Einstellungsdialogs, Leerzustände, Toasts, Fehlertexte, die Morgenbericht-Mail (Betreff „Hustler-Nachtbericht …“, die Anrede darf „hier ist Otto“ bleiben) und Team-Hinweismails (Betreff-Präfix `[Hustler]`). Technische Namen bleiben unverändert: Sammlungen `workers*`, Dienst/Container `webklar-workers`, Repo `knso/workers-dienst`, API-Pfade `/api/workers/*`, Datei- und Komponentennamen (`WorkersPage.jsx`, `workersApi.js`, `workersRoster.js` …), CSS-Präfix `wk-`, Env `WORKERS_*`, `workerId`, Ereignisarten; das Wort „Worker“ für eine einzelne Figur oder Rolle bleibt ebenfalls. Texte an Interessenten und Kunden (Mails, Antworten, Übernahme-Seite, Vorschau-Seiten) nennen weder „Workers“ noch „Hustler“; sie sprechen als „KI-Assistent(in) von WEBklar“. + --- ## 8. Portal – feste Namen (P5 liefert) @@ -294,7 +298,7 @@ Basis intern: `http://webklar-workers:8092`. Von WOMS über nginx `https://ticke | `server/services/workersKaufAbschluss.js` | `isWorkersCheckoutEvent(event)` (Session-`metadata.workersOfferId` vorhanden), `handleWorkersCheckoutEvent(event, { aboWeg })` (`aboWeg` = normaler Abo-Zweig `handleAboEvent`). | | `server/services/{reserviert,workersVorschau,workersKaufProjekt,workersKaufTexte}.js` | reservierte Subdomain `entwurf`; Vorschau-Ordner sicher lesen; Vorschau → Kundenprojekt; Wortlaut Fassung v5 + Nachweis-Texte. | | `server/routes/abo.js` | im Webhook nach dem Feature-Zweig und **vor** dem normalen Zweig: `if (isWorkersCheckoutEvent(event)) return handleWorkersCheckoutEvent(event, { aboWeg: handleAboEvent })`, sonst `handleAboEvent(event)`. | -| `server/mail-templates/K44 Website übernommen.html`, `server/mail-templates/T25 Workers Hinweis.html` | K44 = Zugangsdaten + Begrüßung (an die Adresse des Kundendokuments, ohne BCC); T25 = Team-Hinweis mit Betreff-Präfix `[Workers]`. Die Nummern sind festgelegt; vor dem Anlegen trotzdem alle Branches prüfen. K01 (Vertragsbestätigung) kommt über den normalen Abo-Weg. | +| `server/mail-templates/K44 Website übernommen.html`, `server/mail-templates/T25 Workers Hinweis.html` | K44 = Zugangsdaten + Begrüßung (an die Adresse des Kundendokuments, ohne BCC); T25 = Team-Hinweis mit Betreff-Präfix `[Hustler]` (der Dateiname bleibt). Die Nummern sind festgelegt; vor dem Anlegen trotzdem alle Branches prüfen. K01 (Vertragsbestätigung) kommt über den normalen Abo-Weg. | | `server/config.js` | `collections.workersOffers`, `.workersProspects`, `.workersPreviews`, `.workersEvents`, `.workersSettings` (Env `APPWRITE_COLLECTION_WORKERS_*`, Standard = Sammlungs-ID); `config.workers = { kaufEnabled, vorschauRoot, vorschauBaseUrl }` (Env `WORKERS_KAUF_ENABLED` off/on, Standard off; `WORKERS_VORSCHAU_ROOT`, Standard `/workers-vorschau/vorschau`; `WORKERS_VORSCHAU_BASE`). | Ablauf nach Zahlung (`handleWorkersCheckoutEvent`, unter einer Sperre je Angebot, jeder Schritt wiederholbar): Kunde mit `createCustomerWithPortalAccess({ name, email, password:, kundenart })` anlegen (Status danach `customer`) → Stripe-Customer + Subscription-Metadaten `webklarCustomerId` setzen → normaler Abo-Weg `aboWeg` (`syncSubscription`, `recordAbschluss`, T02 an Team, K01) → Nachweis (`featureConsents`) → Vorschau als Kundenprojekt übernehmen (Repo aus Vorlage in Org WEBklar mit Systembenutzer, Dateien in einem Commit, `websiteProjects` mit `customerId`, Typ `preview`) → K44 senden → `forgetStoredPortalPassword` → `workersOffers.status='bezahlt'` + `paidAt`, `workersProspects.status='deal'` + `customerId` → Ereignis `deal` (workerId `system`, ID `eventIdFor('deal', offerId)`, `metaJson` laut §5). Dauerhafte Fehler (Vorschau unlesbar, Nachweis) melden T25 und der Ablauf geht weiter, vorübergehende (Gitea 5xx, Mail) antworten dem Webhook mit 500. @@ -315,11 +319,11 @@ Ablauf nach Zahlung (`handleWorkersCheckoutEvent`, unter einer Sperre je Angebot | Traefik | Datei `/home/webklar/apps/traefik/dynamic/workers-entwurf.yml`: Router `Host(\`entwurf.project.webklar.com\`)`, Priorität 200, `certResolver: letsencrypt`, Ziel `http://webklar-workers:8093` (P4) | | Reservierte Subdomain | `entwurf` im Portal-Subdomain-Schutz sperren (P5) | | Env (`.env` → Compose) | `WORKERS_APPWRITE_API_KEY`→`APPWRITE_API_KEY`, `WORKERS_ANTHROPIC_API_KEY`→`ANTHROPIC_API_KEY`, `APPWRITE_ENDPOINT=http://webklar-appwrite/v1`, `APPWRITE_PROJECT_ID=6a1058610003c5a13a05`, `WORKERS_SMTP_HOST/PORT/USER/PASS/FROM` (Absender-Postfach der neuen Domain), `WORKERS_IMAP_HOST/PORT/USER/PASS` (gleiches Postfach), `WORKERS_ALERT_IMAP_HOST/PORT/USER/PASS` (Alarm-Postfach), `WORKERS_REPORT_SMTP_*` (interner Morgenbericht über das bestehende webklar.com-Konto erlaubt, da kein Kaltkontakt), `WORKERS_PLACES_API_KEY` (P6), `WORKERS_UNSUB_BASE=https://entwurf.project.webklar.com/abmelden`, `WORKERS_UNSUB_SECRET` (32 Byte zufällig), `WORKERS_SEARCH_MODEL` (optional, Fallback `claude-sonnet-5-5`, falls Haiku kein `web_search` kann), `WORKERS_AUTOMAIL` (`ja`/`nein`, Standard nein: Max sendet Claras `mail_antwort`-Entwürfe erst bei `ja`, P4), `WORKERS_CHROMIUM_PATH` (P4). Es gibt **keinen** Env-Schalter für den Datenschutz-Abschnitt: das ist `settings.privacyInteressentenLive`. | -| Serverpuls | Gruppe `{"app":"Workers","match":["webklar-workers*"],"folders":["workers"]}` in `apps/serverpuls/config/apps.json` | -| Anthropic | eigener Console-Workspace „Workers“, Ausgabenlimit 250 $/Monat (Kenso) | +| Serverpuls | Gruppe `{"app":"Hustler","match":["webklar-workers*"],"folders":["workers"]}` in `apps/serverpuls/config/apps.json` (`app` ist die sichtbare Beschriftung, `match` und `folders` sind technisch) | +| Anthropic | eigener Console-Workspace „Hustler“, Ausgabenlimit 150 $/Monat (Kenso, 17.10.: max. 150 € im Monat) | ## 10. Was Kenso vorab einrichtet (Spec §12) -Console-Workspace + Schlüssel; Domain `webklar-team.de` (INWX) + Google-Workspace-Postfächer (Absender + Alarme) oder Alternative; Konten + Suchaufträge auf Kleinanzeigen/freelancermap/freelance.de an das Alarm-Postfach; Freigabe des Datenschutz-Abschnitts „Interessenten“. Jeder Plan nennt am Ende seine **Betriebsanleitung** (Schritte, die Kenso ausführt: Skripte, `.env`, Compose, nginx, Push). +Console-Workspace „Hustler“ mit Ausgabenlimit 150 $/Monat (Kenso, 17.10.: max. 150 € im Monat) + Schlüssel; Domain `webklar-team.de` (INWX) + Google-Workspace-Postfächer (Absender + Alarme) oder Alternative; Konten + Suchaufträge auf Kleinanzeigen/freelancermap/freelance.de an das Alarm-Postfach; Freigabe des Datenschutz-Abschnitts „Interessenten“. Jeder Plan nennt am Ende seine **Betriebsanleitung** (Schritte, die Kenso ausführt: Skripte, `.env`, Compose, nginx, Push). --- @@ -380,7 +384,7 @@ Stand: Abgleich der Teilpläne P1–P6 am 17.10.2026. Die Teilpläne haben ihre | P5-E4 | P5 | Portal-Container hängt `workers_data:/workers-vorschau:ro` an (§9). | | P5-E5 | P4 | Vorschau-Server 404 ab `convertedProjectId`; Angebote `abgelaufen` bei Sperre; Ordner einer übernommenen Vorschau bleibt ≥ 7 Tage. | | P5-E6 | P1 | `newAbosMonth`, `mrrNewEur` und `dealsMonth` zählt P1 aus `workersOffers` (A12); das Portal schreibt das Ereignis `deal` mit `metaJson` laut §5. | -| P5-E7 | P5 | Mail-Kennungen K44 „Website übernommen“ und T25 „[Workers] …“ (Präfix-Test in `mailTemplates.test.js` erweitert). | +| P5-E7 | P5 | Mail-Kennungen K44 „Website übernommen“ und T25 „[Hustler] …“ (Präfix-Test in `mailTemplates.test.js` erweitert). | | P5-E8 | P5 | `createWorkersCheckout(offer, consent, buyer, { token })`; der Token steht nur im Fragment der Abbruch-Adresse. | | P5-E9 | P5 | Neue Wortlaute (Kundenart, Stripe-Hinweis, Angebotssatz-Zitat) brauchen Kensos Freigabe vor `WORKERS_KAUF_ENABLED=on`. | | P5-E10 | P1, P4 | Verkauft wird nur `productKey 'hosting'` (P1 beschränkt `offerProductKey`, P4 legt nur solche Angebote an). | @@ -390,7 +394,7 @@ Stand: Abgleich der Teilpläne P1–P6 am 17.10.2026. Die Teilpläne haben ihre | P6-E4 | P6 | `deps.places`, `deps.osm`, `deps.dns = { resolveMx, resolve4 }`; `deps.llm` entfällt (A1); das Impressum-Lesen nutzt `deps.fetch` (H2). | | P6-E5 | P6 | Tick-Arten `top3` (05:30) und `scouts` (alle 2 min) ergänzt P6 in `src/schedule.js`; `kuerzen` (täglich 03:00) steht dort seit P1-T08 und wird von P6 nicht noch einmal angelegt (P6 liefert nur den Lauf `runKuerzen` und den Tick-Eintrag). | | P6-E6 | P6 | Zusatzprüfung `coldChecks` über `registerGateCheck` (Gründe `datenschutz`, `adresse_nicht_belegt`, `kein_kaltkontakt`, `kaltmail_ohne_antwort`, `status_unpassend`); `appendCompliance` Zweig `cold:true`. | -| P6-E7 | P6 | `GET /botleads?id=`; Ereignislink `/workers?tab=botleads&bot=`; Route über `routes`. | +| P6-E7 | P6 | `GET /botleads?id=`; Ereignislink `/hustler?tab=botleads&bot=`; Route über `routes`. | | P6-E8 | P6 | `deps.drafters.erstmail/nachfass(deps, job)` liefert P6; die Aufträge legen `selectNightTop3` bzw. `scheduleFollowups` an, versendet werden sie von P4s Tick `senden`. | ### 11.2 Neu aus dem Abgleich (widerspruchsfrei entschieden) @@ -419,6 +423,8 @@ Stand: Abgleich der Teilpläne P1–P6 am 17.10.2026. Die Teilpläne haben ihre | A20 | P4 | **Gelöst in P4-T34:** Die Eingabeseite für Folge-Antworten von Plattformen (Spec §5): Einfügen im Pult (`POST /pult/:id/paste`, `POST /prospect/:id/paste`) oder passende Plattform-Benachrichtigung im Alarm-Postfach ⇒ eingegangene Nachricht im Plattform-Faden ⇒ Nora ordnet ein ⇒ ja ⇒ Bau, Frage ⇒ Clara schreibt den nächsten Entwurf (Pult `plattform_antwort`), siehe P4-E12. Ein eigener Teilplan P7 ist nicht nötig. | | A21 | P2 | WOMS zeigt `sendingHalted` als Warnzeile im Board und bietet dem Admin im Einstellungs-Dialog „Versand wieder freigeben“ (H4). | | A22 | P6 | Kaltmails und Nachfass-Mails enthalten im Text genau **einen** Link, `https://www.webklar.com` (H3), eingesetzt vom Code, nie vom Modell; der Pflichtblock bleibt bei den drei Adressen aus `buildColdFooter`. | +| A23 | alle | **Budget [Kenso, 17.10.: max. 150 € im Monat]:** Standardwerte `dailyBudgetUsd` 6 und `monthlyBudgetUsd` 150 (USD, ≈ 130 €), `warnPct` 80, Console-Ausgabenlimit 150 $/Monat (§2.9, §4.1, §9, §10). Tests und Beispieldaten der Teilpläne nutzen diese Werte. | +| A24 | alle | **Name [Kenso, 17.10.]:** Mitarbeiter sehen „Hustler“ (Menü, Titel, Route `/hustler`, Dialoge, Toasts, Fehlertexte, Morgenbericht „Hustler-Nachtbericht …“, Team-Mails `[Hustler]`); technische Namen bleiben `workers*`; Texte an Interessenten und Kunden nennen weder „Workers“ noch „Hustler“ (Namensregel in §7). | --- diff --git a/docs/superpowers/plans/2026-10-17-workers-p1-dienst-fundament.md b/docs/superpowers/plans/2026-10-17-workers-p1-dienst-fundament.md index 0e5abf3..c373cd1 100644 --- a/docs/superpowers/plans/2026-10-17-workers-p1-dienst-fundament.md +++ b/docs/superpowers/plans/2026-10-17-workers-p1-dienst-fundament.md @@ -18,7 +18,7 @@ - Appwrite: Projekt `6a1058610003c5a13a05`, Datenbank `woms-database`, Rechte `[]`, `documentSecurity=false`, IDs ≤ 36 Zeichen, kein Feld `data`, JSON-Felder als Text, Zeiten ISO-8601 UTC. - Dienst: Port 8092 nur intern (Basis `/api/workers`), in Compose weder `ports:` noch Traefik-Label; Zugang ausschließlich über nginx von `ticket.webklar.com`. - Auth: `X-Appwrite-JWT` → Appwrite `GET /account`, 60 s Cache; Label `mitarbeiter` oder `admin` ist Pflicht (Kunden-Konten teilen das Appwrite-Projekt), `admin` für `/settings`. -- Budget: 10 $/Tag, 250 $/Monat, Warnung bei 80 %, Tag gestoppt bei 100 %; `mailColdPerDay` ≤ 3 ohne Ausnahme, `mailReplyCapPerDay` ≤ 15, `runsPerNight` 1–2. +- Budget [Kenso, 17.10.: max. 150 € im Monat]: `monthlyBudgetUsd` 150 $ (≈ 130 €, unter 150 €, solange 1 € ≥ 1 $), `dailyBudgetUsd` 6 $ (Ausreißer-Schutz), Warnung bei 80 %, Tag gestoppt bei 100 %; das Console-Workspace-Ausgabenlimit steht ebenfalls auf 150 $/Monat (Betriebsanleitung 4); `mailColdPerDay` ≤ 3 ohne Ausnahme, `mailReplyCapPerDay` ≤ 15, `runsPerNight` 1–2. - Preise je MTok: Opus 4/20, Sonnet 2/10, Haiku 0,10/0,50 (Prompt > 100 000 Tokens: 0,50/2,50); Cache-Lesen ×0,05 (Haiku ×0,1), Cache-Schreiben 5 min ×1,25, 1 h ×2; Batch: Tokens ×0,5; Websuche 0,01 $ je Suche (nie rabattiert). - Modelle `claude-opus-5-5`, `claude-sonnet-5-5`, `claude-haiku-5-5`; kein `temperature`/`top_p`/`top_k`; Suche (Werkzeuge) und Strukturierung (Schema) sind getrennte Aufrufe; `pause_turn` höchstens 5 Fortsetzungen. - API-Budgetantworten (HTTP 400 „You have reached your specified … API usage limits“, HTTP 429 `enforced_spend_limit_reached`) ⇒ `BudgetStop('console')`, Tag gestoppt, nie wiederholen. @@ -313,7 +313,7 @@ Die Ergänzungen E1–E11 dieses Plans sind in die Übersicht übernommen: siehe - Test: `test/db.test.js`, `test/fakeDb.test.js` **Interfaces:** -- Produces: `createDb({ endpoint, projectId, apiKey }, { sdk }) → { get(col,id) → doc|null, list(col,queries=[]) → doc[], listAll(col,queries=[]) → doc[], count(col,queries=[]) → number, create(col,data,id?) → doc, update(col,id,data) → doc, remove(col,id) }` (Übersicht §6 + E3); `ConflictError` (`.code = 409`); `DATABASE_ID = 'woms-database'`; `Query` (Re-Export); `GESPERRTE_SAMMLUNGEN = ['customers','workorders','websiteProjects','customerPortalAccess']` (E11): `create`/`update`/`remove` darauf werfen `Error('Schreiben in ist für Workers gesperrt')` ohne SDK-Aufruf, Lesen bleibt erlaubt. `createFakeDb({ schema = SCHEMA, clock, loose = ['customers','workorders','websiteProjects','customerPortalAccess'] })` mit derselben Schnittstelle (inkl. Schreibsperre; nur `seed` darf in lose Sammlungen schreiben) plus `seed(col, over = {}, id?) → doc` (füllt Pflichtfelder mit Typ-Standardwerten), `all(col) → doc[]`, `calls` = Array `{ op:'get'|'list'|'listAll'|'count'|'create'|'update'|'remove', col, id?, queries? }` (`listAll` protokolliert nur den eigenen Aufruf, nicht die Seiten). `makeDeps({ now, …over }) → { db, clock, log }`. +- Produces: `createDb({ endpoint, projectId, apiKey }, { sdk }) → { get(col,id) → doc|null, list(col,queries=[]) → doc[], listAll(col,queries=[]) → doc[], count(col,queries=[]) → number, create(col,data,id?) → doc, update(col,id,data) → doc, remove(col,id) }` (Übersicht §6 + E3); `ConflictError` (`.code = 409`); `DATABASE_ID = 'woms-database'`; `Query` (Re-Export); `GESPERRTE_SAMMLUNGEN = ['customers','workorders','websiteProjects','customerPortalAccess']` (E11): `create`/`update`/`remove` darauf werfen `Error('Schreiben in ist für Hustler gesperrt')` ohne SDK-Aufruf, Lesen bleibt erlaubt. `createFakeDb({ schema = SCHEMA, clock, loose = ['customers','workorders','websiteProjects','customerPortalAccess'] })` mit derselben Schnittstelle (inkl. Schreibsperre; nur `seed` darf in lose Sammlungen schreiben) plus `seed(col, over = {}, id?) → doc` (füllt Pflichtfelder mit Typ-Standardwerten), `all(col) → doc[]`, `calls` = Array `{ op:'get'|'list'|'listAll'|'count'|'create'|'update'|'remove', col, id?, queries? }` (`listAll` protokolliert nur den eigenen Aufruf, nicht die Seiten). `makeDeps({ now, …over }) → { db, clock, log }`. - `sdk` = `{ Client, Databases, ID }` (Standard: `import * as sdk from 'node-appwrite'`). - [ ] **Step 1: Tests schreiben.** `db.test.js` (Fake-SDK: `mkSdk(fns, calls)` baut `{ Client, ID, Databases }`, jede Methode aus `fns` protokolliert ihre Argumente in `calls`): @@ -397,7 +397,7 @@ Die Ergänzungen E1–E11 dieses Plans sind in die Übersicht übernommen: siehe }) ``` - [ ] **Step 2: Fehlschlag sehen.** `cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/db.test.js test/fakeDb.test.js` → FAIL `ERR_MODULE_NOT_FOUND`. -- [ ] **Step 3: Implementieren.** `db.js`: `new sdk.Client().setEndpoint().setProject().setKey()`, `new sdk.Databases(client)`; `create(col, data, id = sdk.ID.unique())`; Fehler mit `code === 409` → `new ConflictError(...)` (Ursache als `cause`); `get` fängt nur 404; `listAll` hängt selbst `Query.limit(100)` und `Query.cursorAfter(lastId)` an, bis eine Seite < 100 liefert (Sicherung: höchstens 1000 Seiten), eigene `limit`/`cursor`-Queries → Fehler; `count` = `total` einer `limit(1)`-Abfrage. `create`/`update`/`remove` prüfen zuerst `GESPERRTE_SAMMLUNGEN` (Fehlertext `Schreiben in 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: `, `: enum …`, `: required`, `: size/length …`, `: ungültiger Wert`); Standard-Limit 25; nicht unterstützte Query-Methoden werfen `Query-Methode nicht unterstützt: `; `listAll` ruft die eigene `list`-Paginierung. `helpers.js`: `makeDeps({ now = '2026-10-19T09:00:00.000Z', ...over })`. +- [ ] **Step 3: Implementieren.** `db.js`: `new sdk.Client().setEndpoint().setProject().setKey()`, `new sdk.Databases(client)`; `create(col, data, id = sdk.ID.unique())`; Fehler mit `code === 409` → `new ConflictError(...)` (Ursache als `cause`); `get` fängt nur 404; `listAll` hängt selbst `Query.limit(100)` und `Query.cursorAfter(lastId)` an, bis eine Seite < 100 liefert (Sicherung: höchstens 1000 Seiten), eigene `limit`/`cursor`-Queries → Fehler; `count` = `total` einer `limit(1)`-Abfrage. `create`/`update`/`remove` prüfen zuerst `GESPERRTE_SAMMLUNGEN` (Fehlertext `Schreiben in ist für Hustler gesperrt`). Kein Retry in dieser Schicht. `fakeDb.js`: Dokumente in Maps; jeder Aufruf wird in `calls` protokolliert (Form siehe Interfaces); Queries per `JSON.parse` der SDK-Query-Strings; `$id` (ohne ID `fake000001`…), `$createdAt`/`$updatedAt` aus `clock`; Prüfung gegen `SCHEMA` (unbekannte Felder – auch jeder `$…`-Schlüssel in `data`, Typen, `datetime` = von `Date.parse` lesbarer ISO-String, Offset erlaubt, Enum, Länge nach `max ?? size`, Pflichtfelder nur bei `create`, `unique`-Indizes → `ConflictError`; Meldungen nennen Feldnamen und Art: `Unknown attribute: `, `: enum …`, `: required`, `: size/length …`, `: ungültiger Wert`); Standard-Limit 25; nicht unterstützte Query-Methoden werfen `Query-Methode nicht unterstützt: `; `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 "` @@ -619,7 +619,7 @@ Die Ergänzungen E1–E11 dieses Plans sind in die Übersicht übernommen: siehe ```js test('Standardwerte §4.1 ohne gespeichertes Dokument', async () => { const s = await getSettings(makeDeps()) - assert.deepEqual([s.paused, s.dailyBudgetUsd, s.monthlyBudgetUsd, s.warnPct, s.mailColdPerDay, s.mailReplyCapPerDay], [false, 10, 250, 80, 3, 15]) + assert.deepEqual([s.paused, s.dailyBudgetUsd, s.monthlyBudgetUsd, s.warnPct, s.mailColdPerDay, s.mailReplyCapPerDay], [false, 6, 150, 80, 3, 15]) // [Kenso, 17.10.: max. 150 € im Monat]: 150 $ ≈ 130 €, Tag 6 $ assert.deepEqual([s.sendWindowStart, s.sendWindowEnd, s.sendWeekdaysOnly, s.runsPerNight, s.candidatesPerRun], ['09:00', '16:00', true, 1, 10]) assert.deepEqual(s.activeRooms, ['netz', 'kleinanzeigen', 'portale', 'foren', 'aerzte', 'handwerk', 'selbst', 'laden']) assert.deepEqual([s.connectedRooms, s.regions], [[], ['Rheinland-Pfalz']]) @@ -637,7 +637,8 @@ Die Ergänzungen E1–E11 dieses Plans sind in die Übersicht übernommen: siehe test('Validierung: ungültige Patches werfen ValidationError (400, deutsch, mit Feldname) und ändern nichts', async () => { const deps = makeDeps() for (const [patch, re] of [[{ mailColdPerDay: 4 }, /mailColdPerDay/], [{ mailColdPerDay: 3.5 }, /mailColdPerDay/], [{ mailReplyCapPerDay: 16 }, /mailReplyCapPerDay/], [{ dailyBudgetUsd: 0 }, /dailyBudgetUsd/], - [{ monthlyBudgetUsd: -1 }, /monthlyBudgetUsd/], [{ warnPct: 101 }, /warnPct/], [{ sendWindowStart: '9:00' }, /sendWindowStart/], [{ sendWindowStart: '16:00', sendWindowEnd: '09:00' }, /sendWindow/], + [{ monthlyBudgetUsd: -1 }, /monthlyBudgetUsd/], [{ monthlyBudgetUsd: 151 }, /monthlyBudgetUsd/], [{ dailyBudgetUsd: 151 }, /dailyBudgetUsd/], // [Kenso, 17.10.: max. 150 € im Monat] als harte Grenze + [{ warnPct: 101 }, /warnPct/], [{ sendWindowStart: '9:00' }, /sendWindowStart/], [{ sendWindowStart: '16:00', sendWindowEnd: '09:00' }, /sendWindow/], [{ runsPerNight: 3 }, /runsPerNight/], [{ activeRooms: ['nirgends'] }, /activeRooms/], [{ connectedRooms: ['nirgends'] }, /connectedRooms/], [{ previewGate: 'vielleicht' }, /previewGate/], [{ previewsSent: 5 }, /previewsSent/], [{ unbekannt: 1 }, /unbekannt/], [{ reportEmail: 'kein-mail' }, /reportEmail/], [{ regions: [] }, /regions/], [{ offerProductKey: 'gratis' }, /offerProductKey/], [{ offerProductKey: 'wartung' }, /offerProductKey/], // A11: verkauft wird nur "hosting" (P5-E10) @@ -645,17 +646,17 @@ Die Ergänzungen E1–E11 dieses Plans sind in die Übersicht übernommen: siehe [{ retentionDays: 91 }, /retentionDays/], [{ retentionDays: 6 }, /retentionDays/]]) // A11: 7–90 Tage (der Datenschutztext verspricht höchstens 90) await assert.rejects(updateSettings(deps, patch, 'u1'), (e) => e instanceof ValidationError && e.status === 400 && re.test(e.message), JSON.stringify(patch)) assert.equal(deps.db.all('workersSettings').length, 0) - await updateSettings(deps, { mailColdPerDay: 3, connectedRooms: ['netz', 'bruecke', 'waage'], retentionDays: 90, offerText: 'x'.repeat(400), offerProductKey: 'hosting' }, 'u1') // Obergrenzen sind erlaubt: 3; jeder bekannte Raum; 90 Tage; 400 Zeichen + await updateSettings(deps, { mailColdPerDay: 3, connectedRooms: ['netz', 'bruecke', 'waage'], retentionDays: 90, offerText: 'x'.repeat(400), offerProductKey: 'hosting', monthlyBudgetUsd: 150 }, 'u1') // Obergrenzen sind erlaubt: 3; jeder bekannte Raum; 90 Tage; 400 Zeichen; 150 $ assert.equal((await updateSettings(deps, { retentionDays: 7 }, 'u1')).retentionDays, 7) }) test('getSettings begrenzt die Mail-Grenzen auch bei von Hand geändertem Dokument (E11)', async () => { - const deps = makeDeps(); deps.db.seed('workersSettings', { mailColdPerDay: 50, mailReplyCapPerDay: 99, runsPerNight: 9 }, 'main') - const s = await getSettings(deps); assert.deepEqual([s.mailColdPerDay, s.mailReplyCapPerDay, s.runsPerNight], [3, 15, 2]) + const deps = makeDeps(); deps.db.seed('workersSettings', { mailColdPerDay: 50, mailReplyCapPerDay: 99, runsPerNight: 9, monthlyBudgetUsd: 999, dailyBudgetUsd: 500 }, 'main') + const s = await getSettings(deps); assert.deepEqual([s.mailColdPerDay, s.mailReplyCapPerDay, s.runsPerNight, s.monthlyBudgetUsd, s.dailyBudgetUsd], [3, 15, 2, 150, 150]) // Budget-Deckel 150 $ gilt auch bei Konsolen-Änderung }) test('publicSettings entfernt previewsSent und $-Felder', async () => { const p = publicSettings(await getSettings(makeDeps())); assert.ok(!('previewsSent' in p)); assert.ok(!Object.keys(p).some((k) => k.startsWith('$'))) }) ``` - [ ] **Step 2: Fehlschlag sehen.** `cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/settings.test.js` → FAIL `ERR_MODULE_NOT_FOUND`. -- [ ] **Step 3: Implementieren.** `errors.js`: `HttpError extends Error` mit `status`; Unterklassen. `settings.js`: `DEFAULTS` wie im Test (Arrays je Aufruf kopieren); `getSettings` = `db.get('workersSettings','main')` über `DEFAULTS` gelegt (nur bekannte Schlüssel), danach `mailColdPerDay` auf höchstens 3, `mailReplyCapPerDay` auf höchstens 15 und `runsPerNight` auf höchstens 2 begrenzt (harte Grenzen gelten auch, wenn das Dokument in der Appwrite-Konsole geändert wurde). `updateSettings`: ganzen Patch zuerst validieren (erst danach schreiben), dann `get`: fehlt → `create(…, 'main')` mit allen `DEFAULTS` + Patch (bei `ConflictError` weiter mit `update`), sonst `update`. Grenzen: Budgets > 0; `warnPct` ganze Zahl 1–100; `mailColdPerDay` ganze Zahl 0–3; `mailReplyCapPerDay` 0–15; `sendWindow*` `HH:MM`, Start < Ende; `runsPerNight` 1–2; `candidatesPerRun` 1–20; `activeRooms` ⊆ `SEARCH_ROOMS`; `connectedRooms` ⊆ alle `room`-Werte aus `ROLES`; `regions` 1–30 Strings (je 1–64 Zeichen); `previewGate` ∈ {erste_n, immer, nie}; `previewGateN` 0–1000; `offerText` 1–400 Zeichen (hält eine Kaltmail unter 180 Wörtern, H3); `offerProductKey` nur `hosting` (das Portal verkauft nichts anderes, P5-E10); `pultStaleDays` 1–30; `retentionDays` 7–90 (der Datenschutztext verspricht höchstens 90 Tage); `followupDays`/`previewReminderDays` 1–60; `previewDeleteDays` 7–365; `reportEmail` einfache Adressprüfung ≤ 256; `paused`/`sendWeekdaysOnly` Boolean; `previewsSent` im Patch → „internes Feld“; Meldungen deutsch, beginnen mit dem Feldnamen. `publicSettings` und `DEFAULTS` bleiben wie beschrieben; die Boolean-Felder `privacyInteressentenLive` (P3) sowie `sendingHalted` und `sendingHaltedReason` (P4) fehlen hier bewusst (Übersicht §4.1): P3 und P4 ergänzen später `DEFAULTS` und Validierung (`sendingHalted:false` leert den Grund). +- [ ] **Step 3: Implementieren.** `errors.js`: `HttpError extends Error` mit `status`; Unterklassen. `settings.js`: `DEFAULTS` wie im Test (Arrays je Aufruf kopieren); `getSettings` = `db.get('workersSettings','main')` über `DEFAULTS` gelegt (nur bekannte Schlüssel), danach `mailColdPerDay` auf höchstens 3, `mailReplyCapPerDay` auf höchstens 15, `runsPerNight` auf höchstens 2, `monthlyBudgetUsd` auf höchstens 150 und `dailyBudgetUsd` auf höchstens `monthlyBudgetUsd` begrenzt (harte Grenzen gelten auch, wenn das Dokument in der Appwrite-Konsole geändert wurde). `updateSettings`: ganzen Patch zuerst validieren (erst danach schreiben), dann `get`: fehlt → `create(…, 'main')` mit allen `DEFAULTS` + Patch (bei `ConflictError` weiter mit `update`), sonst `update`. Grenzen: Budgets > 0, `monthlyBudgetUsd` ≤ 150 [Kenso, 17.10.: max. 150 € im Monat], `dailyBudgetUsd` ≤ `monthlyBudgetUsd`; `warnPct` ganze Zahl 1–100; `mailColdPerDay` ganze Zahl 0–3; `mailReplyCapPerDay` 0–15; `sendWindow*` `HH:MM`, Start < Ende; `runsPerNight` 1–2; `candidatesPerRun` 1–20; `activeRooms` ⊆ `SEARCH_ROOMS`; `connectedRooms` ⊆ alle `room`-Werte aus `ROLES`; `regions` 1–30 Strings (je 1–64 Zeichen); `previewGate` ∈ {erste_n, immer, nie}; `previewGateN` 0–1000; `offerText` 1–400 Zeichen (hält eine Kaltmail unter 180 Wörtern, H3); `offerProductKey` nur `hosting` (das Portal verkauft nichts anderes, P5-E10); `pultStaleDays` 1–30; `retentionDays` 7–90 (der Datenschutztext verspricht höchstens 90 Tage); `followupDays`/`previewReminderDays` 1–60; `previewDeleteDays` 7–365; `reportEmail` einfache Adressprüfung ≤ 256; `paused`/`sendWeekdaysOnly` Boolean; `previewsSent` im Patch → „internes Feld“; Meldungen deutsch, beginnen mit dem Feldnamen. `publicSettings` und `DEFAULTS` bleiben wie beschrieben; die Boolean-Felder `privacyInteressentenLive` (P3) sowie `sendingHalted` und `sendingHaltedReason` (P4) fehlen hier bewusst (Übersicht §4.1): P3 und P4 ergänzen später `DEFAULTS` und Validierung (`sendingHalted:false` leert den Grund). - [ ] **Step 4: Test laufen lassen.** Gleicher Befehl → PASS (5 Tests). - [ ] **Step 5: Commit.** `git add src/errors.js src/settings.js test/settings.test.js && git commit -m "P1-T07: Einstellungen mit Validierung" -m "Co-Authored-By: Claude Opus 5.5 "` @@ -826,27 +827,27 @@ Die Ergänzungen E1–E11 dieses Plans sind in die Übersicht übernommen: siehe - [ ] **Step 1: Test schreiben.** (`near` aus `test/support/helpers.js`; Standard-`makeDeps` = Montag 11:00 Berlin.) ```js test('frisch: ok, kein warn', async () => assert.deepEqual(await checkBudget(makeDeps(), 1), { ok: true, reason: null, warn: false })) - test('Tagesgrenze: 8,5 $ → warn; Schätzung 1,6 sprengt den Tag; reply:true darf trotzdem', async () => { - const deps = makeDeps(); await book(deps, { workerId: 'rita', usd: 8.5 }) - assert.deepEqual(await checkBudget(deps, 1), { ok: true, reason: null, warn: true }) + test('Tagesgrenze (6 $): 5 $ → warn; Schätzung 1,6 sprengt den Tag; reply:true darf trotzdem', async () => { + const deps = makeDeps(); await book(deps, { workerId: 'rita', usd: 5 }) + assert.deepEqual(await checkBudget(deps, 0.5), { ok: true, reason: null, warn: true }) assert.deepEqual(await checkBudget(deps, 1.6), { ok: false, reason: 'tag', warn: true }); assert.equal((await checkBudget(deps, 1.6, { reply: true })).ok, true) }) test('Pause gewinnt immer, auch bei reply', async () => { const deps = makeDeps(); await updateSettings(deps, { paused: true }, 'u') assert.equal((await checkBudget(deps, 0)).reason, 'pause'); assert.equal((await checkBudget(deps, 0, { reply: true })).reason, 'pause') }) - test('Monatsgrenze 250 $ gilt auch für reply', async () => { - const deps = makeDeps(); deps.db.seed('workersDaily', { costUsd: 245 }, '2026-10-05') - assert.equal((await checkBudget(deps, 6)).reason, 'monat'); assert.equal((await checkBudget(deps, 6, { reply: true })).reason, 'monat'); assert.equal((await checkBudget(deps, 4)).ok, true) + test('Monatsgrenze 150 $ gilt auch für reply', async () => { + const deps = makeDeps(); deps.db.seed('workersDaily', { costUsd: 146 }, '2026-10-05') + assert.equal((await checkBudget(deps, 5)).reason, 'monat'); assert.equal((await checkBudget(deps, 5, { reply: true })).reason, 'monat'); assert.equal((await checkBudget(deps, 3)).ok, true) }) test('80 %: genau eine budget_warnung (otto) und eine Mail, auch bei einer zweiten Buchung darüber', async () => { const mails = []; const deps = makeDeps({ notify: async (m) => { mails.push(m); return { sent: true } } }) - await book(deps, { workerId: 'rita', usd: 8.1 }); await book(deps, { workerId: 'rita', usd: 0.5 }) + await book(deps, { workerId: 'rita', usd: 4.9 }); await book(deps, { workerId: 'rita', usd: 0.3 }) // 4,9 $ von 6 $ = 82 %; danach 5,2 $, noch unter 100 % const ev = deps.db.all('workersEvents').filter((e) => e.kind === 'budget_warnung') assert.equal(ev.length, 1); assert.equal(ev[0].workerId, 'otto'); assert.equal(mails.length, 1); assert.match(mails[0].subject, /Budget/) }) test('100 %: ein budget_stopp ohne zusätzliche Warnung; danach tag-gesperrt, reply darf; keine Wiederholung', async () => { - const deps = makeDeps(); const r = await book(deps, { workerId: 'rita', usd: 10.2 }) + const deps = makeDeps(); const r = await book(deps, { workerId: 'rita', usd: 6.2 }) assert.deepEqual(deps.db.all('workersEvents').map((e) => e.kind), ['budget_stopp']); assert.deepEqual([r.warned, r.stopped], [true, true]) assert.deepEqual(await checkBudget(deps, 0.01), { ok: false, reason: 'tag', warn: true }); assert.equal((await checkBudget(deps, 0.01, { reply: true })).ok, true) await book(deps, { workerId: 'rita', usd: 0.5 }); assert.equal(deps.db.all('workersEvents').length, 1) @@ -860,8 +861,8 @@ Die Ergänzungen E1–E11 dieses Plans sind in die Übersicht übernommen: siehe const d = await getDaily(deps, '2026-10-19'); assert.equal(d.searches, 2); assert.deepEqual(d.counters.rita, { searches: 2, gelesen: 5, tokensIn: 1000, costUsd: 0.02 }); near(d.costUsd, 0.02) }) test('vier gleichzeitige Buchungen verlieren keine Kosten; Warnung nur einmal', async () => { - const deps = makeDeps(); await Promise.all([2.1, 2.1, 2.1, 2.1].map((usd) => book(deps, { workerId: 'kim', usd }))) - const d = await getDaily(deps, '2026-10-19'); near(d.costUsd, 8.4); near(d.counters.kim.costUsd, 8.4) + const deps = makeDeps(); await Promise.all([1.4, 1.4, 1.4, 1.4].map((usd) => book(deps, { workerId: 'kim', usd }))) // 5,6 $ von 6 $: über 80 %, unter 100 % + const d = await getDaily(deps, '2026-10-19'); near(d.costUsd, 5.6); near(d.counters.kim.costUsd, 5.6) assert.equal(deps.db.all('workersEvents').filter((e) => e.kind === 'budget_warnung').length, 1) }) ``` @@ -886,12 +887,14 @@ Die Ergänzungen E1–E11 dieses Plans sind in die Übersicht übernommen: siehe const SMTP = { host: 'mail.example', port: 587, user: 'u', pass: 'geheim-pw', from: 'server@example.de' } function mk({ fail } = {}) { const t = { attempts: 0, sent: [], cfg: null } t.createTransport = (cfg) => { t.cfg = cfg; return { sendMail: async (m) => { t.attempts++; if (fail) throw fail; t.sent.push(m); return { messageId: 'x' } } } }; return t } - test('sendet an getTo(): Betreff einzeilig mit [Workers]-Präfix, STARTTLS, Timeouts', async () => { + test('sendet an getTo(): Betreff einzeilig mit [Hustler]-Präfix, STARTTLS, Timeouts', async () => { const deps = makeDeps(); const t = mk() const n = createNotifier({ smtp: SMTP, getTo: async () => 'kenso@example.de', createTransport: t.createTransport, clock: deps.clock, log: deps.log }) assert.deepEqual(await n.notify({ subject: 'Budget\nWarnung', text: 'T' }), { sent: true }) - assert.deepEqual([t.sent[0].to, t.sent[0].from, t.sent[0].subject], ['kenso@example.de', 'server@example.de', '[Workers] Budget Warnung']) + assert.deepEqual([t.sent[0].to, t.sent[0].from, t.sent[0].subject], ['kenso@example.de', 'server@example.de', '[Hustler] Budget Warnung']) assert.deepEqual([t.cfg.host, t.cfg.port, t.cfg.requireTLS, t.cfg.connectionTimeout, t.cfg.auth], ['mail.example', 587, true, 20000, { user: 'u', pass: 'geheim-pw' }]) + await n.notify({ subject: 'Hustler-Nachtbericht 19.10.2026', text: 'T' }); await n.notify({ subject: '[Hustler] Hinweis', text: 'T' }) // beginnt schon mit „Hustler“ → kein zweites Präfix + assert.deepEqual([t.sent[1].subject, t.sent[2].subject], ['Hustler-Nachtbericht 19.10.2026', '[Hustler] Hinweis']) }) test('ohne Konfiguration oder Empfänger: sent=false, kein Transport', async () => { const deps = makeDeps(); const t = mk(); const base = { createTransport: t.createTransport, clock: deps.clock, log: deps.log } @@ -912,7 +915,7 @@ Die Ergänzungen E1–E11 dieses Plans sind in die Übersicht übernommen: siehe }) ``` - [ ] **Step 2: Fehlschlag sehen.** `cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/notify.test.js` → FAIL `ERR_MODULE_NOT_FOUND`. -- [ ] **Step 3: Implementieren.** Transport lazy einmal erzeugen: `{ host, port, secure: port === 465, requireTLS: port !== 465, connectionTimeout: 20000, greetingTimeout: 20000, socketTimeout: 20000, auth: { user, pass } }`. Betreff: CR/LF → Leerzeichen, Präfix `[Workers] ` falls nicht vorhanden. Auth-Fehler (`code === 'EAUTH'` oder `responseCode` ∈ {530, 534, 535}) sperren weitere Versuche bis zum nächsten Berliner Tag (`berlinNow(clock).day`) — Hintergrund: wiederholte falsche SMTP-Logins sperrten am 2026-09-30 die Server-IP bei Hetzner. Logs enthalten nie Passwort oder Nachrichtentext. +- [ ] **Step 3: Implementieren.** Transport lazy einmal erzeugen: `{ host, port, secure: port === 465, requireTLS: port !== 465, connectionTimeout: 20000, greetingTimeout: 20000, socketTimeout: 20000, auth: { user, pass } }`. Betreff: CR/LF → Leerzeichen, Präfix `[Hustler] ` davor, außer der Betreff beginnt schon mit `[Hustler]` oder `Hustler` (so bleibt der Morgenbericht-Betreff `Hustler-Nachtbericht TT.MM.JJJJ` unverändert). Auth-Fehler (`code === 'EAUTH'` oder `responseCode` ∈ {530, 534, 535}) sperren weitere Versuche bis zum nächsten Berliner Tag (`berlinNow(clock).day`) — Hintergrund: wiederholte falsche SMTP-Logins sperrten am 2026-09-30 die Server-IP bei Hetzner. Logs enthalten nie Passwort oder Nachrichtentext. - [ ] **Step 4: Test laufen lassen.** Gleicher Befehl → PASS (4 Tests). - [ ] **Step 5: Commit.** `git add src/notify.js test/notify.test.js && git commit -m "P1-T12: interner Mailer mit Login-Sperre pro Tag" -m "Co-Authored-By: Claude Opus 5.5 "` @@ -958,7 +961,7 @@ Die Ergänzungen E1–E11 dieses Plans sind in die Übersicht übernommen: siehe test('Vorabprüfung: Pause und Tagesgrenze → BudgetStop ohne API-Aufruf; reply:true darf am Tageslimit', async () => { const sdk = fakeSdk([msg()]); const c = createClaude({ apiKey: 'k', sdk }); const ask = (deps, o = {}) => c.ask(deps, { workerId: 'nora', system: 's', input: 'i', maxTokens: 10, ...o }) let deps = makeDeps(); await updateSettings(deps, { paused: true }, 't'); await assert.rejects(ask(deps), (e) => e instanceof BudgetStop && e.reason === 'pause') - deps = makeDeps(); await book(deps, { workerId: 'rita', usd: 10 }); await assert.rejects(ask(deps), (e) => e instanceof BudgetStop && e.reason === 'tag') + deps = makeDeps(); await book(deps, { workerId: 'rita', usd: 6 }); await assert.rejects(ask(deps), (e) => e instanceof BudgetStop && e.reason === 'tag') assert.equal(sdk.calls.length, 0); await ask(deps, { reply: true }); assert.equal(sdk.calls.length, 1) }) test('API-Budgetantworten: 400-Workspace-Limit und 429 enforced_spend_limit_reached → BudgetStop(console), Tag gestoppt, Ereignis einmal, kein weiterer Aufruf', async () => { @@ -1406,7 +1409,7 @@ Die Ergänzungen E1–E11 dieses Plans sind in die Übersicht übernommen: siehe test('nichts geplant: Pause (still), Budget erschöpft, kein angeschlossener Raum (ein leerlauf-Ereignis); Kim/Pia/inaktive Räume nie', async () => { let deps = makeDeps(); await updateSettings(deps, { paused: true, connectedRooms: ['netz'] }, 't') assert.deepEqual(await planNight(deps, { run: 1 }), { planned: [], skipped: 'pause' }); assert.equal(deps.db.all('workersJobs').length + deps.db.all('workersEvents').length, 0) - deps = makeDeps(); await updateSettings(deps, { connectedRooms: ['netz'] }, 't'); await book(deps, { workerId: 'rita', usd: 9.9 }) + deps = makeDeps(); await updateSettings(deps, { connectedRooms: ['netz'] }, 't'); await book(deps, { workerId: 'rita', usd: 5.5 }) // 5,5 + Mindestrest 1 $ > Tagesgrenze 6 $ assert.equal((await planNight(deps, { run: 1 })).skipped, 'budget'); assert.equal(deps.db.all('workersJobs').length, 0) deps = makeDeps(); await updateSettings(deps, { connectedRooms: ['kleinanzeigen', 'portale'] }, 't') assert.deepEqual(await planNight(deps, { run: 1 }), { planned: [], skipped: 'keine_raeume' }) @@ -1438,12 +1441,12 @@ Die Ergänzungen E1–E11 dieses Plans sind in die Übersicht übernommen: siehe const ev = (n, kind, workerId = 'kim', ts = '2026-10-19T00:30:00.000Z', text = 'x') => { for (let i = 0; i < n; i++) deps.db.seed('workersEvents', { ts, workerId, kind, text }) } ev(12, 'fund'); ev(5, 'doppelt', 'arne'); ev(7, 'bewertet', 'bea'); ev(3, 'top3', 'bea'); ev(4, 'botlead', 'arne'); ev(3, 'gesendet', 'max'); ev(1, 'eingang', 'nora'); ev(4, 'auftrag', 'otto') ev(1, 'fehler', 'kim', '2026-10-19T01:00:00.000Z', 'Kim: Postfach nicht erreichbar.'); ev(9, 'fund', 'kim', '2026-10-17T00:00:00.000Z') // letztes: zu alt - deps.db.seed('workersDaily', { costUsd: 6.4 }, '2026-10-18'); deps.db.seed('workersDaily', { costUsd: 0.3 }, '2026-10-19'); deps.db.seed('workersDaily', { costUsd: 34.4 }, '2026-10-05') + deps.db.seed('workersDaily', { costUsd: 4.2 }, '2026-10-18'); deps.db.seed('workersDaily', { costUsd: 0.3 }, '2026-10-19'); deps.db.seed('workersDaily', { costUsd: 34.4 }, '2026-10-05') for (const status of ['offen', 'in_arbeit', 'erledigt']) deps.db.seed('workersPult', { status }) const { subject, text } = await buildReport(deps) - assert.equal(subject, 'Nachtbericht 19.10.2026') + assert.equal(subject, 'Hustler-Nachtbericht 19.10.2026') for (const re of [/Gefunden: 12 · schon bekannt: 5 · bewertet: 7 · Top 3: 3 · Bot-Leads: 4/, /Gesendet: 3 · Antworten eingegangen: 1 · Deals: 0 · gesperrt: 0/, /Wartet auf dich: 2/, /Aufträge von Otto: 4/, - /Kosten gestern: 6,40 \$ · heute bis jetzt: 0,30 \$ · Monat: 41,10 \$ von 250,00 \$ \(Tagesgrenze 10,00 \$\)/, /Fehler: 1 \(zuletzt: Kim: Postfach nicht erreichbar\.\)/, + /Kosten gestern: 4,20 \$ · heute bis jetzt: 0,30 \$ · Monat: 38,90 \$ von 150,00 \$ \(Tagesgrenze 6,00 \$\)/, /Fehler: 1 \(zuletzt: Kim: Postfach nicht erreichbar\.\)/, /Noch nicht angeschlossen: Netz-Radar, Kleinanzeigen, Portale, Foren & Fragen, Ärzte & Praxen, Handwerk, Selbstständige, Läden & Gastro/]) assert.match(text, re) }) test('ruhige Nacht, Pause und Budget-Hinweise', async () => { @@ -1453,13 +1456,13 @@ Die Ergänzungen E1–E11 dieses Plans sind in die Übersicht übernommen: siehe }) test('sendReport reicht Betreff/Text an notify; Fehlschlag wird als fehler-Ereignis sichtbar', async () => { const sent = []; const ok = makeDeps({ notify: async (m) => { sent.push(m); return { sent: true } } }) - assert.deepEqual(await sendReport(ok), { sent: true }); assert.match(sent[0].subject, /^Nachtbericht \d\d\.\d\d\.\d{4}$/) + assert.deepEqual(await sendReport(ok), { sent: true }); assert.match(sent[0].subject, /^Hustler-Nachtbericht \d\d\.\d\d\.\d{4}$/) const bad = makeDeps({ notify: async () => ({ sent: false, reason: 'smtp_auth' }) }); await sendReport(bad) const e = bad.db.all('workersEvents').at(-1); assert.deepEqual([e.kind, e.workerId], ['fehler', 'otto']); assert.match(e.text, /Morgenbericht nicht gesendet: smtp_auth/) }) ``` - [ ] **Step 2: Fehlschlag sehen.** `cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/bericht.test.js` → FAIL `ERR_MODULE_NOT_FOUND`. -- [ ] **Step 3: Implementieren.** Fenster: Ereignisse mit `ts ≥ jetzt − 24 h` (`listAll` mit `Query.greaterThanEqual('ts', …)`), gezählt nach `kind`; Kosten aus den Tagesdokumenten (gestern = Tag vor `now.day`, heute, Monat = `monthDocs`) und den Einstellungen; Pult = `count` mit `status` ∈ {offen, in_arbeit}; Zeilenformate **wörtlich** wie im Test; Zeile `Fehler: n (zuletzt: )` nur bei n > 0; `Budget: Warnung ausgelöst`/`Stopp ausgelöst` nur bei entsprechenden Ereignissen im Fenster; `Pause: aktiv` nur bei `paused`; `Noch nicht angeschlossen: …` = aktive Räume (Reihenfolge `SEARCH_ROOMS`, Namen via `roomLabel`) ohne Anschluss, Zeile entfällt wenn leer; kam keine der gezählten Arten (fund, doppelt, bewertet, top3, botlead, gesendet, eingang, deal, gesperrt, fehler) vor, zusätzlich `Die Nacht war ruhig: keine neuen Funde, keine Nachrichten.`; Anrede `Guten Morgen, hier ist Otto. Nachtbericht für den 19.10.2026.`; Datum `TT.MM.JJJJ` aus `now.day`. `sendReport` ohne `deps.notify` ⇒ Ergebnis `{ sent:false, reason:'nicht_konfiguriert' }` (und das `fehler`-Ereignis). Kein Modellaufruf. +- [ ] **Step 3: Implementieren.** Fenster: Ereignisse mit `ts ≥ jetzt − 24 h` (`listAll` mit `Query.greaterThanEqual('ts', …)`), gezählt nach `kind`; Kosten aus den Tagesdokumenten (gestern = Tag vor `now.day`, heute, Monat = `monthDocs`) und den Einstellungen; Pult = `count` mit `status` ∈ {offen, in_arbeit}; Zeilenformate **wörtlich** wie im Test; Zeile `Fehler: n (zuletzt: )` nur bei n > 0; `Budget: Warnung ausgelöst`/`Stopp ausgelöst` nur bei entsprechenden Ereignissen im Fenster; `Pause: aktiv` nur bei `paused`; `Noch nicht angeschlossen: …` = aktive Räume (Reihenfolge `SEARCH_ROOMS`, Namen via `roomLabel`) ohne Anschluss, Zeile entfällt wenn leer; kam keine der gezählten Arten (fund, doppelt, bewertet, top3, botlead, gesendet, eingang, deal, gesperrt, fehler) vor, zusätzlich `Die Nacht war ruhig: keine neuen Funde, keine Nachrichten.`; Anrede `Guten Morgen, hier ist Otto. Hustler-Nachtbericht für den 19.10.2026.`; Betreff `Hustler-Nachtbericht TT.MM.JJJJ` (der Betreff beginnt schon mit „Hustler“, `notify` setzt deshalb kein zweites `[Hustler]`-Präfix davor, T12); Datum `TT.MM.JJJJ` aus `now.day`. `sendReport` ohne `deps.notify` ⇒ Ergebnis `{ sent:false, reason:'nicht_konfiguriert' }` (und das `fehler`-Ereignis). Kein Modellaufruf. - [ ] **Step 4: Test laufen lassen.** Gleicher Befehl → PASS (3 Tests). - [ ] **Step 5: Commit.** `git add src/otto/bericht.js test/bericht.test.js && git commit -m "P1-T21: Otto schreibt den Morgenbericht" -m "Co-Authored-By: Claude Opus 5.5 "` @@ -1584,7 +1587,7 @@ Die Ergänzungen E1–E11 dieses Plans sind in die Übersicht übernommen: siehe for (const m of ['GET', 'PUT']) { const r = await t.req('/settings', { method: m, body: m === 'PUT' ? { warnPct: 70 } : undefined }); assert.deepEqual([r.status, (await r.json()).error], [403, 'Nur für Admins.']) }; await t.close() }) test('Admin: Einstellungen lesen (ohne previewsSent), ändern, Validierungsfehler 400 mit deutschem Text', async () => { - const t = await boot({ labels: ['admin'] }); const g = await (await t.req('/settings')).json(); assert.equal(g.dailyBudgetUsd, 10); assert.ok(!('previewsSent' in g)) + const t = await boot({ labels: ['admin'] }); const g = await (await t.req('/settings')).json(); assert.equal(g.dailyBudgetUsd, 6); assert.ok(!('previewsSent' in g)) const p = await t.req('/settings', { method: 'PUT', body: { dailyBudgetUsd: 12 } }); assert.equal((await p.json()).dailyBudgetUsd, 12) const bad = await t.req('/settings', { method: 'PUT', body: { mailColdPerDay: 4 } }); assert.equal(bad.status, 400); assert.match((await bad.json()).error, /mailColdPerDay/); await t.close() }) @@ -1601,7 +1604,7 @@ Die Ergänzungen E1–E11 dieses Plans sind in die Übersicht übernommen: siehe }) ``` - [ ] **Step 2: Fehlschlag sehen.** `cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/apiAuth.test.js` → FAIL `ERR_MODULE_NOT_FOUND` (`src/api.js`). -- [ ] **Step 3: Implementieren.** Nach dem Muster `/home/webklar/apps/serverpuls/src/api.js` (`HttpError`, `sendJson` mit `Cache-Control: no-store`, JWT-Form `^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$` und ≤ 2048 Zeichen, Cache `jwt → Promise` 60 s mit Teilen laufender Anfragen, höchstens 500 Einträge, `AbortSignal.timeout(5000)`), aber: `labels` muss `mitarbeiter` **oder** `admin` enthalten (sonst 403 `Kein Zugriff.`), `/settings` verlangt `admin` (403 `Nur für Admins.`); Appwrite 401/403/404 auf `/account` ⇒ 401 `Sitzung abgelaufen.`, sonstige Fehler/Netz ⇒ 502 `Anmeldedienst nicht erreichbar.`. Antworttexte: fehlendes JWT `Nicht angemeldet.`, unbekannter Pfad `Nicht gefunden.`, falsche Methode 405 `Methode nicht erlaubt.`, ungültiges JSON/Feld 400 (`Ungültige Anfrage.` bzw. Text der `ValidationError`), Body > 16 KB 413 `Anfrage zu groß.`, sonst 500 `Interner Fehler.` (Details nur ins Log). `POST /pause`: Body `{ paused: boolean }`; nur bei Änderung `updateSettings(deps, { paused }, user.$id)` und `emit` (`system`, `pause`, Text `Alle Workers pausiert ().` bzw. `Pause aufgehoben ().`, `meta:{ paused, by: user.$id }`); Antwort `{ paused }`. Routen-Tabelle so bauen, dass T25 weitere Pfade ergänzt. +- [ ] **Step 3: Implementieren.** Nach dem Muster `/home/webklar/apps/serverpuls/src/api.js` (`HttpError`, `sendJson` mit `Cache-Control: no-store`, JWT-Form `^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$` und ≤ 2048 Zeichen, Cache `jwt → Promise` 60 s mit Teilen laufender Anfragen, höchstens 500 Einträge, `AbortSignal.timeout(5000)`), aber: `labels` muss `mitarbeiter` **oder** `admin` enthalten (sonst 403 `Kein Zugriff.`), `/settings` verlangt `admin` (403 `Nur für Admins.`); Appwrite 401/403/404 auf `/account` ⇒ 401 `Sitzung abgelaufen.`, sonstige Fehler/Netz ⇒ 502 `Anmeldedienst nicht erreichbar.`. Antworttexte: fehlendes JWT `Nicht angemeldet.`, unbekannter Pfad `Nicht gefunden.`, falsche Methode 405 `Methode nicht erlaubt.`, ungültiges JSON/Feld 400 (`Ungültige Anfrage.` bzw. Text der `ValidationError`), Body > 16 KB 413 `Anfrage zu groß.`, sonst 500 `Interner Fehler.` (Details nur ins Log). `POST /pause`: Body `{ paused: boolean }`; nur bei Änderung `updateSettings(deps, { paused }, user.$id)` und `emit` (`system`, `pause`, Text `Alle Hustler pausiert ().` bzw. `Pause aufgehoben ().`, `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 "` @@ -1624,7 +1627,7 @@ Die Ergänzungen E1–E11 dieses Plans sind in die Übersicht übernommen: siehe await updateSettings(deps, { connectedRooms: ['netz', 'foren', 'aerzte'] }, 't') deps.db.seed('workersJobs', { workerId: 'rita', kind: 'suche', status: 'laeuft' }); deps.db.seed('workersJobs', { workerId: 'ali', kind: 'suche', status: 'offen' }); deps.db.seed('workersJobs', { workerId: 'otto', kind: 'bericht', status: 'offen' }) deps.db.seed('workersEvents', { ts: '2026-10-19T08:55:00.000Z', workerId: 'ali', kind: 'fehler', text: 'Ali: Quelle nicht erreichbar.' }); deps.db.seed('workersEvents', { ts: '2026-10-19T08:50:00.000Z', workerId: 'sven', kind: 'fehler', text: 'Sven: Fehler.' }) - deps.db.seed('workersDaily', { costUsd: 2.5, deals: 1, countersJson: JSON.stringify({ rita: { gelesen: 5, tokensIn: 100, tokensOut: 50, costUsd: 0.1, calls: 2 } }) }, '2026-10-19'); deps.db.seed('workersDaily', { costUsd: 6.4, deals: 2 }, '2026-10-18') + deps.db.seed('workersDaily', { costUsd: 2.5, deals: 1, countersJson: JSON.stringify({ rita: { gelesen: 5, tokensIn: 100, tokensOut: 50, costUsd: 0.1, calls: 2 } }) }, '2026-10-19'); deps.db.seed('workersDaily', { costUsd: 3.4, deals: 2 }, '2026-10-18') for (const status of ['offen', 'in_arbeit', 'erledigt']) deps.db.seed('workersPult', { status }); for (let i = 0; i < 3; i++) deps.db.seed('workersProspects', { status: 'botlead' }); deps.db.seed('workersProspects', { status: 'gefunden' }) deps.db.seed('workersThreads', { status: 'offen' }); deps.db.seed('workersThreads', { status: 'abgeschlossen' }) // A12: Kennzahlen kommen aus workersOffers (bezahlt, paidAt im Berliner Monat); die Tageszähler deals (oben 1 und 2) werden ignoriert @@ -1641,13 +1644,13 @@ Die Ergänzungen E1–E11 dieses Plans sind in die Übersicht übernommen: siehe assert.deepEqual(Object.keys(w).filter((id) => w[id].status === 'grau').sort(), ['hanna', 'kim', 'lea', 'pia', 'sven']) assert.deepEqual(['otto', 'bea', 'max', 'clara', 'nora', 'theo', 'bob', 'bianca', 'ben', 'paul', 'arne'].map((id) => [id, w[id].connected, w[id].status === 'grau']), ['otto', 'bea', 'max', 'clara', 'nora', 'theo', 'bob', 'bianca', 'ben', 'paul', 'arne'].map((id) => [id, true, false])) assert.deepEqual(['rita', 'ali', 'finn', 'kim', 'sven'].map((id) => w[id].connected), [true, true, true, false, false]) - assert.deepEqual(s.kpis, { newAbosMonth: 1, mrrNewEur: 20, dealsMonth: 1, openThreads: 1, pultOpen: 2, botLeads: 3, apiTodayUsd: 2.5, apiMonthUsd: 8.9, dailyBudgetUsd: 10, monthlyBudgetUsd: 250 }) // dealsMonth 1 (bezahltes Angebot im Oktober), nicht 3 (Σ workersDaily.deals) + assert.deepEqual(s.kpis, { newAbosMonth: 1, mrrNewEur: 20, dealsMonth: 1, openThreads: 1, pultOpen: 2, botLeads: 3, apiTodayUsd: 2.5, apiMonthUsd: 5.9, dailyBudgetUsd: 6, monthlyBudgetUsd: 150 }) // dealsMonth 1 (bezahltes Angebot im Oktober), nicht 3 (Σ workersDaily.deals) assert.equal(s.lastEventTs, '2026-10-19T08:55:00.000Z') }) test('buildState auf leerer DB: ohne connectedRooms sind genau die acht Such-Worker grau, die übrigen elf nicht (P1-E9), Kennzahlen 0, lastEventTs null', async () => { const s = await buildState(makeDeps()); assert.deepEqual(s.workers.filter((w) => w.status === 'grau').map((w) => w.workerId).sort(), ['ali', 'finn', 'hanna', 'kim', 'lea', 'pia', 'rita', 'sven']) const rest = s.workers.filter((w) => w.status !== 'grau'); assert.equal(rest.length, 11); assert.ok(rest.every((w) => w.status === 'leerlauf' && w.connected === true)); assert.equal(s.workers.find((w) => w.workerId === 'otto').status, 'leerlauf') - assert.deepEqual(s.kpis, { newAbosMonth: 0, mrrNewEur: 0, dealsMonth: 0, openThreads: 0, pultOpen: 0, botLeads: 0, apiTodayUsd: 0, apiMonthUsd: 0, dailyBudgetUsd: 10, monthlyBudgetUsd: 250 }); assert.equal(s.lastEventTs, null) + assert.deepEqual(s.kpis, { newAbosMonth: 0, mrrNewEur: 0, dealsMonth: 0, openThreads: 0, pultOpen: 0, botLeads: 0, apiTodayUsd: 0, apiMonthUsd: 0, dailyBudgetUsd: 6, monthlyBudgetUsd: 150 }); assert.equal(s.lastEventTs, null) assert.deepEqual([s.sendingHalted, s.sendingHaltedReason], [false, '']) // H4: Standard; P4 legt die Einstellungsfelder an und prüft den gestoppten Zustand }) test('buildEvents: aufsteigend ab after (strikt größer), Limit geklemmt, ohne after die letzten N aufsteigend, meta geparst; ungültiges Datum → ValidationError', async () => { @@ -1676,7 +1679,7 @@ Die Ergänzungen E1–E11 dieses Plans sind in die Übersicht übernommen: siehe }) ``` - [ ] **Step 2: Fehlschlag sehen.** `cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/view.test.js` → FAIL `ERR_MODULE_NOT_FOUND` (`src/view.js`). -- [ ] **Step 3: Implementieren.** `buildState`: Einstellungen (daraus `paused`, `sendingHalted: Boolean(settings.sendingHalted)`, `sendingHaltedReason: settings.sendingHaltedReason ?? ''`); Aufträge `status ∈ {laeuft, offen}` (`limit 200`); Ereignisse der letzten 30 min (`ts` absteigend, `limit 200`) – „aktuell fehlerhaft“ = jüngstes Ereignis des Workers innerhalb 15 min ist `fehler`; `connected` = `!SEARCH_ROOMS.includes(room) || connectedRooms.includes(room)` (`SEARCH_ROOMS` aus `src/roles.js`, T05; verbindlich P1-E9: Bea, Max, Clara, Nora, Theo, Bob, Bianca, Ben, Paul, Arne und Otto sind nie grau, ein Such-Raum ist es, bis er in `connectedRooms` steht; Einträge in `connectedRooms`, die keine Such-Räume sind, ändern nichts); Status in der Rangfolge `grau` (nicht angeschlossen) > `fehler` > `arbeitet` (Job `laeuft`) > `wartet` (Job `offen`) > `leerlauf`; `task`: `grau` ⇒ `noch nicht angeschlossen`, `fehler` ⇒ Text des Fehler-Ereignisses, `arbeitet` ⇒ Text des jüngsten Ereignisses des Workers (sonst `JOB_LABEL`), `wartet` ⇒ `Wartet auf Start: `, sonst `''`; `JOB_LABEL` = deutsche Substantive für alle 15 Auftragsarten (`suche` → `Suche`, `bericht` → `Bericht`, `alarm_lesen` → `Alarm-Postfach lesen`, …); `counters` aus `getDaily(heute).counters[workerId]` ohne `tokensIn, tokensOut, costUsd, calls`. Kennzahlen: eine einzige Liste bezahlter Angebote aus `workersOffers` (Abfrage nur über den Index `status = 'bezahlt'`, `limit 500`; im Code gefiltert auf `paidAt` im Berliner Monat, also ≥ `berlinStartOfDay(Monatserster)` und < `berlinStartOfDay(Folgemonatserster)`); daraus `dealsMonth` = Länge der Liste, `newAbosMonth` = Anzahl der Einträge, deren `productKey` in der Preistabelle steht, `mrrNewEur` = Summe der Preise `{ hosting: 20 }` (A12; `workersDaily.deals` wird nicht gelesen), `openThreads` = `count` Fäden mit Status offen/wartet_auf_kunde/wartet_auf_uns, `pultOpen` = `count` offen+in_arbeit, `botLeads` = `count` Interessenten `botlead`, `apiTodayUsd`/`apiMonthUsd` aus den Tagesdokumenten (alle Geldwerte auf 6 Nachkommastellen runden). `buildEvents`/`buildTrace` mit den Indizes `ts` bzw. `workerId+ts`; `meta` = geparstes `metaJson` oder `null`; ungültiges `after` (`Date.parse` = NaN) ⇒ `ValidationError('Ungültiges Datum.')`, gültiges wird als `new Date(after).toISOString()` in die Query gegeben; jedes ausgegebene `ts`/`lastEventTs` läuft durch `new Date(x).toISOString()` (E9); Limits klemmen statt ablehnen. Routen in `api.js` für Team (Pfadparameter URL-dekodieren, unbekannter Worker ⇒ 404 `Unbekannter Worker.`). +- [ ] **Step 3: Implementieren.** `buildState`: Einstellungen (daraus `paused`, `sendingHalted: Boolean(settings.sendingHalted)`, `sendingHaltedReason: settings.sendingHaltedReason ?? ''`); Aufträge `status ∈ {laeuft, offen}` (`limit 200`); Ereignisse der letzten 30 min (`ts` absteigend, `limit 200`) – „aktuell fehlerhaft“ = jüngstes Ereignis des Workers innerhalb 15 min ist `fehler`; `connected` = `!SEARCH_ROOMS.includes(room) || connectedRooms.includes(room)` (`SEARCH_ROOMS` aus `src/roles.js`, T05; verbindlich P1-E9: Bea, Max, Clara, Nora, Theo, Bob, Bianca, Ben, Paul, Arne und Otto sind nie grau, ein Such-Raum ist es, bis er in `connectedRooms` steht; Einträge in `connectedRooms`, die keine Such-Räume sind, ändern nichts); Status in der Rangfolge `grau` (nicht angeschlossen) > `fehler` > `arbeitet` (Job `laeuft`) > `wartet` (Job `offen`) > `leerlauf`; `task`: `grau` ⇒ `noch nicht angeschlossen`, `fehler` ⇒ Text des Fehler-Ereignisses, `arbeitet` ⇒ Text des jüngsten Ereignisses des Workers (sonst `JOB_LABEL`), `wartet` ⇒ `Wartet auf Start: `, sonst `''`; `JOB_LABEL` = deutsche Substantive für alle 15 Auftragsarten (`suche` → `Suche`, `bericht` → `Bericht`, `alarm_lesen` → `Alarm-Postfach lesen`, …); `counters` aus `getDaily(heute).counters[workerId]` ohne `tokensIn, tokensOut, costUsd, calls`. Kennzahlen: eine einzige Liste bezahlter Angebote aus `workersOffers` (Abfrage nur über den Index `status = 'bezahlt'`, `limit 500`; im Code gefiltert auf `paidAt` im Berliner Monat, also ≥ `berlinStartOfDay(Monatserster)` und < `berlinStartOfDay(Folgemonatserster)`); daraus `dealsMonth` = Länge der Liste, `newAbosMonth` = Anzahl der Einträge, deren `productKey` in der Preistabelle steht, `mrrNewEur` = Summe der Preise `{ hosting: 20 }` (A12; `workersDaily.deals` wird nicht gelesen), `openThreads` = `count` Fäden mit Status offen/wartet_auf_kunde/wartet_auf_uns, `pultOpen` = `count` offen+in_arbeit, `botLeads` = `count` Interessenten `botlead`, `apiTodayUsd`/`apiMonthUsd` aus den Tagesdokumenten (alle Geldwerte auf 6 Nachkommastellen runden). `buildEvents`/`buildTrace` mit den Indizes `ts` bzw. `workerId+ts`; `meta` = geparstes `metaJson` oder `null`; ungültiges `after` (`Date.parse` = NaN) ⇒ `ValidationError('Ungültiges Datum.')`, gültiges wird als `new Date(after).toISOString()` in die Query gegeben; jedes ausgegebene `ts`/`lastEventTs` läuft durch `new Date(x).toISOString()` (E9); Limits klemmen statt ablehnen. Routen in `api.js` für Team (Pfadparameter URL-dekodieren, unbekannter Worker ⇒ 404 `Unbekannter Hustler.`). - [ ] **Step 4: Tests laufen lassen.** Gleicher Befehl und `test/apiAuth.test.js` → PASS (6 + 7 Tests). - [ ] **Step 5: Commit.** `git add src/view.js src/api.js test/view.test.js && git commit -m "P1-T25: API-Sichten state, events, trace" -m "Co-Authored-By: Claude Opus 5.5 "` @@ -1790,7 +1793,7 @@ Die Ergänzungen E1–E11 dieses Plans sind in die Übersicht übernommen: siehe const d = read('Dockerfile'); assert.match(d, /^FROM node:22-alpine$/m); assert.match(d, /npm ci --omit=dev/); assert.match(d, /^USER node$/m); assert.match(d, /chown node:node \/data/); assert.match(d, /^CMD \["node", "src\/main\.js"\]$/m); assert.ok(!/chromium/.test(d.replace(/#.*$/gm, ''))) assert.match(d, /^COPY prompts \.\/prompts$/m); assert.ok(d.indexOf('COPY prompts ./prompts') < d.indexOf('USER node')) // H5: Prompts im Image (der Mount überdeckt sie) }) - test('Serverpuls-Eintrag', () => assert.deepEqual(JSON.parse(read('deploy/serverpuls-apps-eintrag.json')), { app: 'Workers', match: ['webklar-workers*'], folders: ['workers'] })) + test('Serverpuls-Eintrag', () => assert.deepEqual(JSON.parse(read('deploy/serverpuls-apps-eintrag.json')), { app: 'Hustler', match: ['webklar-workers*'], folders: ['workers'] })) // Anzeigename in der Serverpuls-Tabelle (Mitarbeiter sehen ihn); Treffer und Ordner bleiben technisch ``` - [ ] **Step 2: Fehlschlag sehen.** `cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/deploy.test.js` → FAIL `ENOENT … deploy/compose-workers.yml`. - [ ] **Step 3: Bausteine anlegen** (wörtlicher Inhalt): @@ -1858,7 +1861,7 @@ Die Ergänzungen E1–E11 dieses Plans sind in die Übersicht übernommen: siehe ``` dazu im Kommentar der Datei die Zeile `# Unter "volumes:" ergänzen: workers_data:`. Der Mount hängt das Verzeichnis ein (nicht einzelne Dateien), damit auch von `git pull` ersetzte Dateien sichtbar werden; das Image enthält `prompts/` zusätzlich als Rückfall. `deploy/nginx-workers.conf`: ```nginx - # Workers (Kundenfindung, nur Mitarbeiter) - Same-Origin Proxy + # Hustler (Kundenfindung, nur Mitarbeiter) - Same-Origin Proxy location /api/workers/ { resolver 127.0.0.11 valid=30s ipv6=off; set $workers_upstream webklar-workers; @@ -1871,7 +1874,7 @@ Die Ergänzungen E1–E11 dieses Plans sind in die Übersicht übernommen: siehe client_max_body_size 64k; } ``` - `deploy/serverpuls-apps-eintrag.json`: `{ "app": "Workers", "match": ["webklar-workers*"], "folders": ["workers"] }`. + `deploy/serverpuls-apps-eintrag.json`: `{ "app": "Hustler", "match": ["webklar-workers*"], "folders": ["workers"] }`. - [ ] **Step 4: Tests laufen lassen.** `cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/deploy.test.js` → PASS (4 Tests); danach der Gesamtlauf `timeout 300 node --max-old-space-size=1024 --test test/*.test.js` → alle Tests grün, 0 fail. - [ ] **Step 5: Commit.** `git add Dockerfile .dockerignore deploy/compose-workers.yml deploy/nginx-workers.conf deploy/serverpuls-apps-eintrag.json test/deploy.test.js && git commit -m "P1-T27: Container-, nginx- und Serverpuls-Bausteine" -m "Co-Authored-By: Claude Opus 5.5 "` @@ -1893,7 +1896,7 @@ Reihenfolge einhalten. **[Claude]** = Schritt, den der ausführende Agent mit de 3. **[Kenso] Appwrite-Schlüssel.** Appwrite-Konsole → Projekt `6a1058610003c5a13a05` → Integrationen → API-Schlüssel → Name `workers`, kein Ablauf, Bereiche `databases.read/write`, `collections.read/write`, `attributes.read/write`, `indexes.read/write`, `documents.read/write`. Dann: `read -rs -p 'Appwrite-Key workers: ' K; echo; printf 'WORKERS_APPWRITE_API_KEY=%s\n' "$K" >> /home/webklar/apps/.env; unset K` *Prüfen:* `command grep -c '^WORKERS_APPWRITE_API_KEY=.' /home/webklar/apps/.env` → `1`. -4. **[Kenso] Anthropic.** Console: Workspace „Workers“ anlegen, dort Ausgabenlimit 250 $/Monat setzen und einen API-Schlüssel erzeugen (Spec §12). Dann `read -rs -p 'Anthropic-Key Workers: ' K; echo; printf 'WORKERS_ANTHROPIC_API_KEY=%s\n' "$K" >> /home/webklar/apps/.env; unset K`. *Prüfen:* `command grep -c '^WORKERS_ANTHROPIC_API_KEY=.' /home/webklar/apps/.env` → `1`. +4. **[Kenso] Anthropic.** Console: Workspace „Hustler“ anlegen, dort das Ausgabenlimit (Workspace-Ausgabenlimit) auf **150 $/Monat** setzen [Kenso, 17.10.: max. 150 € im Monat; 150 $ ≈ 130 €, also sicher darunter, solange 1 € ≥ 1 $] und einen API-Schlüssel erzeugen (Spec §12). Dann `read -rs -p 'Anthropic-Key Hustler: ' K; echo; printf 'WORKERS_ANTHROPIC_API_KEY=%s\n' "$K" >> /home/webklar/apps/.env; unset K`. *Prüfen:* `command grep -c '^WORKERS_ANTHROPIC_API_KEY=.' /home/webklar/apps/.env` → `1`. 5. **[Kenso] Berichtskonto** (intern, kein Kaltkontakt; Passwort wird aus Serverpuls übernommen, ohne es zu zeigen): ``` cd /home/webklar/apps @@ -1932,7 +1935,7 @@ Reihenfolge einhalten. **[Claude]** = Schritt, den der ausführende Agent mit de ``` *Prüfen:* `docker logs --tail 20 webklar-workers` enthält `workers listening on 8092` und keine Zeile mit dem Wort „fehlt“; `docker stats --no-stream webklar-workers` unter 768 MiB (erwartet < 150 MiB); `docker exec webklar-ticket-integrations wget -qO- http://webklar-workers:8092/api/workers/health` → `{"ok":true,"version":"0.1.0"}`; `docker exec webklar-workers ls /app/prompts` zeigt die 15 `.md`-Dateien (Bind-Mount des Checkouts, schreibgeschützt). Nie ein nacktes `docker compose up -d` (startet Gestopptes). 11. **[Kenso] nginx.** In `/home/webklar/apps/ticket.webklar.com/nginx/ticket.webklar.com.conf` den Inhalt von `deploy/nginx-workers.conf` direkt nach dem Serverpuls-Block (`location /api/serverpuls/ { … }`) einfügen, dann `docker exec webklar-ticket nginx -t && docker exec webklar-ticket nginx -s reload`. *Prüfen:* `curl -s https://ticket.webklar.com/api/workers/health` → `{"ok":true,"version":"0.1.0"}`; `curl -s -o /dev/null -w '%{http_code}\n' https://ticket.webklar.com/api/workers/state` → `401`. -12. **[Kenso] Abnahme Bericht und Nachtplan.** Nach 06:30 Berlin holt der erste Tick den heutigen Bericht sofort nach: innerhalb von 2 Minuten nach dem Start kommt `[Workers] Nachtbericht TT.MM.JJJJ` bei `kenso.gri@gmail.com` an (auch Spam prüfen). Vor 06:30 oder zur Wiederholung: +12. **[Kenso] Abnahme Bericht und Nachtplan.** Nach 06:30 Berlin holt der erste Tick den heutigen Bericht sofort nach: innerhalb von 2 Minuten nach dem Start kommt `Hustler-Nachtbericht TT.MM.JJJJ` bei `kenso.gri@gmail.com` an (auch Spam prüfen). Vor 06:30 oder zur Wiederholung: `timeout 120 docker exec webklar-workers node scripts/jetzt.mjs bericht` und `timeout 120 docker exec webklar-workers node scripts/jetzt.mjs nachtplan` (nur solange `connectedRooms` leer ist). *Erwartet:* die Mail; `nachtplan` druckt `{"planned":[],"skipped":"keine_raeume"}` und erzeugt ein `leerlauf`-Ereignis von Otto in `workersEvents`. Meldet der Bericht `smtp_auth` im Ereignis „Morgenbericht nicht gesendet“: **nicht** wiederholen (der Dienst versucht es erst morgen wieder), Passwort prüfen – wiederholte falsche Logins sperren die Server-IP bei Hetzner. 13. **[Kenso] Abnahme API im Browser** (eingeloggt auf `https://ticket.webklar.com`, Entwicklerkonsole): ```js @@ -1941,9 +1944,9 @@ Reihenfolge einhalten. **[Claude]** = Schritt, den der ausführende Agent mit de ``` *Erwartet:* `state` 200 mit 19 Workern (solange `connectedRooms` leer ist, sind nur die acht Such-Worker Rita, Kim, Pia, Finn, Ali, Hanna, Sven und Lea grau mit `connected: false`; die übrigen elf, darunter Otto, Bea, Max, Clara, Nora, Theo, Bob, Bianca, Ben, Paul und Arne, sind nicht grau; ein Such-Raum wird erst mit seinem Eintrag in `connectedRooms` angeschlossen, P1-E9), `events` 200 mit dem `leerlauf`-Ereignis, `settings` 200 (du bist Admin). Schlägt schon `/v1/account/jwts` fehl, genügt der Test mit der WOMS-Oberfläche in P2. Antwortet die API trotz Login mit `502 Anmeldedienst nicht erreichbar.` oder `401 Sitzung abgelaufen.`, im Compose-Block `APPWRITE_ENDPOINT=https://appwrite.webklar.com/v1` setzen (wie Serverpuls; der interne Endpunkt hat die JWT-Prüfung nicht bestanden) und `docker compose up -d --no-deps workers` wiederholen. 14. **[Kenso] Serverpuls kennt die App.** Eintrag aus `deploy/serverpuls-apps-eintrag.json` in `/home/webklar/apps/serverpuls/config/apps.json` ergänzen (neben „Traefik“), dann - `cd /home/webklar/apps/serverpuls && git add config/apps.json && git commit -m "apps.json: Workers" && cd /home/webklar/apps && docker compose build serverpuls && docker compose up -d --no-deps serverpuls`. *Prüfen:* `docker logs --tail 5 webklar-serverpuls` ohne Fehler; nach dem nächsten Größenlauf (03:00 Berlin) steht „Workers“ in der App-Tabelle der Serverpuls-Seite. + `cd /home/webklar/apps/serverpuls && git add config/apps.json && git commit -m "apps.json: Hustler" && cd /home/webklar/apps && docker compose build serverpuls && docker compose up -d --no-deps serverpuls`. *Prüfen:* `docker logs --tail 5 webklar-serverpuls` ohne Fehler; nach dem nächsten Größenlauf (03:00 Berlin) steht „Hustler“ in der App-Tabelle der Serverpuls-Seite. **Update später:** Code geändert: `cd /home/webklar/apps/workers && git pull --ff-only && cd .. && docker compose build workers && docker compose up -d --no-deps workers`. **Nur Prompts geändert** (`prompts/*.md`): `cd /home/webklar/apps/workers && git pull --ff-only` genügt, ohne Neubau und ohne Neustart; der Dienst liest eine Datei beim nächsten Aufruf neu, sobald sich ihre Änderungszeit geändert hat (Bind-Mount `./workers/prompts:/app/prompts:ro`, H5). Eine leere oder fehlende Prompt-Datei lässt die Aufrufe dieser Rolle mit `Prompt leer: ` bzw. `Prompt fehlt: ` fehlschlagen. **Rückbau:** `docker compose stop workers`; nginx-Sicherung zurückkopieren und `nginx -s reload`; die Daten in Appwrite bleiben (nichts wird gelöscht). -**Fertig, wenn:** Schritte 7, 9, 10, 11, 12 ihre „Erwartet“-Zeilen erfüllen und ab dem nächsten Morgen um 06:30 der Nachtbericht automatisch kommt. In WOMS ist noch nichts zu sehen (kommt mit P2). +**Fertig, wenn:** Schritte 7, 9, 10, 11, 12 ihre „Erwartet“-Zeilen erfüllen und ab dem nächsten Morgen um 06:30 der Hustler-Nachtbericht automatisch kommt. In WOMS ist noch nichts zu sehen (kommt mit P2). diff --git a/docs/superpowers/plans/2026-10-17-workers-p2-hochhaus-woms.md b/docs/superpowers/plans/2026-10-17-workers-p2-hochhaus-woms.md index cec554a..612b669 100644 --- a/docs/superpowers/plans/2026-10-17-workers-p2-hochhaus-woms.md +++ b/docs/superpowers/plans/2026-10-17-workers-p2-hochhaus-woms.md @@ -2,7 +2,7 @@ > **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:** WOMS bekommt unter `/workers` das Pixel-Hochhaus: 10 Etagen, 19 Worker, echte Ereignisse als Animation, Kennzahlen, Worker-Karte mit Spur, Pausenschalter für alle Mitarbeiter und Einstellungen für Admins. +**Goal:** WOMS bekommt unter `/hustler` (Menüpunkt „Hustler“) das Pixel-Hochhaus: 10 Etagen, 19 Worker, echte Ereignisse als Animation, Kennzahlen, Worker-Karte mit Spur, Pausenschalter für alle Mitarbeiter und Einstellungen für Admins. **Architecture:** Eine Plain-Canvas-2D-Engine (ES-Module unter `src/components/workers/engine/`, Layout als Daten, Regie über reine Schritte aus `eventToSteps`) wird vom React-Teil nur eingehängt: `useWorkersFeed` holt Zustand (15 s) und Ereignisse (5 s) über die Dienst-API (Übersicht §6.2) und reicht sie an `mountTower` weiter; Klick auf eine Figur ruft `onSelect(workerId)`, React lädt die Spur. Die Seite wird per `React.lazy` geladen, damit Engine und Schrift nicht im Haupt-Bundle liegen. @@ -27,12 +27,14 @@ - Bewegung und Lebenszyklus: `prefers-reduced-motion` ⇒ keine Schleife, Standbild je Änderung; `visibilitychange` ⇒ `setPaused`; `mountTower`/`destroy` idempotent (React StrictMode: mount, destroy, mount). - Playwright/Chromium nur seriell unter `systemd-run --scope -q -p MemoryMax=700M -p MemorySwapMax=0`, vite-Build unter `MemoryMax=1500M` mit `NODE_OPTIONS=--max-old-space-size=1200`; Ausgabeordner immer neu unter `$TMPDIR`. - Keine `.env`-, Compose- oder nginx-Änderung in P2 (der `/api/workers/`-Block gehört zu P1); P2 schreibt nie nach Appwrite. +- **Anzeigename [Kenso, 17.10.: Name „Hustler“]:** Alles, was Mitarbeiter sehen, heißt „Hustler“, nicht „Workers“: Menüpunkt, Seitentitel (H1), Browser-Tab-Titel, Titel des Einstellungs-Dialogs („Hustler-Einstellungen“), Leerzustände, Hinweise, Fehlertexte (`Der Hustler-Dienst …`, `Hustler-Fehler `) und die Beschriftung der Leinwand. Die Seite liegt unter `/hustler`; `/leads` und `/workers` leiten per `` dorthin um und behalten die Query (`/workers?tab=botleads&bot=` → `/hustler?tab=botleads&bot=`, Übersicht §7). Technische Bezeichner bleiben unverändert: Sammlungen `workers*`, Dienst/Container `webklar-workers`, Repo `knso/workers-dienst`, API `/api/workers/*`, Dateien und Komponenten (`WorkersPage.jsx`, `workersApi.js`, `workersRoster.js` …), CSS-Präfix `wk-`, Umgebungsvariablen `WORKERS_*`, `workerId`, Ereignisarten. Das Wort „Worker“ für eine einzelne Figur oder Rolle bleibt (Übersicht §7, Namensregel). „Workers“ in Commit-Präfixen und Plan-Dateinamen ist das Projekt-Kürzel. +- **Kosten [Kenso, 17.10.: max. 150 € im Monat]:** Anthropic rechnet in USD ab, die Grenzen sind deshalb `monthlyBudgetUsd` 150 (150 $ ≈ 130 €, solange 1 € ≥ 1 $), `dailyBudgetUsd` 6 (Ausreißerschutz, vorher 10) und `warnPct` 80. P2 legt keine Standardwerte fest (die setzt der Dienst, P1); sie stehen hier nur in Test-Fixtures, Beispielwerten und Formulartests. Das harte Limit ist das Ausgabenlimit des Console-Workspaces (150 $/Monat, Betriebsanleitung P1). > **Ergänzungen E1–E7** (Fixture-Form, JWT, Zusatzdateien, `mountTower`-Optionen, API-Formate, Ereignis-`meta`, Reiter/Tagesziel) sind in der Übersicht nachgetragen: siehe Übersicht §11.1, P2-E1 bis P2-E7 (dort auch der Reiter `freigabe`, den P3 anhängt: P3-E11); „E1“ bis „E7“ in diesem Plan meinen diese Zeilen. ## Review Focus -1. Dienst nicht erreichbar oder nginx-Block fehlt (HTML-Seite mit Status 200, 502, Netzfehler) → deutsche Meldung „Der Workers-Dienst antwortet nicht richtig.“ bzw. „… ist gerade nicht erreichbar.“ statt Absturz oder `NaN`; die Seite bleibt bedienbar und fragt weiter ab. Tests: P2-T02 (`kpiTiles` ohne Daten), P2-T04 (Fehlertexte), P2-T19 (Board-Zustände). +1. Dienst nicht erreichbar oder nginx-Block fehlt (HTML-Seite mit Status 200, 502, Netzfehler) → deutsche Meldung „Der Hustler-Dienst antwortet nicht richtig.“ bzw. „… ist gerade nicht erreichbar.“ statt Absturz oder `NaN`; die Seite bleibt bedienbar und fragt weiter ab. Tests: P2-T02 (`kpiTiles` ohne Daten), P2-T04 (Fehlertexte), P2-T19 (Board-Zustände). 2. Tab lange im Hintergrund, danach 200+ Ereignisse auf einmal → keine Animationsflut: höchstens 6 Gruppen laufen, der Rest wirkt still, alle Warteschlangen sind nach ≤ 60 s Simulationszeit leer; der Feed holt seitenweise (200, höchstens 5 je Runde). Tests: P2-T05, P2-T13. 3. Dasselbe Ereignis kommt in zwei Abfragen (gleicher Zeitstempel am Cursor) und die Vorgeschichte vor dem Öffnen der Seite → nie doppelt abgespielt, Vorgeschichte nicht nachgespielt. Tests: P2-T03 (`takeNewEvents`), P2-T05 (Cursor aus dem Snapshot). 4. Raum ohne Quelle (nur die acht Such-Räume aus `SEARCH_ROOMS` können es sein; der Dienst meldet alle anderen Worker von Anfang an als angeschlossen, P1-E9), Worker fehlt im Zustand, unbekannte Ereignisart oder unbekannte `workerId` → grauer Raum mit Schild „noch nicht angeschlossen“, schlafende Figur, keine vorgespielte Arbeit, kein Wurf. Tests: P2-T03, P2-T11, P2-T12, P2-T13. @@ -67,7 +69,7 @@ Alles unter `$WK`. **Neu:** | `tests/unit/helpers/{fakeCtx,workersFixtures,engineVm}.mjs` | Test-Hilfen (aufzeichnender Kontext, 19-Worker-Zustand, vm-Sandbox) | | `tests/unit/workers{Roster,View,Events,Api,Feed,Sprites,Props,Layout,Camera,Fx,Lift,Rooms,Director,Scene,Smoke,SettingsForm,Components,Css,Font,Route,Harness}.test.mjs` | je ein Test pro Baustein | -**Ändern:** `src/App.jsx` (lazy Route, `/leads` → `/workers`), `src/lib/navLinks.js` (Eintrag `workers`), `tests/unit/navLinks.test.mjs`. +**Ändern:** `src/App.jsx` (lazy Route `/hustler`, `/leads` und `/workers` → `/hustler`), `src/lib/navLinks.js` (Eintrag `hustler`), `tests/unit/navLinks.test.mjs`. **Kein Offscreen-Cache** für die Kulisse (Entscheidung: erst messen; `stats().avgDrawMs` wird in P2-T21 geprüft). --- @@ -172,7 +174,7 @@ Alles unter `$WK`. **Neu:** - [ ] **Step 1: Failing test schreiben** (`tests/unit/workersView.test.mjs`, `const V = await loadModule('src/lib/workersView.js')`) ```js const NB = String.fromCharCode(160) // geschütztes Leerzeichen vor $ und € - const K = { newAbosMonth: 3, mrrNewEur: 60, dealsMonth: 3, openThreads: 7, pultOpen: 2, botLeads: 112, apiTodayUsd: 8.5, apiMonthUsd: 50, dailyBudgetUsd: 10, monthlyBudgetUsd: 250 } + const K = { newAbosMonth: 3, mrrNewEur: 60, dealsMonth: 3, openThreads: 7, pultOpen: 2, botLeads: 112, apiTodayUsd: 5.1, apiMonthUsd: 50, dailyBudgetUsd: 6, monthlyBudgetUsd: 150 } // [Kenso, 17.10.: max. 150 € im Monat]: Tagesgrenze 6 $, Monatsgrenze 150 $ test('Zahlenformate', () => { assert.equal(V.formatUsd(7.84), `7,84${NB}$`); assert.equal(V.formatUsd(1234.5), `1.234,50${NB}$`); assert.equal(V.formatUsd(0), `0,00${NB}$`) for (const bad of [null, undefined, NaN, 'x']) assert.equal(V.formatUsd(bad), '–') @@ -194,10 +196,10 @@ Alles unter `$WK`. **Neu:** const t = V.kpiTiles(K) assert.deepEqual(t.map((x) => x.key), ['abos', 'deals', 'threads', 'pult', 'botleads', 'api']) assert.deepEqual(t.map((x) => x.label), ['Neue Abos (Monat)', 'Deals (Monat)', 'Gespräche offen', 'Wartet auf dich', 'Bot-Leads gesamt', 'API heute']) - assert.deepEqual(t.map((x) => x.value), ['3', '3', '7', '2', '112', `8,50${NB}$`]) - assert.equal(t[0].sub, `60${NB}€ neu pro Monat`); assert.equal(t[5].sub, `von 10,00${NB}$`) + assert.deepEqual(t.map((x) => x.value), ['3', '3', '7', '2', '112', `5,10${NB}$`]) + assert.equal(t[0].sub, `60${NB}€ neu pro Monat`); assert.equal(t[5].sub, `von 6,00${NB}$`) assert.deepEqual(t.map((x) => x.tone), ['', '', '', 'warn', '', 'warn']) - assert.equal(V.kpiTiles({ ...K, apiTodayUsd: 10 })[5].tone, 'neg'); assert.equal(V.kpiTiles({ ...K, apiTodayUsd: 7.9 })[5].tone, '') + assert.equal(V.kpiTiles({ ...K, apiTodayUsd: 6 })[5].tone, 'neg'); assert.equal(V.kpiTiles({ ...K, apiTodayUsd: 4.7 })[5].tone, '') assert.equal(V.kpiTiles({ ...K, dailyBudgetUsd: 0 })[5].tone, ''); assert.equal(V.kpiTiles({ ...K, pultOpen: 0 })[3].tone, '') }) test('kpiTiles ohne Daten: Striche, kein NaN (Review Focus 1)', () => { @@ -383,7 +385,7 @@ Alles unter `$WK`. **Neu:** // pult(status): ein Pult-Status, eine Komma-Liste ('offen,in_arbeit', Übersicht §7 und §6.2) oder ein Array; jeder Eintrag (getrimmt) muss in PULT_STATUS stehen, // sonst Error('Unbekannter Pult-Status: ') ohne Anfrage; leer = 'offen'. botleads reicht id schon hier durch (P6 ändert diese Datei nicht mehr). export const PULT_STATUS // ['offen','in_arbeit','gesendet','verworfen','veraltet','erledigt'] - export const DOWN_TEXT, BAD_REPLY_TEXT, SESSION_TEXT, FORBIDDEN_TEXT // 'Der Workers-Dienst ist gerade nicht erreichbar.' · 'Der Workers-Dienst antwortet nicht richtig.' · 'Anmeldung abgelaufen. Bitte neu anmelden.' · 'Dafür fehlt dir die Berechtigung.' + export const DOWN_TEXT, BAD_REPLY_TEXT, SESSION_TEXT, FORBIDDEN_TEXT // 'Der Hustler-Dienst ist gerade nicht erreichbar.' · 'Der Hustler-Dienst antwortet nicht richtig.' · 'Anmeldung abgelaufen. Bitte neu anmelden.' · 'Dafür fehlt dir die Berechtigung.' // workersClient.js export const workersApi // createWorkersApi mit account.createJWT, 10-min-Cache ``` @@ -420,13 +422,13 @@ Alles unter `$WK`. **Neu:** }) test('Fehlertexte (Review Focus 1)', async () => { const run = (reply) => createWorkersApi({ getJwt, fetchImpl: fakeFetch([reply]) }).state().then(() => null, (e) => [e.message, e.status]) - assert.deepEqual(await run({ status: 200, body: undefined }), ['Der Workers-Dienst antwortet nicht richtig.', 200]) // nginx liefert index.html - assert.deepEqual(await run({ status: 404, body: undefined }), ['Der Workers-Dienst antwortet nicht richtig.', 404]) - assert.deepEqual(await run({ status: 502, body: undefined }), ['Der Workers-Dienst ist gerade nicht erreichbar.', 502]) - assert.deepEqual(await run(new TypeError('fetch failed')), ['Der Workers-Dienst ist gerade nicht erreichbar.', 0]) + assert.deepEqual(await run({ status: 200, body: undefined }), ['Der Hustler-Dienst antwortet nicht richtig.', 200]) // nginx liefert index.html + assert.deepEqual(await run({ status: 404, body: undefined }), ['Der Hustler-Dienst antwortet nicht richtig.', 404]) + assert.deepEqual(await run({ status: 502, body: undefined }), ['Der Hustler-Dienst ist gerade nicht erreichbar.', 502]) + assert.deepEqual(await run(new TypeError('fetch failed')), ['Der Hustler-Dienst ist gerade nicht erreichbar.', 0]) assert.deepEqual(await run({ status: 403, body: {} }), ['Dafür fehlt dir die Berechtigung.', 403]) assert.deepEqual(await run({ status: 400, body: { error: 'Tagesgrenze ungültig.' } }), ['Tagesgrenze ungültig.', 400]) - assert.deepEqual(await run({ status: 500, body: {} }), ['Workers-Fehler 500', 500]) + assert.deepEqual(await run({ status: 500, body: {} }), ['Hustler-Fehler 500', 500]) await assert.rejects(createWorkersApi({ getJwt: async () => { throw new Error('x') }, fetchImpl: fakeFetch([]) }).state(), { message: 'Anmeldung abgelaufen. Bitte neu anmelden.', status: 401 }) }) test('401 ⇒ einmal mit frischem JWT wiederholen, danach Meldung', async () => { @@ -459,7 +461,7 @@ Alles unter `$WK`. **Neu:** ``` - [ ] **Step 2: Scheitern sehen** — `cd $WK && timeout 300 node --max-old-space-size=1024 --test tests/unit/workersApi.test.mjs`. Expected: FAIL `Could not resolve …/workersApi.js`. - [ ] **Step 3: Implementieren** - `src/lib/workersApi.js`: ein `request(path, { method, body })` nach Muster `financeRequest.js`: `fetchImpl` wirft ⇒ `DOWN_TEXT` (status 0); `getJwt` wirft ⇒ `SESSION_TEXT` (401); Antwort 401 ⇒ einmal `getJwt({ fresh: true })`, danach erneut 401 ⇒ `SESSION_TEXT`; JSON-Parsefehler ⇒ bei 502/503/504 `DOWN_TEXT`, sonst `BAD_REPLY_TEXT` (mit dem echten Status); 403 ohne `error` ⇒ `FORBIDDEN_TEXT`; sonstiger Fehlerstatus ⇒ `data.error` oder `Workers-Fehler `. Erlaubte `pultAction`-Namen: `claim`, `sent`, `discard`, `done`, `reply`, `approve-preview`, `paste` (`paste` = Antwort des Kunden von einer Plattform einfügen, Körper `{ text }`, P4-T34). `pult(status = 'offen')`: String (an Kommas teilen, Einträge trimmen) oder Array; leer ⇒ `offen`; jeder Eintrag muss in `PULT_STATUS` stehen (`offen`, `in_arbeit`, `gesendet`, `verworfen`, `veraltet`, `erledigt`), sonst `Error('Unbekannter Pult-Status: ')` vor jeder Anfrage; Anfrage `GET /api/workers/pult?status=` (das Komma bleibt unkodiert, die Einträge bestehen nur aus Kleinbuchstaben und `_`). `botleads({ room, q, page, id })`: Query-Reihenfolge `room`, `q`, `page`, `id`, leere Werte fallen weg (P6 braucht `id` für den Ereignislink `/workers?tab=botleads&bot=`). `limit` hat den Standard (200 bzw. 50) nur bei `undefined` (nicht bei 0) und wird auf 1..200 (Ereignisse) bzw. 1..50 (Spur) geklemmt; leere Werte fallen aus der Query. `src/lib/workersClient.js` (nicht unter Node geladen, wie `serverpulsApi.js`): `account.createJWT` + `createJwtCache` (10 min), `getJwt = (o) => { if (o?.fresh) cache.invalidate(); return cache.get() }`, `export const workersApi = createWorkersApi({ getJwt })`. + `src/lib/workersApi.js`: ein `request(path, { method, body })` nach Muster `financeRequest.js`: `fetchImpl` wirft ⇒ `DOWN_TEXT` (status 0); `getJwt` wirft ⇒ `SESSION_TEXT` (401); Antwort 401 ⇒ einmal `getJwt({ fresh: true })`, danach erneut 401 ⇒ `SESSION_TEXT`; JSON-Parsefehler ⇒ bei 502/503/504 `DOWN_TEXT`, sonst `BAD_REPLY_TEXT` (mit dem echten Status); 403 ohne `error` ⇒ `FORBIDDEN_TEXT`; sonstiger Fehlerstatus ⇒ `data.error` oder `Hustler-Fehler `. Erlaubte `pultAction`-Namen: `claim`, `sent`, `discard`, `done`, `reply`, `approve-preview`, `paste` (`paste` = Antwort des Kunden von einer Plattform einfügen, Körper `{ text }`, P4-T34). `pult(status = 'offen')`: String (an Kommas teilen, Einträge trimmen) oder Array; leer ⇒ `offen`; jeder Eintrag muss in `PULT_STATUS` stehen (`offen`, `in_arbeit`, `gesendet`, `verworfen`, `veraltet`, `erledigt`), sonst `Error('Unbekannter Pult-Status: ')` vor jeder Anfrage; Anfrage `GET /api/workers/pult?status=` (das Komma bleibt unkodiert, die Einträge bestehen nur aus Kleinbuchstaben und `_`). `botleads({ room, q, page, id })`: Query-Reihenfolge `room`, `q`, `page`, `id`, leere Werte fallen weg (P6 braucht `id` für den Ereignislink `/hustler?tab=botleads&bot=`). `limit` hat den Standard (200 bzw. 50) nur bei `undefined` (nicht bei 0) und wird auf 1..200 (Ereignisse) bzw. 1..50 (Spur) geklemmt; leere Werte fallen aus der Query. `src/lib/workersClient.js` (nicht unter Node geladen, wie `serverpulsApi.js`): `account.createJWT` + `createJwtCache` (10 min), `getJwt = (o) => { if (o?.fresh) cache.invalidate(); return cache.get() }`, `export const workersApi = createWorkersApi({ getJwt })`. - [ ] **Step 4: Bestehen sehen** — gleicher Befehl. Expected: `# pass 6`, `# fail 0`. - [ ] **Step 5: Commit** ```bash @@ -519,8 +521,8 @@ Alles unter `$WK`. **Neu:** test('keine überlappenden Abrufe, Fehler laufen weiter, Zustand-Auffrischung bei wichtigen Ereignissen', async () => { const hang = makeFeedHarness({ eventsHang: true }); hang.c.start(); await hang.settle(); await hang.advance(60000) assert.equal(hang.log.events.length, 1) - const err = makeFeedHarness({ eventReplies: [new Error('Der Workers-Dienst ist gerade nicht erreichbar.'), []] }); err.c.start(); await err.settle(); await err.advance(5000) - assert.deepEqual(err.log.errors, ['Der Workers-Dienst ist gerade nicht erreichbar.', null]) + const err = makeFeedHarness({ eventReplies: [new Error('Der Hustler-Dienst ist gerade nicht erreichbar.'), []] }); err.c.start(); await err.settle(); await err.advance(5000) + assert.deepEqual(err.log.errors, ['Der Hustler-Dienst ist gerade nicht erreichbar.', null]) const deal = makeFeedHarness({ eventReplies: [[{ ...mk(1), kind: 'deal' }]] }); deal.c.start(); await deal.settle(); assert.equal(deal.log.state, 2) }) test('stop räumt auf, start ist idempotent, späte Antworten werden verworfen', async () => { @@ -931,7 +933,7 @@ Alles unter `$WK`. **Neu:** export function drawAgents(ctx, director, selectedId, t): void // y-sortiert, setzt agent.box, Blasen, Auswahlpfeil, „!“ bei alert ``` Agent: `{ id, name, hat, scale, x, y, floor, facing, frame, pose:'stand'|'type'|'coffee'|'sleep', carrying, stackN, say, bubT, grey, alert, status, task, counters, lane, bob, idleT, cheer, q, cur, stepT, groups, box, home }`. - `tests/unit/helpers/workersFixtures.mjs`: `IDS` (die 19 workerIds in Rollenreihenfolge), `makeState({ statuses = {}, connected = {}, kpis = {}, paused = false, sendingHalted = false, sendingHaltedReason = '', lastEventTs } = {})` ⇒ Zustand (`paused`, `sendingHalted`, `sendingHaltedReason`, `lastEventTs`, wie Übersicht §6.2) mit 19 `workers` (`{ workerId, status: statuses[id] ?? 'arbeitet', task: '', counters: {}, connected: connected[id] ?? true }`; wie im Dienst (P1-E9) sind nur Such-Worker nicht angeschlossen: die Tests setzen `connected: false` ausschließlich für `rita`, `kim`, `pia`, `finn`, `ali`, `hanna`, `sven`, `lea`) und `kpis` (Standard `{ newAbosMonth: 3, mrrNewEur: 60, dealsMonth: 3, openThreads: 7, pultOpen: 2, botLeads: 112, apiTodayUsd: 8.5, apiMonthUsd: 50, dailyBudgetUsd: 10, monthlyBudgetUsd: 250 }`, überschrieben durch `kpis`), `ev(kind, workerId, extra)` ⇒ Ereignis mit eindeutiger `id`, `ts` (zählt je Aufruf um 1 s hoch), `text: 'Text'`. + `tests/unit/helpers/workersFixtures.mjs`: `IDS` (die 19 workerIds in Rollenreihenfolge), `makeState({ statuses = {}, connected = {}, kpis = {}, paused = false, sendingHalted = false, sendingHaltedReason = '', lastEventTs } = {})` ⇒ Zustand (`paused`, `sendingHalted`, `sendingHaltedReason`, `lastEventTs`, wie Übersicht §6.2) mit 19 `workers` (`{ workerId, status: statuses[id] ?? 'arbeitet', task: '', counters: {}, connected: connected[id] ?? true }`; wie im Dienst (P1-E9) sind nur Such-Worker nicht angeschlossen: die Tests setzen `connected: false` ausschließlich für `rita`, `kim`, `pia`, `finn`, `ali`, `hanna`, `sven`, `lea`) und `kpis` (Standard `{ newAbosMonth: 3, mrrNewEur: 60, dealsMonth: 3, openThreads: 7, pultOpen: 2, botLeads: 112, apiTodayUsd: 5.1, apiMonthUsd: 50, dailyBudgetUsd: 6, monthlyBudgetUsd: 150 }` (Kosten: [Kenso, 17.10.: max. 150 € im Monat]), überschrieben durch `kpis`), `ev(kind, workerId, extra)` ⇒ Ereignis mit eindeutiger `id`, `ts` (zählt je Aufruf um 1 s hoch), `text: 'Text'`. - [ ] **Step 1: Failing test schreiben** (`tests/unit/workersDirector.test.mjs`) ```js @@ -1180,11 +1182,11 @@ Alles unter `$WK`. **Neu:** export function validateForm(form): { ok, errors: { [feld]: 'Text' } } export function formToPatch(form, original): object // nur geänderte, getypte Felder; nie READONLY_KEYS ``` - Regeln, gleich oder enger als die Prüfung des Dienstes (P1-T07; Fehlertext in Klammern): `dailyBudgetUsd` Zahl > 0 (`Tagesgrenze muss größer als 0 sein.`) und ≤ `monthlyBudgetUsd` (`Die Tagesgrenze darf nicht über der Monatsgrenze liegen.`); `monthlyBudgetUsd` > 0 (`Monatsgrenze muss größer als 0 sein.`); `warnPct` ganz 1..100 (`Warnschwelle: ganze Zahl von 1 bis 100.`); `mailColdPerDay` ganz 0..3 (`Höchstens 3 unverlangte Mails pro Tag.`); `mailReplyCapPerDay` ganz 0..15 (`Höchstens 15 Antwort-Mails pro Tag.`); `sendWindowStart`/`sendWindowEnd` `HH:MM`, Beginn vor Ende (`Uhrzeit als HH:MM, Beginn vor Ende.`); `runsPerNight` ganz 1..2 (`Läufe pro Nacht: 1 oder 2.`); `candidatesPerRun` ganz 1..20 (`Kandidaten pro Lauf: 1 bis 20.`); `regions` 1..30 Zeilen, je 1..64 Zeichen (`Mindestens eine Region, höchstens 30, je Zeile höchstens 64 Zeichen.`); `previewGate` ∈ `erste_n|immer|nie`; `previewGateN` ganz 0..1000; `offerText` 1..400 Zeichen (`Angebotstext: 1 bis 400 Zeichen.`; 400 hält eine Kaltmail unter 180 Wörtern, H3/A11); `pultStaleDays` ganz 1..30; `followupDays`/`previewReminderDays` ganz 1..60; `retentionDays` ganz 7..90 (`Aufbewahrung: 7 bis 90 Tage.`; der Datenschutztext verspricht höchstens 90 Tage, A11); `previewDeleteDays` ganz 7..365; `reportEmail` einfache Adresse. Dezimalkomma wird akzeptiert (`12,5`). Schreibgeschützt (nie im Patch, nie im Formular bearbeitbar): `READONLY_KEYS`; `connectedRooms` setzt allein der Dienst (P3 pflegt die Anfragen-Räume, Kenso die Branchen-Räume per Skript, P2-E5), `privacyInteressentenLive` der Datenschutz-Schalter (A5), `sendingHalted` und `sendingHaltedReason` die Notbremse (H4; aufgehoben wird sie nur über den Knopf des Dialogs, P2-T18). `offerProductKey` ist ebenfalls nur Anzeige (der Dienst erlaubt nur `hosting`). + Regeln, gleich oder enger als die Prüfung des Dienstes (P1-T07; Fehlertext in Klammern): `dailyBudgetUsd` Zahl > 0 (`Tagesgrenze muss größer als 0 sein.`) und ≤ `monthlyBudgetUsd` (`Die Tagesgrenze darf nicht über der Monatsgrenze liegen.`); `monthlyBudgetUsd` > 0 (`Monatsgrenze muss größer als 0 sein.`) und ≤ 150 (`Monatsgrenze höchstens 150 $ (Vorgabe: max. 150 € im Monat).`); `warnPct` ganz 1..100 (`Warnschwelle: ganze Zahl von 1 bis 100.`); `mailColdPerDay` ganz 0..3 (`Höchstens 3 unverlangte Mails pro Tag.`); `mailReplyCapPerDay` ganz 0..15 (`Höchstens 15 Antwort-Mails pro Tag.`); `sendWindowStart`/`sendWindowEnd` `HH:MM`, Beginn vor Ende (`Uhrzeit als HH:MM, Beginn vor Ende.`); `runsPerNight` ganz 1..2 (`Läufe pro Nacht: 1 oder 2.`); `candidatesPerRun` ganz 1..20 (`Kandidaten pro Lauf: 1 bis 20.`); `regions` 1..30 Zeilen, je 1..64 Zeichen (`Mindestens eine Region, höchstens 30, je Zeile höchstens 64 Zeichen.`); `previewGate` ∈ `erste_n|immer|nie`; `previewGateN` ganz 0..1000; `offerText` 1..400 Zeichen (`Angebotstext: 1 bis 400 Zeichen.`; 400 hält eine Kaltmail unter 180 Wörtern, H3/A11); `pultStaleDays` ganz 1..30; `followupDays`/`previewReminderDays` ganz 1..60; `retentionDays` ganz 7..90 (`Aufbewahrung: 7 bis 90 Tage.`; der Datenschutztext verspricht höchstens 90 Tage, A11); `previewDeleteDays` ganz 7..365; `reportEmail` einfache Adresse. Dezimalkomma wird akzeptiert (`12,5`). Schreibgeschützt (nie im Patch, nie im Formular bearbeitbar): `READONLY_KEYS`; `connectedRooms` setzt allein der Dienst (P3 pflegt die Anfragen-Räume, Kenso die Branchen-Räume per Skript, P2-E5), `privacyInteressentenLive` der Datenschutz-Schalter (A5), `sendingHalted` und `sendingHaltedReason` die Notbremse (H4; aufgehoben wird sie nur über den Knopf des Dialogs, P2-T18). `offerProductKey` ist ebenfalls nur Anzeige (der Dienst erlaubt nur `hosting`). - [ ] **Step 1: Failing test schreiben** (`const F = await loadModule('src/lib/workersSettingsForm.js')`) ```js - const S0 = { paused: true, dailyBudgetUsd: 10, monthlyBudgetUsd: 250, warnPct: 80, mailColdPerDay: 3, mailReplyCapPerDay: 15, sendWindowStart: '09:00', sendWindowEnd: '16:00', sendWeekdaysOnly: true, runsPerNight: 1, candidatesPerRun: 10, + const S0 = { paused: true, dailyBudgetUsd: 6, monthlyBudgetUsd: 150, warnPct: 80, mailColdPerDay: 3, mailReplyCapPerDay: 15, sendWindowStart: '09:00', sendWindowEnd: '16:00', sendWeekdaysOnly: true, runsPerNight: 1, candidatesPerRun: 10, activeRooms: ['netz', 'kleinanzeigen', 'portale', 'foren', 'aerzte', 'handwerk', 'selbst', 'laden'], connectedRooms: [], regions: ['Rheinland-Pfalz'], previewGate: 'erste_n', previewGateN: 10, previewsSent: 4, offerText: 'Gratis-Vorschau …', offerProductKey: 'hosting', pultStaleDays: 3, retentionDays: 90, followupDays: 7, previewReminderDays: 5, previewDeleteDays: 60, reportEmail: 'kenso.gri@gmail.com', privacyInteressentenLive: false, sendingHalted: false, sendingHaltedReason: '', updatedAt: '2026-10-17T10:00:00Z', updatedBy: 'x' } @@ -1204,7 +1206,8 @@ Alles unter `$WK`. **Neu:** test('Prüfung mit den Spec-Grenzen', () => { const bad = (over) => F.validateForm({ ...F.settingsToForm(S0), ...over }).errors assert.equal(bad({ mailColdPerDay: '4' }).mailColdPerDay, 'Höchstens 3 unverlangte Mails pro Tag.'); assert.equal(bad({ mailReplyCapPerDay: '16' }).mailReplyCapPerDay, 'Höchstens 15 Antwort-Mails pro Tag.') - assert.equal(bad({ dailyBudgetUsd: '300' }).dailyBudgetUsd, 'Die Tagesgrenze darf nicht über der Monatsgrenze liegen.'); assert.equal(bad({ dailyBudgetUsd: '0' }).dailyBudgetUsd, 'Tagesgrenze muss größer als 0 sein.') + assert.equal(bad({ dailyBudgetUsd: '151' }).dailyBudgetUsd, 'Die Tagesgrenze darf nicht über der Monatsgrenze liegen.'); assert.equal(bad({ monthlyBudgetUsd: '151' }).monthlyBudgetUsd, 'Monatsgrenze höchstens 150 $ (Vorgabe: max. 150 € im Monat).'); assert.equal(bad({ dailyBudgetUsd: '150' }).dailyBudgetUsd, undefined) // Monatsgrenze 150 aus S0 + assert.equal(bad({ dailyBudgetUsd: '0' }).dailyBudgetUsd, 'Tagesgrenze muss größer als 0 sein.') assert.equal(bad({ sendWindowStart: '16:00', sendWindowEnd: '09:00' }).sendWindowStart, 'Uhrzeit als HH:MM, Beginn vor Ende.'); assert.equal(bad({ regions: ' \n' }).regions, 'Mindestens eine Region, höchstens 30, je Zeile höchstens 64 Zeichen.') assert.equal(bad({ offerText: '' }).offerText, 'Angebotstext: 1 bis 400 Zeichen.'); assert.equal(bad({ offerText: 'x'.repeat(401) }).offerText, 'Angebotstext: 1 bis 400 Zeichen.'); assert.equal(bad({ offerText: 'x'.repeat(400) }).offerText, undefined) assert.equal(bad({ retentionDays: '5' }).retentionDays, 'Aufbewahrung: 7 bis 90 Tage.'); assert.equal(bad({ retentionDays: '6' }).retentionDays, 'Aufbewahrung: 7 bis 90 Tage.'); assert.equal(bad({ retentionDays: '91' }).retentionDays, 'Aufbewahrung: 7 bis 90 Tage.') @@ -1279,7 +1282,7 @@ Alles unter `$WK`. **Neu:** **Interfaces:** - Consumes: `Dialog` (`../ui/Dialog`), `Field` (`../ui/Field`), `Button` (`../ui/Button`), `settingsToForm`, `validateForm`, `formToPatch`, `ROOM_CHOICES` (P2-T16), `api.settings()`, `api.saveSettings(patch)`. -- Produces: `export default function WorkersSettingsDialog({ api, onClose, onSaved })` — lädt die Einstellungen beim Öffnen („Wird geladen …“), zeigt Gruppen **Kosten** (Tagesgrenze (USD), Monatsgrenze (USD), Warnschwelle (%)), **Mails** (Unverlangte Mails pro Tag, Antwort-Mails pro Tag, Sendefenster von/bis, nur werktags), **Läufe** (Läufe pro Nacht, Kandidaten pro Lauf), **Räume** (je Suchraum ein Haken „aktiv“; darunter als reiner Text „Angeschlossen (meldet der Dienst): …“ aus `settings.connectedRooms`, nicht änderbar), **Regionen** (Textfeld, eine je Zeile), **Vorschau** (Freigabe: erste N / immer / nie, N, bisher verschickt als Text), **Angebot** (Angebotstext, Produkt als Text ohne Eingabe), **Fristen** (Pult veraltet nach, Aufbewahrung, Nachfassen nach, Erinnerung nach, Vorschau löschen nach, Bericht an). „Speichern“ ist gesperrt, solange der Patch leer ist oder Fehler bestehen; Fehler stehen am Feld (`aria-invalid`, Text darunter), Serverfehler als `
`; nach Erfolg `onSaved(neueEinstellungen)` und `onClose()`. Der Dialog kennt `paused` nicht. +- Produces: `export default function WorkersSettingsDialog({ api, onClose, onSaved })` — Dialogtitel „Hustler-Einstellungen“ (Übersicht §7, Namensregel); lädt die Einstellungen beim Öffnen („Wird geladen …“), zeigt Gruppen **Kosten** (Tagesgrenze (USD), Monatsgrenze (USD), Warnschwelle (%)), **Mails** (Unverlangte Mails pro Tag, Antwort-Mails pro Tag, Sendefenster von/bis, nur werktags), **Läufe** (Läufe pro Nacht, Kandidaten pro Lauf), **Räume** (je Suchraum ein Haken „aktiv“; darunter als reiner Text „Angeschlossen (meldet der Dienst): …“ aus `settings.connectedRooms`, nicht änderbar), **Regionen** (Textfeld, eine je Zeile), **Vorschau** (Freigabe: erste N / immer / nie, N, bisher verschickt als Text), **Angebot** (Angebotstext, Produkt als Text ohne Eingabe), **Fristen** (Pult veraltet nach, Aufbewahrung, Nachfassen nach, Erinnerung nach, Vorschau löschen nach, Bericht an). „Speichern“ ist gesperrt, solange der Patch leer ist oder Fehler bestehen; Fehler stehen am Feld (`aria-invalid`, Text darunter), Serverfehler als `
`; nach Erfolg `onSaved(neueEinstellungen)` und `onClose()`. Der Dialog kennt `paused` nicht. **Block „Versand gestoppt“ (H4, A21):** Ist `settings.sendingHalted` wahr, steht oben im Dialog (über den Gruppen) ein Block mit der Überschrift „Versand gestoppt“, dem Grund aus `settings.sendingHaltedReason` als Textknoten (React-Text, nie HTML; ist er leer: „Grund nicht angegeben“) und dem Knopf „Versand wieder freigeben“. Der Knopf ruft `api.saveSettings({ sendingHalted: false })` (genau dieses eine Feld; der Dienst leert dabei den Grund) und danach `onSaved(neueEinstellungen)`; der Dialog bleibt offen, mischt die Antwort in sein `settings` ein (`{ ...settings, ...antwort }`; der Block verschwindet, weil `sendingHalted` nun falsch ist) und behält die Eingaben im Formular. Während des Aufrufs ist der Knopf gesperrt; ein Fehler steht als `
` im Block. Der Block liegt **außerhalb** von `formToPatch`/`validateForm`: „Speichern“ sendet ihn nie, und das Freigeben ändert den Patch-Zustand des Formulars nicht. - [ ] **Step 1: Failing Test ergänzen** @@ -1288,7 +1291,7 @@ Alles unter `$WK`. **Neu:** const s = read('WorkersSettingsDialog.jsx') assert.match(s, /from '\.\.\/ui\/Dialog'/); assert.match(s, /formToPatch\(/); assert.match(s, /validateForm\(/); assert.match(s, /api\.saveSettings\(/) assert.doesNotMatch(s, /paused/) - for (const t of ['Tagesgrenze (USD)', 'Monatsgrenze (USD)', 'Unverlangte Mails pro Tag', 'Angebotstext', 'Regionen', 'Speichern', 'Abbrechen']) assert.ok(s.includes(t), t) + for (const t of ['Hustler-Einstellungen', 'Tagesgrenze (USD)', 'Monatsgrenze (USD)', 'Unverlangte Mails pro Tag', 'Angebotstext', 'Regionen', 'Speichern', 'Abbrechen']) assert.ok(s.includes(t), t) assert.match(s, /disabled=\{/) // H4/A21: Block „Versand gestoppt“ mit Freigabe, außerhalb des Formular-Patches assert.match(s, /sendingHalted/); assert.ok(s.includes('Versand gestoppt')); assert.ok(s.includes('Versand wieder freigeben')) @@ -1323,8 +1326,8 @@ Alles unter `$WK`. **Neu:** // WorkersBoard: Inhalt des Reiters „Hochhaus“, ohne Auth-Bezug export default function WorkersBoard({ api, isAdmin, stageRef }) // stageRef optional (nur der Prüfstand reicht eine durch) ``` - **Stage:** `
` plus `wk-zoom` mit „+“, „−“, „Ganz“. Mount-Effekt (Abhängigkeit: `reduceMotion`): `mountTower(canvas, { onSelect: (id) => onSelectRef.current(id), reducedMotion, coarsePointer: matchMedia('(pointer: coarse)').matches, fontsReady: document.fonts?.load('600 12px "Pixelify Sans"'), dpr: devicePixelRatio })`, `ResizeObserver` ⇒ `resize()`, `visibilitychange` ⇒ `setPaused(document.hidden)`, `matchMedia('(prefers-reduced-motion: reduce)')`-`change` ⇒ Zustand `reduceMotion` (Neu-Mount), Cleanup entfernt alles und ruft `destroy()`. Zweiter Effekt `[state, engineKey]` ⇒ `handle.setState(state)`, dritter `[selectedId]` ⇒ `handle.select(selectedId ?? null)`. - **Board:** Ohne übergebenes `stageRef` (die echte Seite übergibt keins) legt das Board selbst eins an: `const ownRef = useRef(null); const ref = stageRef ?? ownRef`; `` und alle Zugriffe laufen über `ref` (sonst stürzt die Seite beim ersten Ereignis ab, der Prüfstand würde es nicht zeigen, weil er ein `stageRef` übergibt). `useWorkersFeed(api, { onEvents })`; `onEvents` ⇒ `setWorkers(w => reduceEvents(w, evs))` und `ref.current?.pushEvents(evs)`; bei neuem `state` `setWorkers(indexWorkers(state))`; `useWorkerTrace(api, selectedId)`. Zustände: nie geladen + Fehler ⇒ `EmptyState title="Der Workers-Dienst ist noch nicht erreichbar"` mit Fehlertext und „Neu laden“ (Review Focus 1); nie geladen ohne Fehler ⇒ `
Wird geladen …
`; geladen mit Fehler ⇒ Bühne weiter sichtbar plus `
Verbindung gestört: … Es wird weiter versucht.
`. Kopfzeile `wk-toolbar`: Statustext („Läuft“ / „Angehalten: Es wird nichts gesendet und nichts gebaut.“), Pausenknopf `pauseLabel(state.paused)` für alle Mitarbeiter (sperrt während des Aufrufs; Fehler als `msg err`; danach `refresh()`), `isAdmin` ⇒ „Einstellungen“ (öffnet den Dialog). **Notbremse (H4, A21):** Ist `state.sendingHalted` wahr, steht zwischen Kopfzeile und `wk-main` (für alle Mitarbeiter, nicht nur Admins) eine Zeile `
` mit dem Text „Versand gestoppt: . Ein Admin kann ihn in den Einstellungen freigeben.“; `` = `state.sendingHaltedReason` als Textknoten, getrimmt und ohne abschließende Punkte (damit kein „..“ entsteht), leer ⇒ „Grund nicht angegeben“. Sie erscheint zusätzlich zu `msg warn` (Verbindung gestört), ersetzt es nicht, und verschwindet mit dem nächsten `state`, in dem `sendingHalted` falsch ist; `WorkersSettingsDialog` bekommt `onSaved={() => refresh()}`, damit die Zeile nach dem Freigeben sofort verschwindet. Layout: `wk-main` mit Bühne links und Seitenspalte (`WorkerCard`, darunter `WorkerList`) rechts. + **Stage:** `
` plus `wk-zoom` mit „+“, „−“, „Ganz“. Mount-Effekt (Abhängigkeit: `reduceMotion`): `mountTower(canvas, { onSelect: (id) => onSelectRef.current(id), reducedMotion, coarsePointer: matchMedia('(pointer: coarse)').matches, fontsReady: document.fonts?.load('600 12px "Pixelify Sans"'), dpr: devicePixelRatio })`, `ResizeObserver` ⇒ `resize()`, `visibilitychange` ⇒ `setPaused(document.hidden)`, `matchMedia('(prefers-reduced-motion: reduce)')`-`change` ⇒ Zustand `reduceMotion` (Neu-Mount), Cleanup entfernt alles und ruft `destroy()`. Zweiter Effekt `[state, engineKey]` ⇒ `handle.setState(state)`, dritter `[selectedId]` ⇒ `handle.select(selectedId ?? null)`. + **Board:** Ohne übergebenes `stageRef` (die echte Seite übergibt keins) legt das Board selbst eins an: `const ownRef = useRef(null); const ref = stageRef ?? ownRef`; `` und alle Zugriffe laufen über `ref` (sonst stürzt die Seite beim ersten Ereignis ab, der Prüfstand würde es nicht zeigen, weil er ein `stageRef` übergibt). `useWorkersFeed(api, { onEvents })`; `onEvents` ⇒ `setWorkers(w => reduceEvents(w, evs))` und `ref.current?.pushEvents(evs)`; bei neuem `state` `setWorkers(indexWorkers(state))`; `useWorkerTrace(api, selectedId)`. Zustände: nie geladen + Fehler ⇒ `EmptyState title="Der Hustler-Dienst ist noch nicht erreichbar"` mit Fehlertext und „Neu laden“ (Review Focus 1); nie geladen ohne Fehler ⇒ `
Wird geladen …
`; geladen mit Fehler ⇒ Bühne weiter sichtbar plus `
Verbindung gestört: … Es wird weiter versucht.
`. Kopfzeile `wk-toolbar`: Statustext („Läuft“ / „Angehalten: Es wird nichts gesendet und nichts gebaut.“), Pausenknopf `pauseLabel(state.paused)` für alle Mitarbeiter (sperrt während des Aufrufs; Fehler als `msg err`; danach `refresh()`), `isAdmin` ⇒ „Einstellungen“ (öffnet den Dialog). **Notbremse (H4, A21):** Ist `state.sendingHalted` wahr, steht zwischen Kopfzeile und `wk-main` (für alle Mitarbeiter, nicht nur Admins) eine Zeile `
` mit dem Text „Versand gestoppt: . Ein Admin kann ihn in den Einstellungen freigeben.“; `` = `state.sendingHaltedReason` als Textknoten, getrimmt und ohne abschließende Punkte (damit kein „..“ entsteht), leer ⇒ „Grund nicht angegeben“. Sie erscheint zusätzlich zu `msg warn` (Verbindung gestört), ersetzt es nicht, und verschwindet mit dem nächsten `state`, in dem `sendingHalted` falsch ist; `WorkersSettingsDialog` bekommt `onSaved={() => refresh()}`, damit die Zeile nach dem Freigeben sofort verschwindet. Layout: `wk-main` mit Bühne links und Seitenspalte (`WorkerCard`, darunter `WorkerList`) rechts. - [ ] **Step 1: Failing Quelltext-Test ergänzen** ```js @@ -1335,7 +1338,7 @@ Alles unter `$WK`. **Neu:** }) test('WorkersBoard: Zustände und Rechte (Review Focus 1)', () => { const s = read('WorkersBoard.jsx') - assert.match(s, /Der Workers-Dienst ist noch nicht erreichbar/); assert.match(s, /Verbindung gestört/); assert.match(s, /reduceEvents\(/); assert.match(s, /pauseLabel\(/) + assert.match(s, /Der Hustler-Dienst ist noch nicht erreichbar/); assert.match(s, /Verbindung gestört/); assert.match(s, /reduceEvents\(/); assert.match(s, /pauseLabel\(/) assert.match(s, /isAdmin &&/); assert.match(s, /stageRef\s*(\?\?|\|\|)/); assert.match(s, /useRef\(/); assert.doesNotMatch(s, /useAuth|appwrite/) // H4/A21: Notbremsen-Zeile für alle, Grund als Textknoten assert.match(s, /state\.sendingHalted/); assert.match(s, /state\.sendingHaltedReason/); assert.match(s, /className="msg err"/); assert.match(s, /role="alert"/) @@ -1363,24 +1366,27 @@ Alles unter `$WK`. **Neu:** **Interfaces:** - Consumes: `WorkersBoard` (P2-T19), `workersApi` (P2-T04), `PageHeader`/`Tabs` (`../components/ui`), `pickTab` (P2-T02), `useAuth().isAdmin`. -- Produces: `export default function WorkersPage()` (Titel „Workers“, Untertitel „Die Nachtschicht deiner Claude-Armee“; `const api = workersApi`; `WORKERS_TABS = [{ id: 'hochhaus', label: 'Hochhaus' }]` (P3 hängt `freigabe` an und passt `workersRoute.test.mjs` an, P3-E11; P6 hängt `botleads` an; kein Platzhalter-Reiter, P2-E7), Auswahl über `?tab=` mit `pickTab`, `Tabs` nur bei `WORKERS_TABS.length > 1`; Reiter `hochhaus` rendert `` ohne `stageRef`); Route `/workers` (lazy), `/leads` leitet auf `/workers` um; Menüeintrag `{ key: 'workers', label: 'Workers', href: '/workers' }` an der Stelle von `leads`, ohne `paused`. +- Produces: `export default function WorkersPage()` (Titel „Hustler“ als H1 und als Browser-Tab-Titel (`document.title = 'Hustler'` in einem Effekt, beim Verlassen der Seite wird der vorige Titel zurückgesetzt), Untertitel „Die Nachtschicht deiner Claude-Armee“; `const api = workersApi`; `WORKERS_TABS = [{ id: 'hochhaus', label: 'Hochhaus' }]` (P3 hängt `freigabe` an und passt `workersRoute.test.mjs` an, P3-E11; P6 hängt `botleads` an; kein Platzhalter-Reiter, P2-E7), Auswahl über `?tab=` mit `pickTab`, `Tabs` nur bei `WORKERS_TABS.length > 1`; Reiter `hochhaus` rendert `` ohne `stageRef`); Route `/hustler` (lazy); `/leads` und `/workers` leiten über die kleine Komponente `HustlerRedirect` (``, `search` aus `useLocation()`) auf `/hustler` um und behalten die Query; Menüeintrag `{ key: 'hustler', label: 'Hustler', href: '/hustler' }` an der Stelle von `leads`, ohne `paused`. - [ ] **Step 1: Failing Tests schreiben** - `tests/unit/navLinks.test.mjs` anpassen: in beiden `deepEqual`-Listen `'leads'` durch `'workers'` ersetzen und die Zeile `assert.equal(links.find((l) => l.key === 'leads').paused, true)` durch + `tests/unit/navLinks.test.mjs` anpassen: in beiden `deepEqual`-Listen `'leads'` durch `'hustler'` ersetzen (an derselben Stelle; die Schlüssel `leads` und `workers` kommen nicht mehr vor) und die Zeile `assert.equal(links.find((l) => l.key === 'leads').paused, true)` durch ```js - assert.deepEqual(links.find((l) => l.key === 'workers'), { key: 'workers', label: 'Workers', href: '/workers' }) + assert.deepEqual(links.find((l) => l.key === 'hustler'), { key: 'hustler', label: 'Hustler', href: '/hustler' }) + assert.ok(!links.some((l) => l.key === 'leads' || l.key === 'workers')) ``` `tests/unit/workersRoute.test.mjs` (Quelltext von `src/App.jsx`): ```js - test('App: /workers lazy hinter ProtectedRoute, /leads leitet um, kein statischer Import', () => { + test('App: /hustler lazy hinter ProtectedRoute, /leads und /workers leiten mit erhaltener Query auf /hustler um, kein statischer Import', () => { const s = readFileSync(new URL('../../src/App.jsx', import.meta.url), 'utf8') assert.match(s, /const WorkersPage = lazy\(\(\) => import\('\.\/pages\/WorkersPage'\)\)/); assert.doesNotMatch(s, /import WorkersPage from/) - assert.match(s, /<\/Suspense><\/ProtectedRoute>\} \/>/) - assert.match(s, /\} \/>/) - assert.ok(s.indexOf('path="/workers"') < s.indexOf('path="*"')) + assert.match(s, /<\/Suspense><\/ProtectedRoute>\} \/>/) + assert.match(s, /\} \/>/); assert.match(s, /\} \/>/) + assert.match(s, /function HustlerRedirect\(\)[\s\S]*?useLocation\(\)[\s\S]*?/) // /workers?tab=botleads&bot=x → /hustler?tab=botleads&bot=x + assert.ok(s.indexOf('path="/hustler"') < s.indexOf('path="*"')); assert.ok(s.indexOf('path="/workers"') < s.indexOf('path="*"')) }) - test('WorkersPage: Reiter hochhaus (kein Platzhalter), Reiterleiste erst ab zwei Reitern, api-Instanz, Admin-Weitergabe', () => { + test('WorkersPage: Titel Hustler (H1 und Tab-Titel), Reiter hochhaus (kein Platzhalter), Reiterleiste erst ab zwei Reitern, api-Instanz, Admin-Weitergabe', () => { const s = readFileSync(new URL('../../src/pages/WorkersPage.jsx', import.meta.url), 'utf8') + assert.match(s, /title="Hustler"/); assert.match(s, /document\.title = 'Hustler'/) assert.match(s, /id: 'hochhaus'/); assert.doesNotMatch(s, /id: 'freigabe'/); assert.match(s, /WORKERS_TABS\.length > 1/) assert.match(s, /const api = workersApi/); assert.match(s, /pickTab\(/); assert.match(s, /isAdmin=\{isAdmin\}/) }) @@ -1410,12 +1416,12 @@ Alles unter `$WK`. **Neu:** - [ ] **Step 3: Implementieren** (a) **Schrift besorgen** (einziger Netzschritt des Plans): neuen Ordner `D=$(mktemp -d "$TMPDIR/wk-font.XXXXXX")`; `curl -sfL --max-time 30 -A 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/120 Safari/537.36' 'https://fonts.googleapis.com/css2?family=Pixelify+Sans:wght@400..700&display=swap' -o "$D/pixelify.css"`; die Adresse aus dem `/* latin */`-Block lesen mit `rg -A6 '^/\* latin \*/$' "$D/pixelify.css" | rg -o 'https://fonts\.gstatic\.com/[^)]+\.woff2'` (genau ein Treffer) und mit `curl -sfL --max-time 60 -o "$D/PixelifySans-Variable.woff2" ''` laden; den Lizenztext (Open Font License 1.1 mit Copyright-Zeile „Copyright 2021 The Pixelify Sans Project Authors“) mit `curl -sfL --max-time 30 -o "$D/OFL-PixelifySans.txt" 'https://raw.githubusercontent.com/google/fonts/main/ofl/pixelifysans/OFL.txt'`; prüfen: `head -c 4 "$D/PixelifySans-Variable.woff2"` ist `wOF2`, `wc -c` zwischen 10 000 und 400 000, die Lizenz enthält „SIL OPEN FONT LICENSE Version 1.1“; erst dann `mkdir -p "$WK/public/fonts"` (der Ordner existiert noch nicht) und beide Dateien dorthin kopieren. Scheitert der Download (kein Netz), meldet der Implementierer BLOCKED mit dem Befehl, statt eine andere Quelle zu raten. (b) `workers.css`: `@font-face { font-family: "Pixelify Sans"; src: url("/fonts/PixelifySans-Variable.woff2") format("woff2"); font-weight: 400 700; font-style: normal; font-display: swap; }`, Klassen der Komponenten (weiß, Haarlinien, `tabular-nums`, `.wk-main { display: grid; grid-template-columns: minmax(0, 1fr) 320px; gap: 32px }` mit `border-left: 1px solid var(--line)` an `.wk-side`, `.wk-canvas { display: block; width: 100%; aspect-ratio: 5 / 4; image-rendering: pixelated; cursor: pointer; touch-action: pan-y }`, `.wk-name { font-family: "Pixelify Sans", var(--font) }`, Chips mit Tokenpaaren `--orange`, `--blue`, `--green`, `--violet`), unter 1100 px einspaltig (Seitenspalte unten, Haarlinie oben), unter 860 px Zoomknöpfe größer (min. 36 px), `@media (prefers-reduced-motion: reduce)` ohne Übergänge; keine `@keyframes`, keine Kommentare mit geschweiften Klammern. - (c) `WorkersPage.jsx` (Muster `LegalPage.jsx`, `useSearchParams` für `?tab=` und `setSearchParams` beim Reiterwechsel, `page wk-page`, `` nur bei `WORKERS_TABS.length > 1`), `App.jsx`: `import { lazy, Suspense, … } from 'react'`, `const WorkersPage = lazy(() => import('./pages/WorkersPage'))`, Route wie im Test (Fallback `
Wird geladen …
`), `/leads` ⇒ `/workers`; `navLinks.js`: Zeile `leads` ersetzen durch `{ key: 'workers', label: 'Workers', href: '/workers' }` (Kommentar „Leads auf Eis“ entfällt). + (c) `WorkersPage.jsx` (Muster `LegalPage.jsx`, `useSearchParams` für `?tab=` und `setSearchParams` beim Reiterwechsel, `page wk-page`, `` nur bei `WORKERS_TABS.length > 1`), `App.jsx`: `import { lazy, Suspense, … } from 'react'`, `useLocation` aus `react-router-dom` ergänzen, `const WorkersPage = lazy(() => import('./pages/WorkersPage'))`, Route `/hustler` wie im Test (Fallback `
Wird geladen …
`), `function HustlerRedirect() { const { search } = useLocation(); return }` und die Routen `/leads` und `/workers` ⇒ `` (beide vor `path="*"`; die Route `/leads` hat bisher auf `/tickets` verwiesen); `navLinks.js`: Zeile `leads` ersetzen durch `{ key: 'hustler', label: 'Hustler', href: '/hustler' }` (Kommentar „Leads auf Eis“ entfällt). - [ ] **Step 4: Bestehen sehen** — gleicher Befehl wie Step 2. Expected: `# fail 0`. - [ ] **Step 5: Commit** ```bash cd $WK && git add src/pages/WorkersPage.jsx src/styles/workers.css public/fonts/PixelifySans-Variable.woff2 public/fonts/OFL-PixelifySans.txt src/App.jsx src/lib/navLinks.js tests/unit/navLinks.test.mjs tests/unit/workersRoute.test.mjs tests/unit/workersCss.test.mjs tests/unit/workersFont.test.mjs - git commit -m "Workers P2: Seite /workers (lazy), Menü statt Leads, Stil und Schrift Pixelify Sans" -m "Co-Authored-By: Claude Opus 5.5 " + git commit -m "Workers P2: Seite /hustler (lazy), Menü „Hustler“ statt Leads, Stil und Schrift Pixelify Sans" -m "Co-Authored-By: Claude Opus 5.5 " ``` --- @@ -1464,7 +1470,7 @@ Alles unter `$WK`. **Neu:** 2. Desktop 1280×900: 19 Knöpfe `[data-wk-worker]`; Klick auf jeden ⇒ `.wk-name` zeigt den Namen aus `ROLES`, `.wk-chip` den vollen Modellnamen; 3. sechs Figuren (otto, kim, rita, bea, bob, arne): `__wk.stage.current.locate(id)` liefert CSS-Pixel relativ zur Leinwand, geklickt wird an `boundingBox(.wk-canvas)` plus diese Werte ⇒ Karte zeigt dieselbe Figur; 4. Wahl von `kim`: `.wk-trace a[href^="javascript:"]` hat 0 Treffer, die https-Zeile ist ein Link mit `rel="noopener noreferrer"` und `target="_blank"`, die `javascript:`-Zeile steht als Text; - 5. Pausenknopf: Text wechselt auf „Fortsetzen“ und zurück auf „Alles anhalten“; „Einstellungen“ öffnet einen Dialog mit „Tagesgrenze (USD)“, „Abbrechen“ schließt ihn; + 5. Pausenknopf: Text wechselt auf „Fortsetzen“ und zurück auf „Alles anhalten“; „Einstellungen“ öffnet einen Dialog mit dem Titel „Hustler-Einstellungen“ und dem Feld „Tagesgrenze (USD)“, „Abbrechen“ schließt ihn; 6. nach 20 s Skriptlauf: `stats().avgDrawMs` ≤ 25 (nur Warnung ausgeben, kein Abbruch, da software-gerendert), `debug().pending` ≥ 0; 7. Handy 390×844 (DPR 2): `document.documentElement.scrollWidth <= 390`, Zoom „+“ erhöht `stats().zoom`; 8. `page.emulateMedia({ reducedMotion: 'reduce' })` + Neuladen: nach 2 s `stats().frames <= 5`; @@ -1509,13 +1515,13 @@ Alles unter `$WK`. **Neu:** ## Betriebsanleitung (Kenso) -P2 braucht **keine** `.env`-, Compose- oder nginx-Änderung. Der Block `location /api/workers/` und der Container `webklar-workers` gehören zur Betriebsanleitung von P1; ohne sie zeigt die Seite „Der Workers-Dienst ist noch nicht erreichbar“ (gewollt, kein Absturz). +P2 braucht **keine** `.env`-, Compose- oder nginx-Änderung. Der Block `location /api/workers/` und der Container `webklar-workers` gehören zur Betriebsanleitung von P1; ohne sie zeigt die Seite „Der Hustler-Dienst ist noch nicht erreichbar“ (gewollt, kein Absturz). 1. **P1 prüfen (empfohlen vor P2):** `curl -s -o /dev/null -w '%{http_code}\n' https://ticket.webklar.com/api/workers/health` ⇒ `200`; `curl -s -o /dev/null -w '%{http_code}\n' https://ticket.webklar.com/api/workers/state` ⇒ `401` (ohne JWT). Anders ⇒ erst P1 in Betrieb nehmen; WOMS kann trotzdem schon live gehen. 2. **Stand merken:** `git -C /home/webklar/apps/ticket.webklar.com/dev-workers fetch origin && git -C /home/webklar/apps/ticket.webklar.com/dev-workers rev-parse origin/test` ⇒ SHA als `ALT` notieren (für das Zurücknehmen). Danach `git -C /home/webklar/apps/ticket.webklar.com/dev-workers merge-base --is-ancestor origin/test HEAD && echo OK`: Kommt kein `OK`, hat `test` inzwischen neue Commits; dann **nicht pushen und nie erzwingen**, sondern Claude bitten, `feature/workers` auf `origin/test` zu rebasen und Schritt 3 zu wiederholen. 3. **Letzter Test:** `cd /home/webklar/apps/ticket.webklar.com/dev-workers && free -m && timeout 600 node --max-old-space-size=1024 --test tests/*.test.mjs tests/unit/*.test.mjs` ⇒ `# fail 0`. 4. **Live schalten (Push):** `git -C /home/webklar/apps/ticket.webklar.com/dev-workers push origin feature/workers:test`. Der Deploy-Webhook baut in 1–3 Minuten. Prüfen: `curl -sI https://ticket.webklar.com/fonts/PixelifySans-Variable.woff2 | head -3` ⇒ `HTTP/… 200`; das Build-Log des Ticket-Build-Containers (Name mit `docker ps --format '{{.Names}}' | rg -i ticket` finden, Muster `webklar-ticket-build`; `docker logs --tail 40 `) endet ohne Fehler. -5. **Im Browser (als Mitarbeiter):** Menü zeigt „Workers“ statt des grauen „Leads“; `https://ticket.webklar.com/leads` springt auf `/workers`; die Seite zeigt die sechs Kennzahlen, das Hochhaus mit 10 Etagen und 19 Figuren, ein Klick auf eine Figur öffnet rechts die Karte (Name, Modell, Zähler, Spur); es gibt vorerst nur den Bereich „Hochhaus“ ohne Reiterleiste (der Reiter „Freigabe“ kommt mit P3, dort P3-E11); auf dem Handy: Zoom-Knöpfe, kein seitliches Scrollen. Zu Beginn sind genau die acht Such-Räume grau mit „noch nicht angeschlossen“ (Netz-Radar, Kleinanzeigen, Portale, Foren & Fragen, Ärzte & Praxen, Handwerk, Selbstständige, Läden & Gastro); Brücke, Waage, Mail-Stube, Forum-Stube, Posteingang, Abschluss, die drei Werkstatt-Tische, Prüftisch und Archiv sind von Anfang an farbig (P1-E9). Das ist richtig: ein Such-Raum wird erst farbig, wenn der Dienst ihn als angeschlossen meldet (P3 für Netz-Radar, Kleinanzeigen, Portale, Foren; P6 für die Branchen-Räume). -6. **Pausenschalter (nur wenn P1 läuft):** „Alles anhalten“ ⇒ Hinweis „Angehalten …“ und heller Schleier im Hochhaus; danach „Fortsetzen“ — den Dienst **nicht** angehalten lassen. Als Admin: „Einstellungen“ öffnen, nichts ändern, „Abbrechen“; als Nicht-Admin fehlt der Knopf. **Versand-Notbremse (H4):** Hat der Dienst den Versand wegen Bounces gestoppt (P4), zeigt das Board für alle Mitarbeiter eine rote Zeile „Versand gestoppt: . Ein Admin kann ihn in den Einstellungen freigeben.“ und der Einstellungs-Dialog (nur Admin) oben den Block „Versand gestoppt“ mit dem Knopf „Versand wieder freigeben“. Den Knopf erst drücken, wenn die Ursache geklärt ist (Bounce-Adressen im Postfach prüfen, Sperrliste); er schickt nur `sendingHalted:false`, der Dienst löscht den Grund. Ohne Stopp sind Zeile und Block nicht zu sehen; wer sie vor dem Livegang sehen will, öffnet den Prüfstand aus P2-T21 mit `?halted=1`. +5. **Im Browser (als Mitarbeiter):** Menü zeigt „Hustler“ statt des grauen „Leads“; `https://ticket.webklar.com/leads` und `https://ticket.webklar.com/workers` springen auf `https://ticket.webklar.com/hustler` (eine angehängte Query wie `?tab=botleads` bleibt erhalten); Seitentitel (H1) und Browser-Tab zeigen „Hustler“; die Seite zeigt die sechs Kennzahlen, das Hochhaus mit 10 Etagen und 19 Figuren, ein Klick auf eine Figur öffnet rechts die Karte (Name, Modell, Zähler, Spur); es gibt vorerst nur den Bereich „Hochhaus“ ohne Reiterleiste (der Reiter „Freigabe“ kommt mit P3, dort P3-E11); auf dem Handy: Zoom-Knöpfe, kein seitliches Scrollen. Zu Beginn sind genau die acht Such-Räume grau mit „noch nicht angeschlossen“ (Netz-Radar, Kleinanzeigen, Portale, Foren & Fragen, Ärzte & Praxen, Handwerk, Selbstständige, Läden & Gastro); Brücke, Waage, Mail-Stube, Forum-Stube, Posteingang, Abschluss, die drei Werkstatt-Tische, Prüftisch und Archiv sind von Anfang an farbig (P1-E9). Das ist richtig: ein Such-Raum wird erst farbig, wenn der Dienst ihn als angeschlossen meldet (P3 für Netz-Radar, Kleinanzeigen, Portale, Foren; P6 für die Branchen-Räume). +6. **Pausenschalter (nur wenn P1 läuft):** „Alles anhalten“ ⇒ Hinweis „Angehalten …“ und heller Schleier im Hochhaus; danach „Fortsetzen“ — den Dienst **nicht** angehalten lassen. Als Admin: „Einstellungen“ öffnen (Titel „Hustler-Einstellungen“; unter „Kosten“ stehen im Standard Tagesgrenze 6 und Monatsgrenze 150, jeweils USD, [Kenso, 17.10.: max. 150 € im Monat]; die harte Grenze ist das Ausgabenlimit des Console-Workspaces, 150 $/Monat, Betriebsanleitung P1), nichts ändern, „Abbrechen“; als Nicht-Admin fehlt der Knopf. **Versand-Notbremse (H4):** Hat der Dienst den Versand wegen Bounces gestoppt (P4), zeigt das Board für alle Mitarbeiter eine rote Zeile „Versand gestoppt: . Ein Admin kann ihn in den Einstellungen freigeben.“ und der Einstellungs-Dialog (nur Admin) oben den Block „Versand gestoppt“ mit dem Knopf „Versand wieder freigeben“. Den Knopf erst drücken, wenn die Ursache geklärt ist (Bounce-Adressen im Postfach prüfen, Sperrliste); er schickt nur `sendingHalted:false`, der Dienst löscht den Grund. Ohne Stopp sind Zeile und Block nicht zu sehen; wer sie vor dem Livegang sehen will, öffnet den Prüfstand aus P2-T21 mit `?halted=1`. 7. **Zurücknehmen, falls nötig:** `git -C /home/webklar/apps/ticket.webklar.com/dev-workers revert --no-edit ALT..HEAD` (ALT aus Schritt 2; macht auch die Dokumentations-Commits rückgängig, das schadet nicht), dann `git -C /home/webklar/apps/ticket.webklar.com/dev-workers push origin feature/workers:test`. Prüfen: `https://ticket.webklar.com/leads` zeigt wieder die Tickets (graues „Leads“ im Menü). 8. **Danach (Claude):** Memory-Eintrag zu WOMS-Workers aktualisieren (Stand live, Commit-SHA, Ergänzungen P2-E1 bis P2-E7 stehen in der Übersicht §11.1, die Notbremsen-Anzeige entspricht A21 und H4). diff --git a/docs/superpowers/plans/2026-10-17-workers-p3-anfragen-pult.md b/docs/superpowers/plans/2026-10-17-workers-p3-anfragen-pult.md index 6a590c5..779348c 100644 --- a/docs/superpowers/plans/2026-10-17-workers-p3-anfragen-pult.md +++ b/docs/superpowers/plans/2026-10-17-workers-p3-anfragen-pult.md @@ -1167,9 +1167,9 @@ Voraussetzung: P1 läuft (Container `webklar-workers`, nginx-Block `/api/workers 5. **Image bauen und Dienst neu starten.** `cd /home/webklar/apps && free -m && docker compose build workers && docker compose up -d --no-deps workers`. *Prüfen:* `docker logs --tail 40 webklar-workers` (Zeile `workers listening on 8092`, keine Stacktraces) und `docker exec webklar-workers node -e "fetch('http://localhost:8092/api/workers/health').then(r=>r.json()).then(console.log)"` → `{ ok: true, … }`. Der erste Lauf des Alarm-Ticks setzt den Lesestand auf die aktuell höchste Mail (keine Altmails). Zusätzlich: `docker exec webklar-workers ls -l /app/prompts/clara.md /app/prompts/bea.md /app/prompts/rita.md /app/prompts/finn.md` zeigt die vier Dateien (Mount aus P1 ist da); fehlt eine, wirft der Dienst `Prompt fehlt: ` beim ersten Aufruf der Rolle. 6. **Postfach prüfen (manuell).** `docker exec webklar-workers node scripts/check-alert-mailbox.mjs`. Erwartet: Zeilen mit `uidValidity`, `uidNext`, `exists` und Absender-Domains, kein Passwort. `IMAP_AUTH` bedeutet falsches Passwort: in `.env` korrigieren, Dienst neu starten (der Dienst pausiert nach einem Anmeldefehler 6 Stunden und versucht es nicht in Schleife). 7. **Konten und Suchaufträge anlegen (von Hand).** Kleinanzeigen: Suchauftrag(e) mit Anzeigentyp „Gesuche“ (z. B. „Website erstellen“, „Homepage“, „Webdesigner“) und Benachrichtigung an die Alarm-Adresse; die Bestätigungs-Mail der Plattform anklicken. freelancermap und freelance.de: Suchagenten „Webdesign/Website/WordPress“ an dieselbe Adresse. Prüfung: Im Alarm-Postfach liegt je Plattform eine Bestätigungs- oder Test-Mail. -8. **Erste echte Alarm-Mail abwarten.** Sobald sie da ist (höchstens 10 Minuten bis zum nächsten Lauf), in WOMS → Workers → Kim anklicken: die Spur zeigt „Anzeigen gelesen“, der Raum „Kleinanzeigen“ ist nicht mehr grau. Steht dort „Alarm-Mail nicht lesbar“, leite die Mail (ohne persönliche Daten) weiter; sie ersetzt die Fixture aus Annahme A1/A2, und der Parser wird angepasst. Steht dort „Alarm-Mail ohne gültige Absender-Prüfung verworfen“, schick stattdessen nur die Kopfzeilen `From` und `Authentication-Results` der Mail (Gmail: Mail öffnen → „Original anzeigen“); fehlt `Authentication-Results` ganz, fügt dein Postfach-Anbieter die Zeile nicht ein, und die Prüfung muss für ihn angepasst werden. Nichts wird bis dahin ungeprüft übernommen. +8. **Erste echte Alarm-Mail abwarten.** Sobald sie da ist (höchstens 10 Minuten bis zum nächsten Lauf), in WOMS → Hustler → Kim anklicken: die Spur zeigt „Anzeigen gelesen“, der Raum „Kleinanzeigen“ ist nicht mehr grau. Steht dort „Alarm-Mail nicht lesbar“, leite die Mail (ohne persönliche Daten) weiter; sie ersetzt die Fixture aus Annahme A1/A2, und der Parser wird angepasst. Steht dort „Alarm-Mail ohne gültige Absender-Prüfung verworfen“, schick stattdessen nur die Kopfzeilen `From` und `Authentication-Results` der Mail (Gmail: Mail öffnen → „Original anzeigen“); fehlt `Authentication-Results` ganz, fügt dein Postfach-Anbieter die Zeile nicht ein, und die Prüfung muss für ihn angepasst werden. Nichts wird bis dahin ungeprüft übernommen. 9. **nginx prüfen.** `curl -s -o /dev/null -w '%{http_code}\n' https://ticket.webklar.com/api/workers/pult` → `401`. -10. **WOMS ausliefern.** Erst wenn P2 live ist: `git -C /home/webklar/apps/ticket.webklar.com/dev-workers push origin feature/workers:test`; nach etwa 3 Minuten in WOMS → Workers → Reiter „Freigabe“: „Nichts wartet auf dich“. +10. **WOMS ausliefern.** Erst wenn P2 live ist: `git -C /home/webklar/apps/ticket.webklar.com/dev-workers push origin feature/workers:test`; nach etwa 3 Minuten in WOMS → Hustler → Reiter „Freigabe“: „Nichts wartet auf dich“. 11. **Pult-Probe.** `docker exec webklar-workers node scripts/pult-test.mjs` druckt eine Id. In „Freigabe“ erscheint „[TEST] Pult-Probe – bitte verwerfen“. Prüfen: „Kopieren & öffnen“ öffnet webklar.com in neuem Tab und der Entwurf liegt in der Zwischenablage; „Ich kümmere mich“ zeigt deinen Namen; „Verwerfen“ (zweimal klicken) lässt den Eintrag verschwinden (Reiter „Erledigt“). 12. **Datenschutz-Freigabe, dann Clara einschalten.** Erst wenn der Abschnitt „Interessenten“ in der Datenschutzerklärung freigegeben und in WOMS „Rechtliches“ hochgeladen ist (P6) und du den Hinweistext der Erstantwort (P3-T13, „Texte (exakt)“, Test `buildFinalText`) gelesen und freigegeben hast (die Adresse darin ist `https://www.webklar.com/datenschutz#interessenten`, sie muss auf der Live-Seite wirklich zum Abschnitt führen): `docker exec webklar-workers node scripts/datenschutz-schalter.mjs --an`. Es gibt genau diesen einen Schalter (`settings.privacyInteressentenLive`, kein Env); sobald P6 eingerichtet ist, nimmst du stattdessen `p6-freischalten.mjs datenschutz --check` und `--set`, das vorher die Live-Seite prüft und dasselbe Feld setzt (A5). Prüfung: Ausgabe `Datenschutz-Schalter: an`; innerhalb von 2 Minuten (Tick `anfragen`) entstehen Pult-Einträge für bereits bewertete Anfragen; ohne Anfragen bleibt „Freigabe“ leer (bei 0–3 Gesuchen pro Woche normal). 13. **Rückweg.** Sofort stoppen: Pausenschalter in WOMS. Erstantworten abstellen: `docker exec webklar-workers node scripts/datenschutz-schalter.mjs --aus`. Alarm-Lesen abstellen: Raum „Kleinanzeigen“/„Portale“ in den Einstellungen deaktivieren. Alter Stand: `git -C /home/webklar/apps/workers checkout `, `docker compose build workers && docker compose up -d --no-deps workers`. diff --git a/docs/superpowers/plans/2026-10-17-workers-p4-gespraeche-werkstatt.md b/docs/superpowers/plans/2026-10-17-workers-p4-gespraeche-werkstatt.md index 5833111..724815f 100644 --- a/docs/superpowers/plans/2026-10-17-workers-p4-gespraeche-werkstatt.md +++ b/docs/superpowers/plans/2026-10-17-workers-p4-gespraeche-werkstatt.md @@ -454,7 +454,7 @@ WOMS `/home/webklar/apps/ticket.webklar.com/dev-workers/`: `src/lib/workersPult. **Interfaces:** - Consumes: `onInbound`, `lastOutbound` (T06), `startBuild` (T13), `suppress`, `suppressAddress` (T02), `recordHardBounce` (T14a, wird vor T08 ausgeführt), `addPult` (P3, `emit` inklusive), `enqueue`, `deps.notify`, `emit`, `getSettings`. - Produces: `applyClassification(deps, {message, thread, prospect, result, senderEmail}) → {action: string}` · `routeFrage(topic, {hasPreview}) → 'antwort'|'angebot'|'preisfrage'|'mensch_gewuenscht'` · `blockProspect(deps, {prospect, thread, emails, reason}) → void` · `expireOffersFor(deps, prospectId) → number` (offene `workersOffers` ⇒ `abgelaufen`). -- Regeln: `confident = confidence ≥ 0.7`. Ereignis `sortiert` (`nora`, Text `Antwort eingeordnet`, `meta.classification`); `onInbound(thread, {classification, confident})`; **zuletzt**, nach allen Folgen unten, erhält die Nachricht `classification`/`classificationConfidence` – das ist die Marke „erledigt“, auf die T15 beim Wiederaufnehmen nach einem Absturz prüft (alle Folgen sind wiederholbar: `suppress` ist idempotent, `startBuild` legt keine zweite Vorschau an). Nicht sicher oder `unklar` ⇒ Pult `unsicher` (Titel `Unsichere Antwort`, `originalText` = Nachrichtentext). `ja`: Interessent gesperrt/verloren/deal ⇒ ignorieren; Vorschau `gesendet|freigabe|fertig` ⇒ Job `angebot` (theo, `payload {previewId}`); Vorschau im Bau ⇒ nichts; sonst `startBuild`. `frage`: `routeFrage` – `allgemein` ⇒ Job `antwort` (max, `payload {author:'max'}`; bei einem Plattform-Faden stattdessen `clara`, T34); `kaufen` mit Vorschau ⇒ Job `angebot`, ohne Vorschau ⇒ `antwort` (ebenso); `preis_verhandlung` ⇒ Pult `preisfrage`; `mensch_gewuenscht` ⇒ Pult `mensch_gewuenscht`. `nein`/`abmelden` ⇒ `blockProspect` (Gründe `nein`/`abmeldung`). `beschwerde` ⇒ `blockProspect` (`beschwerde`), Pult `beschwerde` (Titel `Beschwerde oder rechtliche Drohung`), sofort `deps.notify({subject:'Beschwerde oder rechtliche Drohung', text})` mit Betrieb, Auszug (≤ 600 Zeichen) und WOMS-Link `https://ticket.webklar.com/workers`. `abwesend` ⇒ nichts. `unzustellbar` ⇒ bei `hardBounce` `suppressAddress(bounceEmail,'bounce','nora')` (nur die Adresse), `recordHardBounce(deps, {source:'dsn', messageDocId})` (H4, T14a: markiert die ausgehende Nachricht `unzustellbar`, vermerkt den Bounce im Tageszähler und prüft die Notbremse; `messageDocId` = Dokument-Id der ausgehenden Nachricht des Fadens mit `messageId === result.originalMessageId`, sonst `lastOutbound(deps, thread.$id)`, ohne ausgehende Nachricht leer), Interessent `verloren`; bei weichem Bounce (`hardBounce` falsch) nichts davon und nichts im Zähler. +- Regeln: `confident = confidence ≥ 0.7`. Ereignis `sortiert` (`nora`, Text `Antwort eingeordnet`, `meta.classification`); `onInbound(thread, {classification, confident})`; **zuletzt**, nach allen Folgen unten, erhält die Nachricht `classification`/`classificationConfidence` – das ist die Marke „erledigt“, auf die T15 beim Wiederaufnehmen nach einem Absturz prüft (alle Folgen sind wiederholbar: `suppress` ist idempotent, `startBuild` legt keine zweite Vorschau an). Nicht sicher oder `unklar` ⇒ Pult `unsicher` (Titel `Unsichere Antwort`, `originalText` = Nachrichtentext). `ja`: Interessent gesperrt/verloren/deal ⇒ ignorieren; Vorschau `gesendet|freigabe|fertig` ⇒ Job `angebot` (theo, `payload {previewId}`); Vorschau im Bau ⇒ nichts; sonst `startBuild`. `frage`: `routeFrage` – `allgemein` ⇒ Job `antwort` (max, `payload {author:'max'}`; bei einem Plattform-Faden stattdessen `clara`, T34); `kaufen` mit Vorschau ⇒ Job `angebot`, ohne Vorschau ⇒ `antwort` (ebenso); `preis_verhandlung` ⇒ Pult `preisfrage`; `mensch_gewuenscht` ⇒ Pult `mensch_gewuenscht`. `nein`/`abmelden` ⇒ `blockProspect` (Gründe `nein`/`abmeldung`). `beschwerde` ⇒ `blockProspect` (`beschwerde`), Pult `beschwerde` (Titel `Beschwerde oder rechtliche Drohung`), sofort `deps.notify({subject:'Beschwerde oder rechtliche Drohung', text})` mit Betrieb, Auszug (≤ 600 Zeichen) und WOMS-Link `https://ticket.webklar.com/hustler` (Betreff ohne eigenes Präfix: P1s `notify` setzt `[Hustler] ` davor). `abwesend` ⇒ nichts. `unzustellbar` ⇒ bei `hardBounce` `suppressAddress(bounceEmail,'bounce','nora')` (nur die Adresse), `recordHardBounce(deps, {source:'dsn', messageDocId})` (H4, T14a: markiert die ausgehende Nachricht `unzustellbar`, vermerkt den Bounce im Tageszähler und prüft die Notbremse; `messageDocId` = Dokument-Id der ausgehenden Nachricht des Fadens mit `messageId === result.originalMessageId`, sonst `lastOutbound(deps, thread.$id)`, ohne ausgehende Nachricht leer), Interessent `verloren`; bei weichem Bounce (`hardBounce` falsch) nichts davon und nichts im Zähler. - `expireOffersFor`: lädt `workersOffers` über `Query.equal('status', ['offen'])` (indiziert), filtert `prospectId` im Code und setzt nur `offen` auf `abgelaufen` (ein Angebot in `checkout` oder `bezahlt` bleibt unberührt). - `blockProspect`: `suppress` für `thread.toEmail` und jede weitere Adresse aus `emails` (leere Adressen, etwa bei Plattform-Fäden, werden übersprungen; Firmen-Domain sperrt P1 automatisch, Freemail nie), Interessent `gesperrt`, Vorschau des Interessenten `verworfen` (nicht bei gesetztem `convertedProjectId`), `expireOffersFor`, Ereignis `gesperrt` (`nora`, Text ohne Namen/Adressen); ist der Interessent schon `deal`, kommt nur die Sperrliste (Status, Vorschau, Angebote bleiben). @@ -486,7 +486,7 @@ WOMS `/home/webklar/apps/ticket.webklar.com/dev-workers/`: `src/lib/workersPult. - Produces (Übersicht §12 H4): - `recordHardBounce(deps, {messageDocId?, source}) → {counted:boolean, stopped:boolean}` (`source` ∈ `dsn`, `smtp`). Ablauf: (1) mit `messageDocId` das Dokument laden; hat es schon `classification 'unzustellbar'` ⇒ `{counted:false, stopped:false}` ohne Zählen (Wiederholung nach einem Absturz zählt nicht doppelt); (2) den ISO-Zeitpunkt (`isoNow(deps)`) an `counters.__bounce` (Array) des Berliner Tagesdokuments anhängen, über `updateDaily`, höchstens die letzten 50 pro Tag; (3) mit `messageDocId` und `direction 'out'` die ausgehende Nachricht auf `classification:'unzustellbar'` setzen (Zählen vor Markieren: ein Absturz dazwischen zählt höchstens einen Bounce doppelt, nie zu wenig); (4) `evaluateBounces(deps)`. Ohne `messageDocId` (SMTP lehnt sofort ab, die Reservierung ist schon gelöscht) entfallen (1) und (3). - `evaluateBounces(deps) → {stopped:boolean, reason?:string}`. Harte Bounces = die Zeitpunkte in `counters.__bounce` der Tagesdokumente der letzten 8 Berliner Tage (`getDaily` je Tag). **Regel 1:** Ausgehende Nachrichten über `deps.db.list('workersMessages', [Query.orderDesc('$createdAt'), Query.limit(100)])` laden (`$createdAt` ist ein Systemfeld und braucht keinen Index), im Code auf `direction 'out'` mit gesetztem `sentAt` filtern und nach `sentAt` absteigend ordnen; T10 sei `sentAt` der 10. jüngsten (gibt es weniger als 10, die älteste); stoppen, wenn mindestens 2 harte Bounces einen Zeitpunkt ≥ T10 haben (Grund `Zwei harte Bounces bei den letzten 10 Mails`). **Regel 2:** harte Bounces der letzten 7 Tage geteilt durch `max(Σ(mailsCold + mailsReply) der 7 Berliner Tage, 20)` ist **größer** als 0,05 (Grund `Bounce-Quote über 5 % in 7 Tagen ( von Mails)`; der Mindestnenner 20 verhindert, dass ein einzelner Bounce bei wenigen Mails sofort stoppt). Weiche Bounces stehen nie in `__bounce` und zählen nicht. Ist `settings.sendingHalted` schon wahr, bleibt es dabei (`{stopped:true}`, keine zweite Wirkung). - - `tripKillSwitch(deps, reason) → {tripped:boolean}` (idempotent, serialisiert über `onceFor('killswitch', …)`): ist `settings.sendingHalted` schon wahr ⇒ `{tripped:false}` ohne jede Wirkung; sonst `updateSettings(deps, {sendingHalted:true, sendingHaltedReason: reason (≤ 300 Zeichen)}, 'system')`, Ereignis `fehler` (`workerId 'system'`, Text `Versand gestoppt: Bounce-Notbremse`, `meta {reason:'bounce_stop'}`) und `deps.notify({subject:'Workers: Versand gestoppt (Bounces)', text})` (Grund plus Hinweis, dass ein Admin den Versand in WOMS unter `/workers`, Einstellungen, freigibt; geht an `settings.reportEmail`; ein Fehler beim Mailen wird nur geloggt) – Ereignis und Mail je genau einmal. + - `tripKillSwitch(deps, reason) → {tripped:boolean}` (idempotent, serialisiert über `onceFor('killswitch', …)`): ist `settings.sendingHalted` schon wahr ⇒ `{tripped:false}` ohne jede Wirkung; sonst `updateSettings(deps, {sendingHalted:true, sendingHaltedReason: reason (≤ 300 Zeichen)}, 'system')`, Ereignis `fehler` (`workerId 'system'`, Text `Versand gestoppt: Bounce-Notbremse`, `meta {reason:'bounce_stop'}`) und `deps.notify({subject:'Versand gestoppt (Bounces)', text})` (P1s `notify` setzt `[Hustler] ` davor; Text: Grund plus Hinweis, dass ein Admin den Versand in WOMS unter `/hustler`, Einstellungen, freigibt; geht an `settings.reportEmail`; ein Fehler beim Mailen wird nur geloggt) – Ereignis und Mail je genau einmal. - [ ] **Step 1: Failing test** `test/mail-killswitch.test.js` (`makeP4Deps`, `seedProspect`, `seedThread`; Hilfen: `sentMails(n)` legt n ausgehende Nachrichten mit `sentAt` im Minutenabstand an und rückt die Uhr jeweils um 1 Minute vor, `dayCounts(day, n)` setzt `mailsCold` des Tages per `updateDaily`; `deps.notify.calls` ist der Spion aus T01): - `2 harte Bounces in den letzten 10 Mails`: 10 Mails, der erste `recordHardBounce` (mit `messageDocId` einer ausgehenden Nachricht) ⇒ `{stopped:false}`; der zweite ⇒ `{stopped:true}`, `getSettings().sendingHalted === true`, `sendingHaltedReason` nicht leer und ≤ 300 Zeichen, `updatedBy 'system'`; genau ein Ereignis `fehler` mit `meta.reason === 'bounce_stop'` und genau ein `deps.notify`-Aufruf; ein dritter Bounce ändert daran nichts (weiterhin ein Ereignis, eine Mail). @@ -835,12 +835,12 @@ WOMS `/home/webklar/apps/ticket.webklar.com/dev-workers/`: `src/lib/workersPult. **Interfaces:** - Consumes: `sendMail` (T08), `ensureThread`, `lastInbound`, `onInbound` (T06), `enqueue`, `PultError` (P3-T11), `onceFor`, `getDoc`, `isoNow`. -- Produces: `REPLY_KINDS = ['mail_antwort','unsicher','preisfrage','mensch_gewuenscht']` · `replyToThread(deps, {pultId, text, user:{id, name}}) → {item}` · `approvePreviewItem(deps, {pultId, user}) → {item, previewUrl}` · `reasonText(reason) → string` (deutsche Meldungen: `pause` „Workers ist pausiert.“, `versand_gestoppt` „Der Versand ist wegen Bounces gestoppt; ein Admin muss ihn freigeben.“, `platzhalter` „Im Text steht noch ein Platzhalter.“, `adresse_ungueltig` „Die Adresse passt nicht zum Gespräch.“, `link_anzahl` „Der Text enthält zu viele Links.“, `gesperrt` „Dieser Kontakt ist gesperrt.“, `datenschutz` „Der Datenschutz-Abschnitt ist noch nicht freigegeben.“, `antwortlimit` „Das Tageslimit für Antworten ist erreicht.“, `smtp_gesperrt`/`smtp_auth` „Der Mail-Versand ist für heute gestoppt.“, `nicht_konfiguriert` „Der Mail-Versand ist nicht eingerichtet.“, sonst `Senden nicht möglich ().`) · `gespraecheRoutes() → [{method, pattern, handler}]` in der Form von P3-E6/P3-T15 (Pfade relativ zu `/api/workers`, Team-Recht prüft P1s Kern): `{method:'POST', pattern:/^\/pult\/([^/]+)\/reply$/, handler}` (Body `{text}`) und `{method:'POST', pattern:/^\/pult\/([^/]+)\/approve-preview$/, handler}`; `handler(deps, ctx)` liest `ctx.params[0]` (Eintrags-Id), `ctx.body`, `ctx.user = {id, name, labels}` und liefert `{status, json}`; `PultError` ⇒ `{status: e.status, json:{error: e.message}}`, Erfolg `{status:200, json:{item}}` bzw. `{item, previewUrl}`; ungültige Id (nicht `^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$`) ⇒ 404. +- Produces: `REPLY_KINDS = ['mail_antwort','unsicher','preisfrage','mensch_gewuenscht']` · `replyToThread(deps, {pultId, text, user:{id, name}}) → {item}` · `approvePreviewItem(deps, {pultId, user}) → {item, previewUrl}` · `reasonText(reason) → string` (deutsche Meldungen: `pause` „Hustler ist pausiert.“, `versand_gestoppt` „Der Versand ist wegen Bounces gestoppt; ein Admin muss ihn freigeben.“, `platzhalter` „Im Text steht noch ein Platzhalter.“, `adresse_ungueltig` „Die Adresse passt nicht zum Gespräch.“, `link_anzahl` „Der Text enthält zu viele Links.“, `gesperrt` „Dieser Kontakt ist gesperrt.“, `datenschutz` „Der Datenschutz-Abschnitt ist noch nicht freigegeben.“, `antwortlimit` „Das Tageslimit für Antworten ist erreicht.“, `smtp_gesperrt`/`smtp_auth` „Der Mail-Versand ist für heute gestoppt.“, `nicht_konfiguriert` „Der Mail-Versand ist nicht eingerichtet.“, sonst `Senden nicht möglich ().`) · `gespraecheRoutes() → [{method, pattern, handler}]` in der Form von P3-E6/P3-T15 (Pfade relativ zu `/api/workers`, Team-Recht prüft P1s Kern): `{method:'POST', pattern:/^\/pult\/([^/]+)\/reply$/, handler}` (Body `{text}`) und `{method:'POST', pattern:/^\/pult\/([^/]+)\/approve-preview$/, handler}`; `handler(deps, ctx)` liest `ctx.params[0]` (Eintrags-Id), `ctx.body`, `ctx.user = {id, name, labels}` und liefert `{status, json}`; `PultError` ⇒ `{status: e.status, json:{error: e.message}}`, Erfolg `{status:200, json:{item}}` bzw. `{item, previewUrl}`; ungültige Id (nicht `^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$`) ⇒ 404. - `replyToThread` (in `onceFor('pult:' + pultId)`): Eintrag fehlt ⇒ 404 „Eintrag nicht gefunden.“ (wie P3); Art ∉ `REPLY_KINDS` ⇒ 409 „Dazu gibt es keine Antwort-Funktion.“; ohne `prospectId` (etwa „Mail ohne Faden“ aus T15) ⇒ 409 „Zu diesem Eintrag gibt es keinen Interessenten.“; Status ∉ `offen|in_arbeit|veraltet` ⇒ 409 „Schon erledigt.“; `in_arbeit` von anderer Person ⇒ 409 „Wird schon von einem Kollegen bearbeitet.“; Text getrimmt leer ⇒ 400, > 8000 ⇒ 400; Faden aus `item.threadId`/`prospect.threadId`, fehlt er und der Interessent hat eine E-Mail ⇒ `ensureThread` (Betreff `Re: `); Kanal ≠ `mail` ⇒ 409 „Nur für Mail-Fäden.“; `sendMail(deps, {prospectId, threadId, to: thread.toEmail, subject: 'Re: ' + Betreff ohne führendes „Re: “, text, cold:false, inReplyTo: lastInbound?.messageId, kind:'antwort', author:'mensch:', idempotencyKey:'pult:'})`; nicht gesendet ⇒ 409 mit `reasonText`; gesendet ⇒ Eintrag `gesendet`, `doneBy 'mensch:'`, `doneAt`. Von der Bounce-Notbremse ist dieser Weg **nicht** ausgenommen (A19): `versand_gestoppt` blockiert wie `pause` jede Mail; für `mensch:` entfällt nur das Sendefenster, von den Inhaltsregeln (H3) gilt nur `platzhalter`. - `approvePreviewItem` (in `onceFor`): Art ≠ `vorschau_freigabe` ⇒ 409; Status wie oben; Vorschau nicht `freigabe` ⇒ 409 „Die Vorschau ist nicht freigabebereit.“; Interessent gesperrt ⇒ 409 „Dieser Kontakt ist gesperrt.“; Job `vorschau_link` (`max`, `payload {previewId, text: item.draft}`); Eintrag `erledigt`, `doneBy`, `doneAt`; Antwort `previewUrl` = `sourceUrl`. - [ ] **Step 1: Failing test** (Eintrag `unsicher` mit `threadId 't1'`, eingegangene Nachricht mit `messageId ''`; Nutzer `U1 = {id:'u1', name:'Justin'}`): - - `reply`: genau eine Mail an `thread.toEmail` mit Betreff `Re: Ihre Gratis-Vorschau`, Header `In-Reply-To: `, Text enthält `Ein Mitarbeiter von WEBklar`; gespeicherte Nachricht `author 'mensch:u1'`; Eintrag `gesendet`/`doneBy 'mensch:u1'`; am Samstag ebenso (Fenster gilt nicht); `settings.paused` ⇒ 409 „Workers ist pausiert.“, nichts gesendet; `settings.sendingHalted` ⇒ 409 „Der Versand ist wegen Bounces gestoppt; ein Admin muss ihn freigeben.“, nichts gesendet, Eintrag unverändert (A19); Text mit `{{name}}` ⇒ 409 „Im Text steht noch ein Platzhalter.“, ein Text mit `!` und `Sehr geehrte` wird dagegen gesendet (ein Mensch entscheidet über Ton, H3). + - `reply`: genau eine Mail an `thread.toEmail` mit Betreff `Re: Ihre Gratis-Vorschau`, Header `In-Reply-To: `, Text enthält `Ein Mitarbeiter von WEBklar`; gespeicherte Nachricht `author 'mensch:u1'`; Eintrag `gesendet`/`doneBy 'mensch:u1'`; am Samstag ebenso (Fenster gilt nicht); `settings.paused` ⇒ 409 „Hustler ist pausiert.“, nichts gesendet; `settings.sendingHalted` ⇒ 409 „Der Versand ist wegen Bounces gestoppt; ein Admin muss ihn freigeben.“, nichts gesendet, Eintrag unverändert (A19); Text mit `{{name}}` ⇒ 409 „Im Text steht noch ein Platzhalter.“, ein Text mit `!` und `Sehr geehrte` wird dagegen gesendet (ein Mensch entscheidet über Ton, H3). - Doppelklick (zwei parallele Aufrufe) ⇒ eine Mail, der zweite Aufruf 409 „Schon erledigt.“; anderer Nutzer bei `in_arbeit` von `u2` ⇒ 409; Art `beschwerde` ⇒ 409; Plattform-Faden ⇒ 409 „Nur für Mail-Fäden.“; gesperrter Interessent ⇒ 409 „Dieser Kontakt ist gesperrt.“, nichts gesendet; leerer Text ⇒ 400. - `mail_antwort` ohne Faden (Clara-Entwurf, Interessent mit E-Mail) ⇒ Faden wird angelegt, die Mail enthält den vollen Art.-14-Block (`Art. 21 DSGVO`) – mit `privacyInteressentenLive=false` ⇒ 409 „Der Datenschutz-Abschnitt …“; Eintrag `unsicher` ohne `prospectId` ⇒ 409 „Zu diesem Eintrag gibt es keinen Interessenten.“ - `approve`: Eintrag `vorschau_freigabe` (Vorschau `freigabe`) ⇒ Job `vorschau_link` mit `payload.text === item.draft`, Eintrag `erledigt`, Antwort `previewUrl`; zweiter Klick ⇒ 409; Vorschau nicht `freigabe` ⇒ 409; gesperrter Interessent ⇒ 409; falsche Art ⇒ 409. @@ -925,7 +925,7 @@ WOMS `/home/webklar/apps/ticket.webklar.com/dev-workers/`: `src/lib/workersPult. **Interfaces:** - Produces: Dockerfile-Zeilen (vor der Zeile `USER node`) `RUN apk add --no-cache chromium ttf-dejavu`, `RUN mkdir -p /data/vorschau && chown -R node:node /data` (kein `playwright install`) · `ops/traefik/workers-entwurf.yml` mit Router `entwurf-https` (`Host(\`entwurf.project.webklar.com\`)`, `entryPoints: [websecure]`, `priority: 200`, `tls.certResolver: letsencrypt`, Service `entwurf-workers` → `http://webklar-workers:8093`) und Router `entwurf-http` (`entryPoints: [web]`, Middleware `entwurf-https` = `redirectScheme https permanent`) · `ops/compose-p4.md` (nur die **neuen** Zeilen für den Compose-Dienst `workers`; `WORKERS_UNSUB_BASE`/`WORKERS_UNSUB_SECRET` und die SMTP-/IMAP-Umgebung stehen seit P1-T27 im Block und werden nicht doppelt eingetragen: `WORKERS_CHROMIUM_PATH=/usr/bin/chromium`, `WORKERS_AUTOMAIL=${WORKERS_AUTOMAIL:-nein}`, `HOME=/tmp`, `XDG_CACHE_HOME=/tmp/.cache` und die Ersetzung des bisherigen `tmpfs`-Eintrags `/tmp` durch `/tmp:size=256m`; ein Hinweis nennt, dass der Datenschutz-Schalter kein Env ist, sondern `settings.privacyInteressentenLive`) · `parseArgs(argv) → {cmd, to?, name?}` (wirft ohne `--ja-echt`: „Ohne --ja-echt wird nichts ausgeführt.“; Befehle `smtp` (braucht `--to`), `imap`, `screenshot`, `build`, `pilot` (braucht `--to` und `--name`); sonst „Unbekannter Befehl“). -- `scripts/p4-livecheck.mjs` (manuell, Kenso): `smtp` sendet **eine** Testmail über `config.smtp` (ein Versuch, mit denselben Headern wie das Tor, Betreff `Workers P4 Testmail`) und druckt die Message-ID; `imap` druckt über P3s Leser `peek('antworten')` `uidValidity/uidNext/exists`; `screenshot` startet `createShooter` gegen eine lokale Testseite und druckt Bildgrößen und Spitzen-Speicher; `build` macht **einen** echten Opus-Aufruf mit Beispielbetrieb „Testbetrieb Muster“ (über `ask` mit `stream: true`, wie `runBuild`), prüft die Ausgabe mit `validateBuildFiles`, schreibt nach `/tmp/p4-build-test/` und druckt Tokens und Kosten; `pilot` legt Interessent (`entrance 'anfrage'`, `room 'netz'`, `foundBy 'rita'`, `source 'pilot'`, `status 'bewertet'`, `name` = `contactName` = `--name`, `requestText 'Pilot-Test: einfache Firmenwebsite'`, E-Mail = `--to`, `emailSource 'Pilot-Test'`), Faden und Job `antwort` (`author 'clara'`, fester Testtext `PILOT_TEXT` mit „Antworten Sie mit „ja““, exportiert; er besteht die Regeln des Tors) an und druckt die Ids. Nie Passwort, Schlüssel oder Mailinhalt ausgeben. +- `scripts/p4-livecheck.mjs` (manuell, Kenso): `smtp` sendet **eine** Testmail über `config.smtp` (ein Versuch, mit denselben Headern wie das Tor, Betreff `Hustler P4 Testmail`) und druckt die Message-ID; `imap` druckt über P3s Leser `peek('antworten')` `uidValidity/uidNext/exists`; `screenshot` startet `createShooter` gegen eine lokale Testseite und druckt Bildgrößen und Spitzen-Speicher; `build` macht **einen** echten Opus-Aufruf mit Beispielbetrieb „Testbetrieb Muster“ (über `ask` mit `stream: true`, wie `runBuild`), prüft die Ausgabe mit `validateBuildFiles`, schreibt nach `/tmp/p4-build-test/` und druckt Tokens und Kosten; `pilot` legt Interessent (`entrance 'anfrage'`, `room 'netz'`, `foundBy 'rita'`, `source 'pilot'`, `status 'bewertet'`, `name` = `contactName` = `--name`, `requestText 'Pilot-Test: einfache Firmenwebsite'`, E-Mail = `--to`, `emailSource 'Pilot-Test'`), Faden und Job `antwort` (`author 'clara'`, fester Testtext `PILOT_TEXT` mit „Antworten Sie mit „ja““, exportiert; er besteht die Regeln des Tors) an und druckt die Ids. Nie Passwort, Schlüssel oder Mailinhalt ausgeben. - [ ] **Step 1: Failing test:** - `p4-ops`: `Dockerfile` enthält `apk add --no-cache chromium` und `ttf-dejavu`, enthält nicht `playwright install`; `ops/traefik/workers-entwurf.yml` enthält die Zeilen `Host(\`entwurf.project.webklar.com\`)`, `priority: 200`, `certResolver: letsencrypt`, `http://webklar-workers:8093`, `redirectScheme`; `ops/compose-p4.md` nennt die fünf neuen Einträge oben und **nicht** `WORKERS_UNSUB_`/`WORKERS_DATENSCHUTZ_OK`. @@ -992,12 +992,12 @@ Voraussetzung: P1–P3 laufen (P3-Betriebsanleitung durchgeführt), die Absender 5. **Bauen und starten.** `free -m | sed -n 2p` (mindestens 1,5 GB verfügbar), `docker compose build workers`, dann `docker compose up -d --no-deps workers`. Prüfung: `docker logs --tail 30 webklar-workers` zeigt `workers listening on 8092` und `vorschau listening on 8093` ohne Fehlerzeile; `docker exec webklar-workers chromium --version` zeigt eine Version; `docker exec webklar-workers sh -c 'mkdir -p /data/vorschau && id -un && ls -ld /data/vorschau'` zeigt `node` und `node` als Besitzer und `drwxr-xr-x` (0755; in einem schon vorhandenen Volume fehlt der Ordner bisher; scheitert `mkdir`, meldet Claude den Fehler und ändert nichts am Volume). Der Dienst legt Vorschau-Verzeichnisse selbst mit 0755 und Dateien mit 0644 an, damit der schreibgeschützt eingehängte Portal-Container (P5) sie lesen kann (A14). 6. **Schema nachziehen** (jetzt, im laufenden Container): `docker exec webklar-workers node scripts/setup-collections.mjs --check; echo "Exit $?"` → erwartet `workersPreviews.idx_status FEHLT`, `workersSettings.sendingHalted FEHLT`, `workersSettings.sendingHaltedReason FEHLT` (Bounce-Notbremse, H4), `Exit 1`; dann derselbe Befehl ohne `--check` (Ende `Fertig.`), danach wieder mit `--check` → `Alles vorhanden.`, `Exit 0`. 7. **Traefik-Route.** `cp /home/webklar/apps/workers/ops/traefik/workers-entwurf.yml /home/webklar/apps/traefik/dynamic/workers-entwurf.yml` (Hot-Reload, kein Neustart). Nach etwa 1 Minute: `curl -sI https://entwurf.project.webklar.com/abc` zeigt `404` und `x-robots-tag: noindex, nofollow`; `curl -sv https://entwurf.project.webklar.com/abc 2>&1 | rg -a -i 'issuer'` zeigt Let's Encrypt. Bei Zertifikatsfehler 5 Minuten warten und `docker logs --tail 50 webklar-traefik | rg -a entwurf` lesen. -8. **WOMS ausliefern.** `git -C /home/webklar/apps/ticket.webklar.com/dev-workers push origin feature/workers:test`. Prüfung nach dem Deploy: `https://ticket.webklar.com/workers` → Reiter „Freigabe“ lädt ohne Fehler. +8. **WOMS ausliefern.** `git -C /home/webklar/apps/ticket.webklar.com/dev-workers push origin feature/workers:test`. Prüfung nach dem Deploy: `https://ticket.webklar.com/hustler` → Reiter „Freigabe“ lädt ohne Fehler. 9. **Live-Prüfungen (je einzeln, Kosten nur bei `build`).** - `docker exec webklar-workers node scripts/p4-livecheck.mjs smtp --to kenso.gri@gmail.com --ja-echt` → Mail kommt an; in Gmail „Original anzeigen“: SPF, DKIM, DMARC `PASS`, `List-Unsubscribe` und `List-Unsubscribe-Post` vorhanden. Meldet das Skript einen Login-Fehler, **nicht** wiederholen: Passwort in `.env` prüfen, erst dann erneut. - `docker exec webklar-workers node scripts/p4-livecheck.mjs imap --ja-echt` → druckt `uidValidity`, `uidNext`, `exists`. - `docker exec webklar-workers node scripts/p4-livecheck.mjs screenshot --ja-echt` → druckt je Ansicht Bildgrößen (lange Kante ≤ 1456) und den Spitzen-Speicher; parallel `docker stats --no-stream webklar-workers` in einem zweiten Fenster. Liegt der Speicher nahe 768 MB oder der Container wird beendet: `mem_limit` und `memswap_limit` auf `1g` anheben (Kensos Entscheidung), `docker compose up -d --no-deps workers`. - `docker exec webklar-workers node scripts/p4-livecheck.mjs build --ja-echt` → ein echter Opus-Aufruf über Streaming (etwa 1–3 $, Dauer einige Minuten); Ausgabe nennt Tokens, Kosten und `validateBuildFiles: ok`. Bricht der Aufruf mit Zeitüberschreitung oder 400er-Fehler ab, **nicht** wiederholen, sondern die Meldung an Claude geben. -10. **Pilot mit eigenem Postfach** (nur Mo–Fr 09–16 Uhr). Das Tor lässt die erste Nachricht in einem Faden nur zu, wenn der Datenschutz-Schalter an ist. Für den Test geht die Mail nur an die eigene Adresse; deshalb den Schalter kurz einlegen (P3-Skript): `docker exec webklar-workers node scripts/datenschutz-schalter.mjs --an` und **am Ende dieses Schritts** wieder `… --aus`, solange der Abschnitt „Interessenten“ nicht live ist (P6). Dann `docker exec webklar-workers node scripts/p4-livecheck.mjs pilot --to kenso.gri@gmail.com --name "Testbetrieb Muster" --ja-echt` → innerhalb von 5 Minuten kommt die Testmail. Antworte „ja, gern“. Erwartung in WOMS `/workers`: Ereignis „Antwort eingegangen“ (Nora), Bau-Auftrag, nach etwa 5–15 Minuten im Reiter „Freigabe“ ein Eintrag „Vorschau für Testbetrieb Muster freigeben“ mit Link. Link öffnen; Rechte der Vorschau-Dateien prüfen: `docker exec webklar-workers sh -c 'ls -ld /data/vorschau/* | head -3; ls -l /data/vorschau/*/ | head -8'` zeigt `drwxr-xr-x` für die Verzeichnisse und `-rw-r--r--` für die Dateien (A14, sonst kann der Portal-Container sie nicht lesen). Dann „Freigeben & senden“ → Mail mit Vorschau-Link kommt an (innerhalb von 5 Minuten, im Sendefenster). Abmelde-Link der Mail im Browser öffnen: es erscheint nur die Seite mit Knopf; erst „Abmelden“ sperrt. Danach erneut `pilot` mit derselben Adresse: die Ereignisse zeigen keinen Versand (Kontakt gesperrt). **Aufräumen:** Der Abmelde-Test hat `kenso.gri@gmail.com` für immer auf die Sperrliste gesetzt; ohne Aufräumen kämen künftige Tests und Mails an diese Adresse nie an. Dokument-Id berechnen: `printf 'mail:kenso.gri@gmail.com' | sha256sum | cut -c1-32`, dann in der Appwrite-Konsole (Datenbank `woms-database`, Sammlung `workersSuppression`) das Dokument mit dieser Id löschen. Danach `docker exec webklar-workers node scripts/datenschutz-schalter.mjs --aus` (siehe oben). Der Pilot-Interessent bleibt als Testeintrag in `workersProspects` (Quelle `pilot`). **Plattform-Folge-Antwort (T34, A20):** Solange noch kein echter Plattform-Eintrag existiert (P3-Pilot), entfällt dieser Test. Sonst im Reiter „Freigabe“ (Filter „Erledigt“) bei einem von dir gesendeten Eintrag „Antwort auf …“ auf „Antwort des Kunden einfügen“ klicken, einen Beispieltext einfügen („Wie lange dauert das?“) und absenden: Meldung „Eingefügt. Nora sortiert die Antwort ein.“; innerhalb von 2 Minuten Ereignis „Antwort von der Plattform eingegangen“ (Nora), kurz danach ein neuer Eintrag „Folge-Antwort für …“ mit Claras Entwurf (Art `plattform_antwort`); der Text „Bitte abmelden“ sperrt den Interessenten sofort. Der Entwurf wird wie jeder Plattform-Entwurf von Hand kopiert und gesendet; Max sendet auf Plattformen nie. **Plattform-Benachrichtigungen aus dem Alarm-Postfach** werden erst ausgewertet, wenn der Betreff-Ausdruck der jeweiligen Plattform in `REPLY_SUBJECTS` (`src/gespraeche/plattformMail.js`) steht: Sobald du auf Kleinanzeigen, freelancermap oder freelance.de die erste echte Antwort auf eine von dir gesendete Nachricht bekommst, leite die Benachrichtigungs-Mail an Claude weiter (ohne persönliche Daten, nur Betreff und Aufbau), der den Ausdruck einträgt und testet. Bis dahin ist nur das Einfügen aktiv; Antwort-Mails im Alarm-Postfach behandelt P3 dann wie jede andere Nicht-Alarm-Mail. -11. **Dauerbetrieb freigeben.** Erst nach Warm-up und live veröffentlichtem Datenschutz-Abschnitt „Interessenten“: Schalter mit P6s Skript bzw. `docker exec webklar-workers node scripts/datenschutz-schalter.mjs --an` setzen (Prüfung `… --status` → `an`); optional `WORKERS_AUTOMAIL=ja` in `.env` (Max sendet Claras Mail-Antworten selbst), dann `docker compose up -d --no-deps workers`. Prüfung: `docker exec webklar-workers printenv WORKERS_AUTOMAIL`. **Bounce-Notbremse (H4):** Stoppt der Dienst den Versand (Mail „Workers: Versand gestoppt (Bounces)“ an die Berichtsadresse, in WOMS die Warnzeile „Versand gestoppt: …“), zuerst die Ursache prüfen (Adressquellen, Postfach, DNS/DKIM); freigegeben wird ausschließlich im Einstellungs-Dialog („Versand wieder freigeben“, nur Admin) – bis dahin bleiben alle Mail-Aufträge offen und gehen nach der Freigabe raus. +10. **Pilot mit eigenem Postfach** (nur Mo–Fr 09–16 Uhr). Das Tor lässt die erste Nachricht in einem Faden nur zu, wenn der Datenschutz-Schalter an ist. Für den Test geht die Mail nur an die eigene Adresse; deshalb den Schalter kurz einlegen (P3-Skript): `docker exec webklar-workers node scripts/datenschutz-schalter.mjs --an` und **am Ende dieses Schritts** wieder `… --aus`, solange der Abschnitt „Interessenten“ nicht live ist (P6). Dann `docker exec webklar-workers node scripts/p4-livecheck.mjs pilot --to kenso.gri@gmail.com --name "Testbetrieb Muster" --ja-echt` → innerhalb von 5 Minuten kommt die Testmail. Antworte „ja, gern“. Erwartung in WOMS `/hustler`: Ereignis „Antwort eingegangen“ (Nora), Bau-Auftrag, nach etwa 5–15 Minuten im Reiter „Freigabe“ ein Eintrag „Vorschau für Testbetrieb Muster freigeben“ mit Link. Link öffnen; Rechte der Vorschau-Dateien prüfen: `docker exec webklar-workers sh -c 'ls -ld /data/vorschau/* | head -3; ls -l /data/vorschau/*/ | head -8'` zeigt `drwxr-xr-x` für die Verzeichnisse und `-rw-r--r--` für die Dateien (A14, sonst kann der Portal-Container sie nicht lesen). Dann „Freigeben & senden“ → Mail mit Vorschau-Link kommt an (innerhalb von 5 Minuten, im Sendefenster). Abmelde-Link der Mail im Browser öffnen: es erscheint nur die Seite mit Knopf; erst „Abmelden“ sperrt. Danach erneut `pilot` mit derselben Adresse: die Ereignisse zeigen keinen Versand (Kontakt gesperrt). **Aufräumen:** Der Abmelde-Test hat `kenso.gri@gmail.com` für immer auf die Sperrliste gesetzt; ohne Aufräumen kämen künftige Tests und Mails an diese Adresse nie an. Dokument-Id berechnen: `printf 'mail:kenso.gri@gmail.com' | sha256sum | cut -c1-32`, dann in der Appwrite-Konsole (Datenbank `woms-database`, Sammlung `workersSuppression`) das Dokument mit dieser Id löschen. Danach `docker exec webklar-workers node scripts/datenschutz-schalter.mjs --aus` (siehe oben). Der Pilot-Interessent bleibt als Testeintrag in `workersProspects` (Quelle `pilot`). **Plattform-Folge-Antwort (T34, A20):** Solange noch kein echter Plattform-Eintrag existiert (P3-Pilot), entfällt dieser Test. Sonst im Reiter „Freigabe“ (Filter „Erledigt“) bei einem von dir gesendeten Eintrag „Antwort auf …“ auf „Antwort des Kunden einfügen“ klicken, einen Beispieltext einfügen („Wie lange dauert das?“) und absenden: Meldung „Eingefügt. Nora sortiert die Antwort ein.“; innerhalb von 2 Minuten Ereignis „Antwort von der Plattform eingegangen“ (Nora), kurz danach ein neuer Eintrag „Folge-Antwort für …“ mit Claras Entwurf (Art `plattform_antwort`); der Text „Bitte abmelden“ sperrt den Interessenten sofort. Der Entwurf wird wie jeder Plattform-Entwurf von Hand kopiert und gesendet; Max sendet auf Plattformen nie. **Plattform-Benachrichtigungen aus dem Alarm-Postfach** werden erst ausgewertet, wenn der Betreff-Ausdruck der jeweiligen Plattform in `REPLY_SUBJECTS` (`src/gespraeche/plattformMail.js`) steht: Sobald du auf Kleinanzeigen, freelancermap oder freelance.de die erste echte Antwort auf eine von dir gesendete Nachricht bekommst, leite die Benachrichtigungs-Mail an Claude weiter (ohne persönliche Daten, nur Betreff und Aufbau), der den Ausdruck einträgt und testet. Bis dahin ist nur das Einfügen aktiv; Antwort-Mails im Alarm-Postfach behandelt P3 dann wie jede andere Nicht-Alarm-Mail. +11. **Dauerbetrieb freigeben.** Erst nach Warm-up und live veröffentlichtem Datenschutz-Abschnitt „Interessenten“: Schalter mit P6s Skript bzw. `docker exec webklar-workers node scripts/datenschutz-schalter.mjs --an` setzen (Prüfung `… --status` → `an`); optional `WORKERS_AUTOMAIL=ja` in `.env` (Max sendet Claras Mail-Antworten selbst), dann `docker compose up -d --no-deps workers`. Prüfung: `docker exec webklar-workers printenv WORKERS_AUTOMAIL`. **Bounce-Notbremse (H4):** Stoppt der Dienst den Versand (Mail „[Hustler] Versand gestoppt (Bounces)“ an die Berichtsadresse, in WOMS die Warnzeile „Versand gestoppt: …“), zuerst die Ursache prüfen (Adressquellen, Postfach, DNS/DKIM); freigegeben wird ausschließlich im Einstellungs-Dialog („Versand wieder freigeben“, nur Admin) – bis dahin bleiben alle Mail-Aufträge offen und gehen nach der Freigabe raus. 12. **Zurückrollen.** `mv /home/webklar/apps/traefik/dynamic/workers-entwurf.yml /home/webklar/apps/`; `cp .env.bak--workers-p4 .env`; `cp docker-compose.yml.bak--workers-p4 docker-compose.yml` (die Sicherungen aus Schritt 2); `docker compose up -d --no-deps workers`; in WOMS Pausenschalter setzen. Prüfung: `curl -sI https://entwurf.project.webklar.com/abc` liefert nicht mehr `x-robots-tag` vom Workers-Dienst. diff --git a/docs/superpowers/plans/2026-10-17-workers-p5-portal-kaufweg.md b/docs/superpowers/plans/2026-10-17-workers-p5-portal-kaufweg.md index c8e88d8..5be4556 100644 --- a/docs/superpowers/plans/2026-10-17-workers-p5-portal-kaufweg.md +++ b/docs/superpowers/plans/2026-10-17-workers-p5-portal-kaufweg.md @@ -2,9 +2,9 @@ > **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 Interessent bezahlt über einen geheimen „Website übernehmen“-Link ohne Login ein Hosting-Abo; erst danach entstehen WOMS-Kunde, Portal-Zugang, Zustimmungsnachweis, Kundenprojekt mit der Vorschau und der Deal in Workers. +**Goal:** Ein Interessent bezahlt über einen geheimen „Website übernehmen“-Link ohne Login ein Hosting-Abo; erst danach entstehen WOMS-Kunde, Portal-Zugang, Zustimmungsnachweis, Kundenprojekt mit der Vorschau und der Deal im Hustler. -**Architecture:** Öffentliche Seite `uebernahme.html` plus Routen `/api/uebernahme/:token` (Muster `routes/auftrag.js`) lesen das Angebot (`workersOffers`, vom Dienst geschrieben) und erzeugen eine Stripe-Checkout-Sitzung **ohne** WOMS-Kunden (`workersOfferId` statt `webklarCustomerId`). Im Stripe-Webhook (`routes/abo.js`) fängt der neue Zweig `isWorkersCheckoutEvent` die Sitzung ab, legt den Kunden an, verknüpft Stripe, lässt den normalen Abo-Weg laufen (Spiegel, Abschluss-Ereignis, T02, K01) und erledigt danach Nachweis, Vorschau-Übernahme, K44 und Rückmeldung an Workers – jeder Schritt wiederholbar. Portal und Dienst teilen dieselbe Appwrite-Datenbank; eine eigene Dienst-Route (Spec §7, dritter Portal-Punkt) ist für den Kaufweg deshalb nicht nötig. +**Architecture:** Öffentliche Seite `uebernahme.html` plus Routen `/api/uebernahme/:token` (Muster `routes/auftrag.js`) lesen das Angebot (`workersOffers`, vom Dienst geschrieben) und erzeugen eine Stripe-Checkout-Sitzung **ohne** WOMS-Kunden (`workersOfferId` statt `webklarCustomerId`). Im Stripe-Webhook (`routes/abo.js`) fängt der neue Zweig `isWorkersCheckoutEvent` die Sitzung ab, legt den Kunden an, verknüpft Stripe, lässt den normalen Abo-Weg laufen (Spiegel, Abschluss-Ereignis, T02, K01) und erledigt danach Nachweis, Vorschau-Übernahme, K44 und Rückmeldung an den Hustler – jeder Schritt wiederholbar. Portal und Dienst teilen dieselbe Appwrite-Datenbank; eine eigene Dienst-Route (Spec §7, dritter Portal-Punkt) ist für den Kaufweg deshalb nicht nötig. **Tech Stack:** Node 20/ESM (Host-Tests Node 24), Express 4, stripe 19, Appwrite-REST (`appwriteAdmin.js`), HTML-Mailvorlagen (`mailTemplates.js`), Gitea-API (`giteaAdmin.js`), `node:test` mit den Nachbauten `appwriteMock`, `fakeStripe`, `fakeGitea`, `mailSink`, `fakeDom`; Vanilla-JS-Seite im Stil von `auftrag.html`. @@ -20,7 +20,7 @@ > - **E4 Betrieb:** Der Portal-Container hängt `workers_data:/workers-vorschau:ro` an – siehe Übersicht §9, §11, P5-E4. > - **E5 Auftrag an P4 (A14):** Vorschau-Server 404 ab `convertedProjectId`; Angebote `abgelaufen` bei Sperre (das Portal prüft `workersProspects.status === 'gesperrt'` zusätzlich); Ordner einer übernommenen Vorschau mindestens 7 Tage (Stripe wiederholt Webhooks bis zu 3 Tage), Verzeichnisse 0755, Dateien 0644 – übernommen, siehe Übersicht §4.13, §11, P5-E5. > - **E6 Auftrag an P1/P2 (A12, A13):** Ereignis `deal` (workerId `system`, ID `eventIdFor('deal', offerId)`, `metaJson` laut Übersicht §5); `newAbosMonth`, `mrrNewEur` **und** `dealsMonth` zählt P1 aus `workersOffers` (`status 'bezahlt'`, `paidAt` im Berliner Monat) – Auftrag an P1, nicht mehr offen; P5 setzt genau diese Felder, `paidAt` als ISO-UTC mit `Z` (P5-T12) – übernommen, siehe Übersicht §5, §11, P5-E6. -> - **E7 Mail-Kennungen:** K44 „Website übernommen“ und T25 „[Workers] …“ (Betreff-Präfix `[Workers]`, Präfix-Test in `mailTemplates.test.js` erweitert) – siehe Übersicht §8, §11, P5-E7. +> - **E7 Mail-Kennungen:** K44 „Website übernommen“ und T25 „[Hustler] …“ (Betreff-Präfix `[Hustler]`, Präfix-Test in `mailTemplates.test.js` erweitert; der Dateiname `T25 Workers Hinweis.html` bleibt als technischer Name) – siehe Übersicht §8, §11, P5-E7. > - **E8 `createWorkersCheckout(offer, consent, buyer, { token })`:** vierter, optionaler Parameter; der Token steht nur im Fragment der Abbruch-Adresse – siehe Übersicht §8, §11, P5-E8. > - **E9 Rechtsrunde:** Neue Wortlaute (Kundenart-Auswahl, Stripe-Hinweistext, Zitat des Angebotssatzes) brauchen Kensos Freigabe vor `WORKERS_KAUF_ENABLED=on`; der Bestellwortlaut ist Fassung v5 (`2026-09-30-v5`), Wort für Wort aus `public/app.js` – siehe Übersicht §11, P5-E9. > - **E10 Nur Hosting (A11):** P5 verkauft ausschließlich `productKey: 'hosting'`, jedes andere Produkt ergibt die neutrale 404-Antwort (die Stundengutschrift der ersten Wartungs-Rechnung, `invoice.paid`, träfe vor der Verknüpfung ein und ginge verloren). P1 und P4 setzen es um: P1 beschränkt `offerProductKey` auf `hosting`, P4 legt nur solche Angebote an – siehe Übersicht §4.12, §11, P5-E10. @@ -41,7 +41,8 @@ - Reservierte Subdomain `entwurf` (Übersicht §9): kein Projekt darf sie tragen oder erben. - Vorschau-Dateien nur aus `config.workers.vorschauRoot/`: höchstens 300 Dateien, 5 MB je Datei, 25 MB gesamt, `index.html` Pflicht, keine Symlinks, nie `.webklar-preview.json` überschreiben. - Texte an Käufer in Sie-Form, mit echten Umlauten; Mail-Kennungen K44 und T25 (vor dem Anlegen in P5-T10 gegen **alle** Refs prüfen). -- Nicht in P5: Widerruf oder Kündigung eines Workers-Kunden laufen über den bestehenden Weg (`contractEvents`, K03/K04, T08/T09); `workersOffers.status = 'widerrufen'` setzt später, wer es braucht. +- **Name [Kenso, 17.10.: Name „Hustler“]:** Was Mitarbeiter sehen, heißt „Hustler“: Betreff-Präfix der Team-Hinweise `[Hustler]` (T25), die Tag-Zeile „Intern · Hustler“, der in WOMS angezeigte Name beim Anlegen des Projekts (`staffName: 'Hustler'`). Texte an Käufer (Übernahme-Seite, Stripe-Hinweis, K44, Nachweis-Wortlaut) nennen weder „Workers“ noch „Hustler“ (Pin-Tests in P5-T02, P5-T08, P5-T10, P5-T13, P5-T14); sie sprechen als „WEBklar“. Technische Bezeichner bleiben unverändert: Sammlungen `workers*`, Dienst/Container `webklar-workers`, Dateien und Funktionen (`workersKauf.js`, `createWorkersCheckout` …), Metadaten `workersOfferId`, `workersAngebotssatz`, Env `WORKERS_*`, Volume `workers_data`, Mail-Dateiname `T25 Workers Hinweis.html`, Ereignisart `deal`. „Workers“ in Commit-Präfixen und Plan-Dateinamen ist das Projekt-Kürzel. +- Nicht in P5: Widerruf oder Kündigung eines Hustler-Kunden laufen über den bestehenden Weg (`contractEvents`, K03/K04, T08/T09); `workersOffers.status = 'widerrufen'` setzt später, wer es braucht. ## Review Focus @@ -98,7 +99,7 @@ Erwartet: `git status --short` leer (`node_modules` steht in `.gitignore`), letz - [ ] **Step 2: Failing test `test/workersReserviert.test.js`** (Muster `test/giteaAdmin.test.js`: `startFakeAppwrite` aus `./helpers/fakeAppwrite.js`, Env setzen, dann dynamisch importieren; Appwrite-Dokument `{ $id: 'wp1', repoFullName: 'WEBklar/x', projectName: 'X', subdomain: 'x', projectType: 'preview' }` in `websiteProjects`; `GITEA_BASE_URL=http://127.0.0.1:1`, `GITEA_API_TOKEN=t`, `PREVIEW_DEPLOY_ROOT=''`, `TRAEFIK_DYNAMIC_DIR=''`) ```js -test('config: Sammlungen und Workers-Block mit Standardwerten', () => { +test('config: Sammlungen und Block `workers` mit Standardwerten', () => { for (const n of ['workersOffers', 'workersProspects', 'workersPreviews', 'workersEvents', 'workersSettings']) assert.equal(config.collections[n], n) assert.deepEqual(config.workers, { kaufEnabled: false, vorschauRoot: '/workers-vorschau/vorschau', vorschauBaseUrl: 'https://entwurf.project.webklar.com' }) }) @@ -177,6 +178,7 @@ test('Werte eingesetzt: Betrag, Datum, Laufzeit', () => { assert.match(T.zeilen.laufzeit, /^Mindestlaufzeit 12 Monate ab Leistungsbeginn/) assert.equal(T.zeilen.preis, '20,00 € pro Monat (Gesamtpreis, gemäß § 19 UStG ohne Umsatzsteuer), keine weiteren Kosten') assert.equal(T.knopf, 'Zahlungspflichtig bestellen') + assert.doesNotMatch(JSON.stringify(T), /Workers|Hustler/) // Käufertexte nennen den internen Namen nie }) test('Angebotssatz-Fallback ist Kensos Satz aus Spec §11.2', () => { assert.equal(angebotssatzFallback({ amountCents: 2000, minTermMonths: 12 }), '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.') @@ -427,7 +429,7 @@ Erwartet: FAIL `Cannot find module '../server/services/workersKaufProjekt.js'`. - [ ] **Step 3: Implementieren (`server/services/workersKaufProjekt.js`)** -Ablauf (Reihenfolge ist Teil des Vertrags): (1) `convertedProjectId` gesetzt (Wiederholung): Projektdokument lesen, die Dateien mit `leseVorschau` holen; wirft das ein `VorschauProblem`, stehen Projekt und Commit schon von einem früheren Lauf, also ohne Commit mit dem Projekt zurückkehren; sonst weiter bei (4). (2) Sonst **zuerst** `leseVorschau(preview.token, { root })` – scheitert das, entsteht nichts; (3) Kandidaten der Reihe nach mit `createProjectFromTemplate({ repoName: k, displayName: offer.businessName, subdomain: k, customerId, owner: config.gitea.org, kind: 'website', staffName: 'Workers' })`; Status 409 → nächster, fünf Fehlschläge → `VorschauProblem('Kein freier Projektname')`, jeder andere Fehler wird weitergereicht; direkt danach `updateDocument(workersPreviews, preview.$id, { convertedProjectId: projectId })`; (4) Branch über `getRepoInfo`, `replaceRepoFiles(repoFullName, branch, dateien, { message: 'Vorschau übernommen (Workers)' })` (bei Wiederholung ohne Änderung kein Commit). +Ablauf (Reihenfolge ist Teil des Vertrags): (1) `convertedProjectId` gesetzt (Wiederholung): Projektdokument lesen, die Dateien mit `leseVorschau` holen; wirft das ein `VorschauProblem`, stehen Projekt und Commit schon von einem früheren Lauf, also ohne Commit mit dem Projekt zurückkehren; sonst weiter bei (4). (2) Sonst **zuerst** `leseVorschau(preview.token, { root })` – scheitert das, entsteht nichts; (3) Kandidaten der Reihe nach mit `createProjectFromTemplate({ repoName: k, displayName: offer.businessName, subdomain: k, customerId, owner: config.gitea.org, kind: 'website', staffName: 'Hustler' })` (`staffName` landet als `kindSetBy` im Projekt und ist in WOMS sichtbar); Status 409 → nächster, fünf Fehlschläge → `VorschauProblem('Kein freier Projektname')`, jeder andere Fehler wird weitergereicht; direkt danach `updateDocument(workersPreviews, preview.$id, { convertedProjectId: projectId })`; (4) Branch über `getRepoInfo`, `replaceRepoFiles(repoFullName, branch, dateien, { message: 'Vorschau übernommen (Hustler)' })` (bei Wiederholung ohne Änderung kein Commit). - [ ] **Step 4: Test laufen lassen** @@ -534,7 +536,7 @@ test('Sitzung ohne WOMS-Kunden: Metadaten, Adressen, Texte', async () => { assert.equal(b.success_url, 'https://project.webklar.com/uebernahme.html?danke=1') assert.equal(b.cancel_url, `https://project.webklar.com/uebernahme.html#t=${TOKEN}`) assert.equal(b.billing_address_collection, 'required') - assert.match(b['custom_text[submit][message]'], /^Ihre Bestellung haben Sie auf der Seite „Website übernehmen“ abgegeben\./) + assert.match(b['custom_text[submit][message]'], /^Ihre Bestellung haben Sie auf der Seite „Website übernehmen“ abgegeben\./); assert.doesNotMatch(b['custom_text[submit][message]'], /Workers|Hustler/) assert.equal(u.aw.docs('customers').size, 0) const cus = [...u.st.customers.values()].at(-1) assert.deepEqual([cus.email, cus.name, cus.metadata.workersOfferId, cus.metadata.webklarCustomerId], ['kaeufer@example.invalid', 'Müller Bau GmbH', 'o1', undefined]) @@ -691,14 +693,14 @@ git commit -m "feat(workers-kauf): öffentliche Routen /api/uebernahme" -m "Co-A **Interfaces:** - Produces: `renderMail('K44', values, blocks)` – Betreff `Willkommen bei WEBklar – Ihre Zugangsdaten`; Platzhalter `{{name}}, {{firma}}, {{email}}, {{passwort}}, {{portal_url}}, {{vorschau_url}}, {{leistungsbeginn}}`; Blöcke `vorschau`, `sofortbeginn`, `beginn_nach_widerrufsfrist`; Sie-Form; Layout wie K16. -- Produces: `renderMail('T25', values)` – Betreff `[Workers] {{vorfall}}`; Platzhalter `{{vorfall}}, {{details}}, {{zeit}}`; keine Blöcke; Layout wie T20 (Fuß der Team-Mails). +- Produces: `renderMail('T25', values)` – Betreff `[Hustler] {{vorfall}}`; Platzhalter `{{vorfall}}, {{details}}, {{zeit}}`; keine Blöcke; Layout wie T20 (Fuß der Team-Mails). - [ ] **Step 1: Nummern gegen alle Refs prüfen, dann failing test** ```bash for r in $(git for-each-ref --format='%(refname)' refs/heads refs/remotes); do git ls-tree -r --name-only $r -- server/mail-templates; done | sed 's#server/mail-templates/##' | rg '^(K4[4-9]|T2[5-9])' | sort -u ``` -Erwartet: keine Zeile. Zusätzlich gegen die noch nicht gebauten Pläne: `rg -n '\b(K44|T25)\b' /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht/docs/superpowers/plans` – „frei“ ist in Ordnung, eine Belegung durch eine andere Vorlage nicht. Ist K44 oder T25 belegt, nimmt der Plan die nächste freie Nummer und ersetzt sie in diesem Plan überall (K44 steht in P5-T10, T11, T12; T25 in T10 bis T12). In `test/mailTemplates.test.js`: `EXPECTED = [...ids('G', 22), ...ids('K', 44), ...ids('T', 25)]`; im Kopfkommentar-Test die Präfix-Liste um `Workers` erweitern; `TEAM_FOOTER` um `'T25'`; Kommentarzeile 9 um „K44 Website übernommen und T25 Workers-Hinweis (Workers P5)“ ergänzen. Neue Tests: +Erwartet: keine Zeile. Zusätzlich gegen die noch nicht gebauten Pläne: `rg -n '\b(K44|T25)\b' /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht/docs/superpowers/plans` – „frei“ ist in Ordnung, eine Belegung durch eine andere Vorlage nicht. Ist K44 oder T25 belegt, nimmt der Plan die nächste freie Nummer und ersetzt sie in diesem Plan überall (K44 steht in P5-T10, T11, T12; T25 in T10 bis T12). In `test/mailTemplates.test.js`: `EXPECTED = [...ids('G', 22), ...ids('K', 44), ...ids('T', 25)]`; im Kopfkommentar-Test die Präfix-Liste um `Hustler` erweitern; `TEAM_FOOTER` um `'T25'`; Kommentarzeile 9 um „K44 Website übernommen und T25 Hustler-Hinweis (Workers P5)“ ergänzen. Neue Tests: ```js test('K44: Zugangsdaten, Sie-Form, Blöcke je Beginn und Vorschau', () => { @@ -713,8 +715,9 @@ test('K44: Zugangsdaten, Sie-Form, Blöcke je Beginn und Vorschau', () => { const ohne = renderMail('K44', v, { sofortbeginn: true }) assert.ok(!ohne.text.includes('mueller.project.webklar.com') && !ohne.text.includes('02.11.2026')) assert.doesNotMatch(mit.text, DU_FORM); assert.match(mit.text, SIE_FORM) + assert.doesNotMatch(mit.text + ohne.text + tpl.subject, /Workers|Hustler/) // Käufer-Mail nennt den internen Namen nie }) -test('T25: Betreff [Workers], drei Platzhalter, Zeilenumbrüche in details bleiben', () => { /* wie der T20-Test, Datei 'T25 Workers Hinweis.html' */ }) +test('T25: Betreff [Hustler], drei Platzhalter, Zeilenumbrüche in details bleiben, Tag-Zeile „Intern · Hustler“', () => { /* wie der T20-Test, Datei 'T25 Workers Hinweis.html' (technischer Dateiname), Betreff beginnt mit "[Hustler] ", die Mail enthält "Intern · Hustler" und nicht "Workers" */ }) ``` - [ ] **Step 2: Test laufen lassen, Fehlschlag sehen** @@ -724,7 +727,7 @@ Erwartet: FAIL (`alle Vorlagen sind da`: K44/T25 fehlen; `Mail-Vorlage K44 fehlt - [ ] **Step 3: Vorlagen anlegen** -K44 aus `K16 Funktion bereit.html` kopieren (Kopfkommentar im Format `K44 · Website übernommen` mit `Betreff:`, `Absender:`, `Empfänger: Käufer (E-Mail aus der Bestellung) · ohne BCC, enthält das Anfangspasswort`, `Platzhalter:`, `Optionale Blöcke (…):`), Mitte ersetzen: Überschrift „Willkommen bei WEBklar“, Satz „vielen Dank für Ihre Bestellung für {{firma}}. Wir haben für Sie ein Konto im Kundenportal eingerichtet.“, Tabelle Adresse `{{portal_url}}` / Benutzername `{{email}}` / Passwort `{{passwort}}` mit Satz „Bitte ändern Sie das Passwort nach der ersten Anmeldung im Kundenportal.“, Block `vorschau` („Ihre Website-Vorschau finden Sie nach der Anmeldung unter „Projekte“ oder direkt unter {{vorschau_url}}. Der neueste Stand erscheint dort in wenigen Minuten.“), Block `sofortbeginn` („Wie von Ihnen gewünscht, beginnen wir sofort mit der Leistung.“), Block `beginn_nach_widerrufsfrist` („Wie von Ihnen gewählt, beginnen wir am {{leistungsbeginn}} nach Ablauf der Widerrufsfrist; bis dahin erbringen wir keine Leistungen und buchen nichts ab.“), Satz „Die Vertragsbestätigung mit den Vertragsunterlagen erhalten Sie in einer separaten E-Mail.“, Gruß „Viele Grüße, Ihr WEBklar-Team“. T25 aus `T20 Terminplaner Betriebsmeldung.html` kopieren, nur Kopf, ``, Betreff und die Tag-Zeile „Intern · Workers“ ändern. +K44 aus `K16 Funktion bereit.html` kopieren (Kopfkommentar im Format `K44 · Website übernommen` mit `Betreff:`, `Absender:`, `Empfänger: Käufer (E-Mail aus der Bestellung) · ohne BCC, enthält das Anfangspasswort`, `Platzhalter:`, `Optionale Blöcke (…):`), Mitte ersetzen: Überschrift „Willkommen bei WEBklar“, Satz „vielen Dank für Ihre Bestellung für {{firma}}. Wir haben für Sie ein Konto im Kundenportal eingerichtet.“, Tabelle Adresse `{{portal_url}}` / Benutzername `{{email}}` / Passwort `{{passwort}}` mit Satz „Bitte ändern Sie das Passwort nach der ersten Anmeldung im Kundenportal.“, Block `vorschau` („Ihre Website-Vorschau finden Sie nach der Anmeldung unter „Projekte“ oder direkt unter {{vorschau_url}}. Der neueste Stand erscheint dort in wenigen Minuten.“), Block `sofortbeginn` („Wie von Ihnen gewünscht, beginnen wir sofort mit der Leistung.“), Block `beginn_nach_widerrufsfrist` („Wie von Ihnen gewählt, beginnen wir am {{leistungsbeginn}} nach Ablauf der Widerrufsfrist; bis dahin erbringen wir keine Leistungen und buchen nichts ab.“), Satz „Die Vertragsbestätigung mit den Vertragsunterlagen erhalten Sie in einer separaten E-Mail.“, Gruß „Viele Grüße, Ihr WEBklar-Team“. T25 aus `T20 Terminplaner Betriebsmeldung.html` kopieren, nur Kopf, `<title>`, Betreff (`[Hustler] {{vorfall}}`) und die Tag-Zeile „Intern · Hustler“ ändern. - [ ] **Step 4: Test laufen lassen** @@ -784,19 +787,19 @@ test('doppelte Zustellung (nacheinander und gleichzeitig): ein Kunde, ein Abschl const { event } = await kauf(); await Promise.all([u.http.postWebhook(event()), u.http.postWebhook(event())]); await u.http.postWebhook(event()) assert.equal(u.aw.docs('customers').size, 1); assert.equal([...u.aw.docs('contractEvents').values()].filter((e) => e.type === 'abschluss').length, 1) assert.equal(u.mails.sent.filter((m) => /Vertragsbestätigung|^\[Abo\] Neues/.test(m.subject)).length, 2) - assert.ok(!u.mails.sent.some((m) => m.subject.startsWith('[Workers]')), 'eine doppelte Zustellung ist kein Konflikt') + assert.ok(!u.mails.sent.some((m) => m.subject.startsWith('[Hustler]')), 'eine doppelte Zustellung ist kein Konflikt') }) -test('veralteter subscription.updated nach der Verknüpfung: wird wie ein normales Abo-Ereignis gespiegelt', async () => { /* nach der Zustellung von checkout.completed ein customer.subscription.updated mit workersOfferId ohne webklarCustomerId senden: Status 200, subscriptions-Dokument bleibt eines, kein [Workers]-Mail */ }) +test('veralteter subscription.updated nach der Verknüpfung: wird wie ein normales Abo-Ereignis gespiegelt', async () => { /* nach der Zustellung von checkout.completed ein customer.subscription.updated mit workersOfferId ohne webklarCustomerId senden: Status 200, subscriptions-Dokument bleibt eines, kein [Hustler]-Mail */ }) test('Zahlung ohne Angebot (Angebot gelöscht): 200, T25 "Zahlung ohne Angebot", nichts angelegt', async () => { const { event } = await kauf(); await deleteDocument('workersOffers', 'o1') assert.equal((await u.http.postWebhook(event())).status, 200); assert.equal(u.aw.docs('customers').size, 0) - assert.ok(u.mails.sent.some((m) => m.subject.startsWith('[Workers] Zahlung ohne Angebot'))) + assert.ok(u.mails.sent.some((m) => m.subject.startsWith('[Hustler] Zahlung ohne Angebot'))) }) test('E-Mail gehört schon einem Kunden (Wettlauf nach dem Bezahlen): 200, kein zweites Konto, T25, Ereignis fehler', async () => { const { event } = await kauf(); u.aw.seed('customers', { $id: 'cx', email: 'kaeufer@example.invalid', name: 'Alt', customerStatus: 'active' }) assert.equal((await u.http.postWebhook(event())).status, 200) assert.equal(u.aw.docs('customers').size, 1); assert.equal(u.aw.docs('workersOffers').get('o1').customerId || '', '') - const t25 = u.mails.sent.find((m) => m.subject.startsWith('[Workers] E-Mail-Adresse gehört schon einem Kunden')); assert.ok(t25) + const t25 = u.mails.sent.find((m) => m.subject.startsWith('[Hustler] E-Mail-Adresse gehört schon einem Kunden')); assert.ok(t25) assert.ok(!u.mails.sent.some((m) => /Zugangsdaten/.test(m.subject))) assert.ok([...u.aw.docs('workersEvents').values()].some((e) => e.kind === 'fehler' && e.workerId === 'system')) }) @@ -871,22 +874,22 @@ test('Gesamtlauf: Seite → Bezahlen → Webhook: alles genau einmal und alles v const passwort = k44[0].text.match(/Passwort[\s:]*([abcdefghjkmnpqrstuvwxyz23456789]{16})\b/)[1] assert.equal(u.aw.docs('customers').get(kunde.$id).portalPassword, ''); assert.ok(u.aw.user(kunde.appwriteUserId)) assert.ok(k44[0].text.includes(projekt.previewUrl) && k44[0].text.includes('nach Ablauf der Widerrufsfrist')) - // Rückmeldung an Workers + // Rückmeldung an den Hustler const o = u.aw.docs('workersOffers').get('o1'); assert.deepEqual([o.status, o.customerId, o.stripeSessionId], ['bezahlt', kunde.$id, sessionId]); assert.ok(o.paidAt); assert.match(o.paidAt, /Z$/) // ISO-UTC: P1 filtert paidAt für die Berliner Monatsgrenze (A12) const p = u.aw.docs('workersProspects').get('p1'); assert.deepEqual([p.status, p.customerId], ['deal', kunde.$id]) const deal = u.aw.docs('workersEvents').get(eventIdFor('deal', 'o1')); assert.deepEqual([deal.kind, deal.workerId], ['deal', 'system']); assert.match(deal.ts, /Z$/) assert.deepEqual(JSON.parse(deal.metaJson), { offerId: 'o1', customerId: kunde.$id, productKey: 'hosting', amountCents: 2000, kundenart: 'privat' }) // Geheimnisse nie im Log for (const zeile of logs()) { assert.ok(!zeile.includes(passwort)); assert.ok(!zeile.includes(token)) } - assert.ok(!u.mails.sent.some((m) => m.subject.startsWith('[Workers]')), 'im Normalfall kein Team-Hinweis') + assert.ok(!u.mails.sent.some((m) => m.subject.startsWith('[Hustler]')), 'im Normalfall kein Team-Hinweis') }) test('Wiederholung nach vollständigem Lauf: nichts Neues', async () => { /* dasselbe Ereignis nochmals: Status 200; Zahl von customers, featureConsents, websiteProjects, workersEvents, K44-Mails und T02-Mails unverändert */ }) -test('zweite bezahlte Sitzung zum selben Angebot', async () => { /* nach dem Gesamtlauf die Sitzung klonen (neue ID in u.st.checkoutSessions, andere subId) und als Ereignis senden: Status 200, customers bleibt 1, Mail "[Workers] Doppelte Zahlung für ein Angebot" */ }) +test('zweite bezahlte Sitzung zum selben Angebot', async () => { /* nach dem Gesamtlauf die Sitzung klonen (neue ID in u.st.checkoutSessions, andere subId) und als Ereignis senden: Status 200, customers bleibt 1, Mail "[Hustler] Doppelte Zahlung für ein Angebot" */ }) test('K44 scheitert (Transport wirft): 500, Passwort bleibt; Wiederholung sendet K44 einmal und löscht das Passwort', async () => { /* sendMail wirft nur für Betreff "Willkommen bei WEBklar …": Status 500, customers.portalPassword nicht leer; Transport frei, erneute Zustellung: Status 200, genau eine K44, portalPassword '' */ }) test('Gitea weg: 500 ohne K44; später genau ein Projekt und eine K44', async () => { /* u.gitea.setDown(true): Status 500, keine K44; setDown(false) + erneute Zustellung: Status 200, ein websiteProjects-Dokument, eine K44 */ }) -test('Gitea lehnt dauerhaft ab (404 auf die Vorlage): 200, T25, K44 ohne Vorschau, Angebot bezahlt', async () => { /* u.gitea.failNext({ method: 'POST', pathIncludes: '/generate', status: 404 }): Status 200, Mail "[Workers] Vorschau konnte nicht übernommen werden", genau eine K44 ohne Vorschauadresse, workersOffers.status 'bezahlt', websiteProjects leer */ }) -test('Vorschau-Ordner fehlt: 200, T25, K44 ohne Vorschau, Angebot trotzdem bezahlt', async () => { /* seedAngebot({ previewDateien: null }) (im Aufruf von kauf über u.seedAngebot vorab): Mail "[Workers] Vorschau konnte nicht übernommen werden", K44 ohne "project.webklar.com"-Vorschauadresse, workersOffers.status 'bezahlt', websiteProjects leer */ }) -test('Nachweis scheitert: 200, T25, K44 geht trotzdem raus', async () => { /* u.aw.failNext({ collection: 'featureConsents', method: 'POST', status: 500 }): Status 200, Mail "[Workers] Zustimmungsnachweis nicht gespeichert", eine K44, Angebot bezahlt */ }) +test('Gitea lehnt dauerhaft ab (404 auf die Vorlage): 200, T25, K44 ohne Vorschau, Angebot bezahlt', async () => { /* u.gitea.failNext({ method: 'POST', pathIncludes: '/generate', status: 404 }): Status 200, Mail "[Hustler] Vorschau konnte nicht übernommen werden", genau eine K44 ohne Vorschauadresse, workersOffers.status 'bezahlt', websiteProjects leer */ }) +test('Vorschau-Ordner fehlt: 200, T25, K44 ohne Vorschau, Angebot trotzdem bezahlt', async () => { /* seedAngebot({ previewDateien: null }) (im Aufruf von kauf über u.seedAngebot vorab): Mail "[Hustler] Vorschau konnte nicht übernommen werden", K44 ohne "project.webklar.com"-Vorschauadresse, workersOffers.status 'bezahlt', websiteProjects leer */ }) +test('Nachweis scheitert: 200, T25, K44 geht trotzdem raus', async () => { /* u.aw.failNext({ collection: 'featureConsents', method: 'POST', status: 500 }): Status 200, Mail "[Hustler] Zustimmungsnachweis nicht gespeichert", eine K44, Angebot bezahlt */ }) test('vertragsNachweis: ohne wortlaut-Parameter bleibt WORTLAUT_STATUS', async () => { /* speichereVertragsNachweis wie bisher aufgerufen: JSON.parse(fassungen).wortlaut === 'entwurf' */ }) ``` @@ -911,7 +914,7 @@ Erwartet: alle PASS. ```bash git add server/services/workersKaufAbschluss.js server/services/vertragsNachweis.js test/workersKaufWebhook.test.js test/workersKaufAbschluss.test.js -git commit -m "feat(workers-kauf): Nachweis, Vorschau-Projekt, K44 und Rückmeldung an Workers" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" +git commit -m "feat(workers-kauf): Nachweis, Vorschau-Projekt, K44 und Rückmeldung an den Hustler" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" ``` --- @@ -938,6 +941,7 @@ test('HTML: noindex, kein Referrer, nur externes Modul-Skript, keine Fremdadress const h = readFileSync(new URL('../public/uebernahme.html', import.meta.url), 'utf8') for (const t of ['<meta name="robots" content="noindex, nofollow">', '<meta name="referrer" content="no-referrer">', '<body class="ab-seite">', 'id="ue-wurzel"', '<script type="module" src="/uebernahme.js"></script>', 'href="/auftrag.css"', 'href="/uebernahme.css"']) assert.ok(h.includes(t), t) assert.equal([...h.matchAll(/<script\b(?![^>]*\bsrc=)/g)].length, 0, 'kein Inline-Skript') + for (const datei of ['uebernahme.html', 'uebernahme.css', 'uebernahme-ansicht.js']) assert.doesNotMatch(readFileSync(new URL(`../public/${datei}`, import.meta.url), 'utf8'), /Workers|Hustler/, datei) // die Seite für Käufer nennt den internen Namen nie (uebernahme.js: P5-T14) assert.deepEqual([...h.matchAll(/(?:src|href)="(https?:)?\/\/[^"]+"/g)].map((m) => m[0]).filter((u) => !u.includes('webklar.com')), []) }) test('zustandAusAntwort: 404/429/Rest', () => { assert.equal(zustandAusAntwort(404, {}).zustand, 'ungueltig'); assert.match(zustandAusAntwort(404, {}).titel, /nicht mehr gültig/); assert.equal(zustandAusAntwort(429, {}).zustand, 'limit'); for (const s of [0, 500, 503, 200]) assert.equal(zustandAusAntwort(s, {}).zustand, 'fehler') }) @@ -989,7 +993,7 @@ test('Angebot wird angezeigt; Daten aus dem Server bleiben Text, nie HTML', asyn await initUebernahmeseite({ root, ansage: fx.document.createElement('div'), hash: `#t=${TOKEN}` }) assert.equal(root.dataset.zustand, 'offen'); assert.deepEqual(fx.fetchLog[0], { method: 'GET', url: `/api/uebernahme/${TOKEN}`, body: undefined }) assert.ok(fx.text(root).includes('<img src=x onerror=alert(1)> GmbH')); assert.equal(fx.finde(root, 'img'), null) - assert.ok(fx.text(root).includes(ANGEBOT.angebotssatz)) + assert.ok(fx.text(root).includes(ANGEBOT.angebotssatz)); assert.doesNotMatch(fx.text(root), /Workers|Hustler/) // die Seite nennt den internen Namen nie const a = fx.finde(root, 'a[data-vorschau]'); assert.equal(a.getAttribute('rel'), 'noopener noreferrer'); assert.equal(a.getAttribute('target'), '_blank') assert.equal(fx.text(fx.finde(root, '[data-zeile="faellig"]')), ANGEBOT.texte.zeilen.faellig.ohneWahl) await fx.klick(fx.finde(root, 'input[name="startChoice"][value="nach_frist"]')) @@ -1093,7 +1097,7 @@ Vorher: P1 ist live (Container `webklar-workers` läuft, Sammlungen angelegt), u 3. **`.env`.** Zeile `WORKERS_KAUF_ENABLED=off` ergänzen (Schalter zunächst aus). Prüfung: `rg -n '^WORKERS_KAUF_ENABLED' .env` zeigt genau eine Zeile. 4. **Compose, Dienst `kundenbereich-server`.** Unter `environment:` zwei Zeilen, unter `volumes:` eine Zeile ergänzen: ```yaml - # Workers-Kaufweg (2026-10-17): Seite "Website uebernehmen" zuerst aus; Vorschau-Dateien lesend aus dem Workers-Volume + # Hustler-Kaufweg (2026-10-17): Seite "Website uebernehmen" zuerst aus; Vorschau-Dateien lesend aus dem Volume workers_data - WORKERS_KAUF_ENABLED=${WORKERS_KAUF_ENABLED:-off} - WORKERS_VORSCHAU_ROOT=/workers-vorschau/vorschau ``` @@ -1110,7 +1114,7 @@ Vorher: P1 ist live (Container `webklar-workers` läuft, Sammlungen angelegt), u 2. `.env`: `WORKERS_KAUF_ENABLED=on`, dann `docker compose up -d --no-deps kundenbereich-server`. 3. Testangebot: `docker exec -w /app webklar-kundenbereich-server timeout 60 node --max-old-space-size=256 scripts/workers-kauf-testangebot.mjs --email <testadresse> --firma "Testbetrieb GmbH" --vorschau $T` – der Befehl druckt den Link und die Angebots-ID. 4. Link auf Handy und am Desktop in einem privaten Fenster öffnen (eine offene Kundenansicht im Browser sperrt das Bestellen mit 403); Seite prüfen (Betrieb, Vorschau-Link, Angebotssatz, Bedingungen, keine waagerechte Scrollleiste bei 360 px Breite, Dunkelmodus lesbar), bestellen, bei Stripe bezahlen. - 5. Prüfung: Rücksprung auf „Vielen Dank“; in Stripe tragen Kunde und Abo `webklarCustomerId`; im WOMS gibt es den Kunden (Status `customer`) mit Projekt „Testbetrieb GmbH“ (Vorschau erreichbar nach Anmeldung); Postfach der Testadresse: K44 (mit Passwort) und K01; Team-Postfach: T02; `workersOffers.status = bezahlt`; Anmeldung mit den K44-Daten klappt; Passwort im WOMS-Kundenblatt leer. + 5. Prüfung: Rücksprung auf „Vielen Dank“; in Stripe tragen Kunde und Abo `webklarCustomerId`; im WOMS gibt es den Kunden (Status `customer`) mit Projekt „Testbetrieb GmbH“ (Vorschau erreichbar nach Anmeldung); Postfach der Testadresse: K44 (mit Passwort) und K01; Team-Postfach: T02 und kein Hinweis mit Betreff „[Hustler] …“; `workersOffers.status = bezahlt`; Anmeldung mit den K44-Daten klappt; Passwort im WOMS-Kundenblatt leer. 6. Aufräumen: Abo bei Stripe beenden bzw. widerrufen; Kunde im WOMS löschen; `docker exec -w /app webklar-kundenbereich-server timeout 60 node --max-old-space-size=256 scripts/workers-kauf-testangebot.mjs --loeschen <angebotsId>` (löscht nur Angebote, deren ID mit `t-` beginnt); Test-Repo `WEBklar/testbetrieb-gmbh` im WOMS-Projekt archivieren; Ordner `docker exec webklar-workers rm -r -- /data/vorschau/$T`. -10. **Handfälle** (kommen per T25-Mail): *E-Mail gehört schon einem Kunden* – im Stripe-Dashboard am Abo und am Kunden `webklarCustomerId=<ID des bestehenden Kunden>` setzen, dann das Ereignis `checkout.session.completed` erneut senden: der Handler setzt am Kunden fort (Nachweis, Projekt, Abschluss). Hat dieser Kunde im WOMS noch ein gespeichertes Anfangspasswort, geht es per K44 an die im Kundendokument hinterlegte Adresse (nicht an die Käufer-Adresse); sonst entfällt K44. Prüfe vorher, dass der Kunde wirklich dieselbe Person ist. *Doppelte Zahlung* – die zweite Zahlung bei Stripe erstatten und das zweite Abo beenden. *Vorschau nicht übernommen* – Dateien prüfen, Ereignis erneut senden. +10. **Handfälle** (kommen per T25-Mail, Betreff „[Hustler] …“): *E-Mail gehört schon einem Kunden* – im Stripe-Dashboard am Abo und am Kunden `webklarCustomerId=<ID des bestehenden Kunden>` setzen, dann das Ereignis `checkout.session.completed` erneut senden: der Handler setzt am Kunden fort (Nachweis, Projekt, Abschluss). Hat dieser Kunde im WOMS noch ein gespeichertes Anfangspasswort, geht es per K44 an die im Kundendokument hinterlegte Adresse (nicht an die Käufer-Adresse); sonst entfällt K44. Prüfe vorher, dass der Kunde wirklich dieselbe Person ist. *Doppelte Zahlung* – die zweite Zahlung bei Stripe erstatten und das zweite Abo beenden. *Vorschau nicht übernommen* – Dateien prüfen, Ereignis erneut senden. 11. **Zurücknehmen.** `WORKERS_KAUF_ENABLED=off` in `.env`, `docker compose up -d --no-deps kundenbereich-server`; die Links antworten sofort 404. Der Webhook-Zweig bleibt wirksam (bezahlte Sitzungen werden weiter verarbeitet). diff --git a/docs/superpowers/plans/2026-10-17-workers-p6-ohne-website.md b/docs/superpowers/plans/2026-10-17-workers-p6-ohne-website.md index 97b2cd4..5fbe497 100644 --- a/docs/superpowers/plans/2026-10-17-workers-p6-ohne-website.md +++ b/docs/superpowers/plans/2026-10-17-workers-p6-ohne-website.md @@ -53,7 +53,7 @@ Die P6-Ergänzungen E1–E8 stehen jetzt verbindlich in Übersicht §11.1 (P6-E1 | E4 | siehe Übersicht §11, P6-E4 und A1: `deps.places`, `deps.osm`, `deps.dns = { resolveMx, resolve4 }`, kein `llm`; das Impressum-Lesen nutzt `deps.fetch` (H2). | P6-T08 (Nutzung), P6-T24 (`erweitereDeps`) | | E5 | siehe Übersicht §11, P6-E5 und §6.1 (Ränge, H1): P6 ergänzt in `src/schedule.js` nur die Tick-Arten `top3` (05:30) und `scouts` (alle 2 Minuten); `kuerzen` (03:00) steht dort seit P1-T08 und wird nicht noch einmal angelegt; Einhängen aller drei Tick-Einträge über `buildTickJobs()` (A4). | P6-T05, P6-T24 | | E6 | siehe Übersicht §11, P6-E6 und §6 (`gate.js`): Zusatzprüfung `coldChecks` über `registerGateCheck` (Gründe `datenschutz`, `adresse_nicht_belegt`, `kein_kaltkontakt`, `kaltmail_ohne_antwort`, `status_unpassend`), `appendCompliance` Zweig `cold:true`. | P6-T15 | -| E7 | siehe Übersicht §11, P6-E7: `GET /botleads?id=<prospectId>`, Ereignislink `/workers?tab=botleads&bot=<prospectId>`, Route über `routes`/`buildRoutes()` (A4). | P6-T18, P6-T19, P6-T24 | +| E7 | siehe Übersicht §11, P6-E7: `GET /botleads?id=<prospectId>`, Ereignislink `/hustler?tab=botleads&bot=<prospectId>` (WOMS-Route `/hustler`, Weiterleitung von `/workers` und `/leads`), Route über `routes`/`buildRoutes()` (A4). | P6-T18, P6-T19, P6-T24 | | E8 | siehe Übersicht §11, P6-E8: `deps.drafters.erstmail/nachfass(deps, job)` liefert P6; die Aufträge legen `selectNightTop3` bzw. `scheduleFollowups` an, versendet P4s Tick `senden`. | P6-T16 | ## File Structure @@ -156,7 +156,7 @@ test('makeDeps: Uhr in Millisekunden, Einstellungen über den Standardwerten, do assert.equal(typeof deps.clock.now(), 'number') assert.equal(new Date(deps.clock.now()).toISOString(), '2026-10-20T03:30:00.000Z') const s = deps.db.docs('workersSettings')[0] - assert.deepEqual([s.$id, s.connectedRooms, s.dailyBudgetUsd, s.privacyInteressentenLive], ['main', ['handwerk'], 10, true]) + assert.deepEqual([s.$id, s.connectedRooms, s.dailyBudgetUsd, s.privacyInteressentenLive], ['main', ['handwerk'], 6, true]) // 6 $/Tag = Standard (P1, Kenso 17.10.: max. 150 € im Monat) const p = await seedProspect(deps.db, {}) await deps.db.update('workersProspects', p.$id, { score: 1 }) assert.deepEqual(deps.db.writes.slice(-1)[0], { op: 'update', col: 'workersProspects', id: p.$id }) @@ -872,7 +872,7 @@ test('fileBotLead: Status, Grund, Kontaktdaten weg, Ereignis ohne Namen oder Adr const d = await deps.db.get('workersProspects', p.$id) assert.deepEqual([d.status, d.botLeadReason, d.email, d.emailSource, d.contactName, d.phone, d.score], ['botlead', grund, '', '', '', '0631 111', 80]) const ev = deps.db.docs('workersEvents').find((e) => e.kind === 'botlead') - assert.deepEqual([ev.workerId, ev.text, ev.prospectId, ev.link], ['arne', `Bot-Lead (Handwerk, Kaiserslautern): ${grund}`, p.$id, `/workers?tab=botleads&bot=${p.$id}`]) + assert.deepEqual([ev.workerId, ev.text, ev.prospectId, ev.link], ['arne', `Bot-Lead (Handwerk, Kaiserslautern): ${grund}`, p.$id, `/hustler?tab=botleads&bot=${p.$id}`]) assert.ok(!/Beispiel|@/.test(ev.text)) await fileBotLead(deps, { prospectId: p.$id, reason: grund }) assert.equal(deps.db.docs('workersEvents').filter((e) => e.kind === 'botlead').length, 1, 'zweiter Aufruf ändert nichts') @@ -889,7 +889,7 @@ test('Grund wird auf 500 Zeichen gekürzt; weitergeschrittene oder für Mail vor }) ``` - [ ] **Step 2: Fehlschlag sehen.** `timeout 300 node --max-old-space-size=1024 --test test/p6-botleads-ablage.test.js` ⇒ FAIL, Modul fehlt. -- [ ] **Step 3: Implementieren.** Interessenten lesen (`db.get` liefert `null` ⇒ `Error('Interessent fehlt')`); ist `status` schon `botlead` ⇒ zurück ohne Schreiben; ist `status` nicht `gefunden` oder `bewertet` oder `threadId` gesetzt ⇒ `Error('Bot-Lead nur für Interessenten ohne Kontakt')`; `update` mit `status`, `botLeadReason`, bei `dropContact` die drei Felder auf `''`, `updatedAt`; Ereignis `emit(deps, { workerId: 'arne', kind: 'botlead', prospectId, link: '/workers?tab=botleads&bot=<id>', text: 'Bot-Lead (<Raumbeschriftung>, <Ort oder „Ort unbekannt“>): <Grund, höchstens 200 Zeichen>' })`. +- [ ] **Step 3: Implementieren.** Interessenten lesen (`db.get` liefert `null` ⇒ `Error('Interessent fehlt')`); ist `status` schon `botlead` ⇒ zurück ohne Schreiben; ist `status` nicht `gefunden` oder `bewertet` oder `threadId` gesetzt ⇒ `Error('Bot-Lead nur für Interessenten ohne Kontakt')`; `update` mit `status`, `botLeadReason`, bei `dropContact` die drei Felder auf `''`, `updatedAt`; Ereignis `emit(deps, { workerId: 'arne', kind: 'botlead', prospectId, link: '/hustler?tab=botleads&bot=<id>', text: 'Bot-Lead (<Raumbeschriftung>, <Ort oder „Ort unbekannt“>): <Grund, höchstens 200 Zeichen>' })`. - [ ] **Step 4: Bestehen sehen.** Derselbe Befehl ⇒ `# fail 0`. - [ ] **Step 5: Commit.** `git add src/archive/botleads.js test/p6-botleads-ablage.test.js && git commit -m "p6: Arne legt Bot-Leads ab, ohne Kontaktdaten" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"` @@ -956,7 +956,7 @@ test('Register zuerst, nur der frische Kandidat kostet; Bewertung 82; R1: keine }) test('Budget aus ⇒ kein Places-Aufruf', async () => { const deps = szenario() - await deps.db.create('workersDaily', { costUsd: 10, mailsCold: 0, mailsReply: 0, searches: 0, found: 0, contacted: 0, deals: 0, countersJson: '{}', warned: false, stopped: false }, '2026-10-20') + await deps.db.create('workersDaily', { costUsd: 6, mailsCold: 0, mailsReply: 0, searches: 0, found: 0, contacted: 0, deals: 0, countersJson: '{}', warned: false, stopped: false }, '2026-10-20') // Tagesgrenze 6 $ erreicht let n = 0 deps.places = { textSearch: async () => { n++; return { places: [], nextPageToken: null } } } const r = await runScoutJob(deps, await jobIn(deps)) @@ -1689,7 +1689,7 @@ test('legal_datenschutz_interessenten: Geltungsbereich, Empfänger und Art. 22 p **Interfaces:** - Consumes aus `rechtliches-hochladen.mjs` (neu exportiert): `client(fetchFn, schluessel)`, `alleDokumente(api, sammlung)`, `versionen(api, documentKey)`, `neueId()`, `ddmmjjjj(iso)`, `planVersion({ alle, source, validFrom })`, `BUCKET`, `DB`, `KEY_DATEI`, `ROOT`. -- Produces: `parseArgs(argv) → { modus: 'probe'|'hochladen', freigabe: string|null }`; `main(argv, deps) → Promise<0|1>` mit `deps = { fetch, schluessel, log, git, dateien }` wie das Vorbild. Karte `datenschutz`; Dateien `docs/WEBklar-Datenschutzerklaerung.pdf` und `public/datenschutz.html`; `source = kundenbereich@<Commit>:public/datenschutz.html`; `changeNote = 'Fassung vom <TT.MM.JJJJ>: Abschnitt 11 Interessenten (Workers, Kaltmail)'`. Der Aufruf `dokumentFuer('datenschutz')` des Vorbilds bleibt unverändert ein Fehler (bestehender Test). +- Produces: `parseArgs(argv) → { modus: 'probe'|'hochladen', freigabe: string|null }`; `main(argv, deps) → Promise<0|1>` mit `deps = { fetch, schluessel, log, git, dateien }` wie das Vorbild. Karte `datenschutz`; Dateien `docs/WEBklar-Datenschutzerklaerung.pdf` und `public/datenschutz.html`; `source = kundenbereich@<Commit>:public/datenschutz.html`; `changeNote = 'Fassung vom <TT.MM.JJJJ>: Abschnitt 11 Interessenten (Ansprache per E-Mail)'` (steht in WOMS „Rechtliches“ und kann für Kunden sichtbar werden: darum weder „Workers“ noch „Hustler“). Der Aufruf `dokumentFuer('datenschutz')` des Vorbilds bleibt unverändert ein Fehler (bestehender Test). - [ ] **Step 1: Test schreiben.** @@ -2036,7 +2036,7 @@ Reihenfolge ist wichtig: Erst das Fundament (1–4), dann Probe (5), dann der Da 6. **Datenschutztext live.** (a) Kenso gibt den Text aus P6-T20 frei und nennt den Mail-Anbieter. (b) Der Ausführende committet in `dev-workers-datenschutz`; Kenso pusht: `git -C /home/webklar/apps/kundenbereich.webklar.com/dev-workers-datenschutz push origin feature/workers-datenschutz:main`; nach 1–2 Minuten `curl -s https://project.webklar.com/datenschutz.html | command grep -c 'id="interessenten"'` ⇒ `1`. (c) Website: `git -C /home/webklar/apps/webklar.com/dev-workers-ds push origin fix/datenschutz-anker:main`; deployt der Webhook nicht binnen 3 Minuten: `docker exec webklar-webclaw-build sh -c 'DEPLOY_TRIGGER=manual sh "${WEBCLAW_DEPLOY_SCRIPT:-/deploy.sh}"'`. Prüfen im Browser: `https://www.webklar.com/datenschutz#interessenten` landet auf `https://project.webklar.com/datenschutz.html#interessenten` und zeigt „11. Interessenten“. (d) Rechtliches: Kenso erzeugt in der Appwrite-Konsole den kurzlebigen Schlüssel „Rechtliches-Upload“ (files.read, files.write, documents.read, documents.write, Ablauf 1 Tag) und legt ihn nach `/root/.appwrite-rechtliches.key` (chmod 600); der Ausführende (Claude) führt im Portal-Worktree `timeout 300 node --max-old-space-size=1024 scripts/datenschutz-hochladen.mjs --probe` und dann `timeout 300 node --max-old-space-size=1024 scripts/datenschutz-hochladen.mjs --freigabe=<Stand-Datum>` aus; Prüfen in WOMS „Rechtliches“: Karte „Datenschutzerklärung“ zeigt die neue Version als „aktuell“, die alte als „abgelöst“; danach löscht Kenso den Schlüssel in der Konsole. 7. **Schalter umlegen (erst jetzt dürfen Kaltmails raus).** `docker exec webklar-workers node scripts/p6-freischalten.mjs datenschutz --check` ⇒ „Seite ok“; dann `… datenschutz --set` ⇒ „Schalter privacyInteressentenLive: an“ (derselbe Schalter wie P3s `datenschutz-schalter.mjs --an`, aber erst nach der Seitenprüfung; P3s Schritt „Clara einschalten“ ist damit mit erledigt). 8. **Räume verbinden, einen nach dem anderen.** `docker exec webklar-workers node scripts/p6-freischalten.mjs raeume --set handwerk` ⇒ „Verbundene Räume: …handwerk“. Ab der nächsten Nacht (22:00 Suche, 05:30 Auswahl, ab 09:00 Versand) arbeitet Hanna; weitere Räume erst, wenn Zustellung und Antworten gut aussehen (`aerzte`, `selbst`, `laden` mit demselben Befehl). -9. **WOMS.** `git -C /home/webklar/apps/ticket.webklar.com/dev-workers push origin feature/workers:test` (wenn P2/P3 mit dran sind, laut Übersicht §1.1). Prüfen: `curl -s -o /dev/null -w '%{http_code}' https://ticket.webklar.com/api/workers/botleads` ⇒ `401`; in WOMS `/workers` ⇒ Reiter „Bot-Leads“. -10. **Erste Nacht beobachten.** Morgens: Morgenbericht-Mail (06:30); „Bot-Leads“ zeigt rund 7 Einträge mit Grund; im Absender-Postfach „Gesendet“ höchstens 3 Mails, alle zwischen 09:00 und 16:00, jede mit genau einem Link im Text (`https://www.webklar.com`), Fußblock und Abmelde-Link (Klick testen: erscheint der Eintrag in der Sperrliste?); `workersDaily` des Tages zeigt `mailsCold` ≤ 3. Beschwerden und Antworten landen wie in P4 beschrieben im Pult. Zeigt das Board „Versand gestoppt“ (Bounce-Notbremse, H4), sind harte Bounces aufgetreten: Ursache prüfen (falsche Adressen? Absender-Reputation?), dann gibt ein Admin im Einstellungs-Dialog „Versand wieder freigeben“; die Erstmail-Aufträge blieben solange offen. Verweigert das Tor eine Kaltmail aus P6 (Pult „Mail nicht gesendet: …“, z. B. `text_zu_lang` bei einem sehr langen Angebotssatz), gehört der Entwurf geprüft, nicht das Tor gelockert. +9. **WOMS.** `git -C /home/webklar/apps/ticket.webklar.com/dev-workers push origin feature/workers:test` (wenn P2/P3 mit dran sind, laut Übersicht §1.1). Prüfen: `curl -s -o /dev/null -w '%{http_code}' https://ticket.webklar.com/api/workers/botleads` ⇒ `401`; in WOMS `/hustler` ⇒ Reiter „Bot-Leads“. +10. **Erste Nacht beobachten.** Morgens: Morgenbericht-Mail „Hustler-Nachtbericht TT.MM.JJJJ“ (06:30); „Bot-Leads“ zeigt rund 7 Einträge mit Grund; im Absender-Postfach „Gesendet“ höchstens 3 Mails, alle zwischen 09:00 und 16:00, jede mit genau einem Link im Text (`https://www.webklar.com`), Fußblock und Abmelde-Link (Klick testen: erscheint der Eintrag in der Sperrliste?); `workersDaily` des Tages zeigt `mailsCold` ≤ 3. Beschwerden und Antworten landen wie in P4 beschrieben im Pult. Zeigt das Board „Versand gestoppt“ (Bounce-Notbremse, H4), sind harte Bounces aufgetreten: Ursache prüfen (falsche Adressen? Absender-Reputation?), dann gibt ein Admin im Einstellungs-Dialog „Versand wieder freigeben“; die Erstmail-Aufträge blieben solange offen. Verweigert das Tor eine Kaltmail aus P6 (Pult „Mail nicht gesendet: …“, z. B. `text_zu_lang` bei einem sehr langen Angebotssatz), gehört der Entwurf geprüft, nicht das Tor gelockert. 11. **Not-Aus und Rückbau.** Sofort: Pausenschalter in WOMS. Nur Kaltmail aus (stoppt auch Claras Erstantworten, P3): `docker exec webklar-workers node scripts/p6-freischalten.mjs datenschutz --off`. Suche aus: `… raeume --clear`. Beides ist ohne Neustart wirksam. Die Bounce-Notbremse (H4) stoppt den Versand außerdem von selbst, siehe Schritt 10. diff --git a/docs/superpowers/specs/2026-10-17-workers-design.md b/docs/superpowers/specs/2026-10-17-workers-design.md index 4b660b8..35949cc 100644 --- a/docs/superpowers/specs/2026-10-17-workers-design.md +++ b/docs/superpowers/specs/2026-10-17-workers-design.md @@ -1,4 +1,4 @@ -# Workers – Design +# Hustler – Design Stand: 17.10.2026 · Status: freigegeben von Kenso am 17.10.2026 · Ersetzt den pausierten Bereich „Leads“ in WOMS @@ -15,9 +15,11 @@ Unterlagen: ## 1. Worum es geht -WEBklar will, dass eine „Claude-Armee“ nachts selbstständig **Kunden findet und Gespräche mit ihnen aufbaut**, bis ein Deal abgeschlossen ist. Kenso beschreibt den Traum so: Er wacht morgens auf, öffnet Workers und sieht neue Kunden, abgeschlossene Abos und verkaufte Websites. **[Kenso]** +Name in der Oberfläche: **Hustler** [Kenso, 17.10.]; technische Namen bleiben workers*. Das Wort „Worker“ für eine einzelne Figur oder Rolle (`workerId`) bleibt unverändert; was für Mitarbeiter und was für Interessenten sichtbar ist, regelt Abschnitt 11, Punkt 5. -Workers ersetzt den grau geschalteten Menüpunkt „Leads“ in WOMS. Der Name ist bewusst verspielt: Die Workers sind die „Arbeiterklasse“. **[Kenso]** Die Arbeit wird als kleines **Pixel-Spiel auf weißem Grund** gezeigt: ein Hochhaus im Querschnitt, in dem kleine Claude-artige Figuren mit Dokumenten herumlaufen. Ein **Overseer** steuert alle. Jede Figur lässt sich anklicken und zeigt ihr Modell und ihre Aufgabe. **[Kenso]** +WEBklar will, dass eine „Claude-Armee“ nachts selbstständig **Kunden findet und Gespräche mit ihnen aufbaut**, bis ein Deal abgeschlossen ist. Kenso beschreibt den Traum so: Er wacht morgens auf, öffnet Hustler und sieht neue Kunden, abgeschlossene Abos und verkaufte Websites. **[Kenso]** + +Hustler ersetzt den grau geschalteten Menüpunkt „Leads“ in WOMS. Der Name ist bewusst verspielt: Die Hustler sind die „Arbeiterklasse“. **[Kenso]** Die Arbeit wird als kleines **Pixel-Spiel auf weißem Grund** gezeigt: ein Hochhaus im Querschnitt, in dem kleine Claude-artige Figuren mit Dokumenten herumlaufen. Ein **Overseer** steuert alle. Jede Figur lässt sich anklicken und zeigt ihr Modell und ihre Aufgabe. **[Kenso]** **Warum jetzt:** Es gibt jetzt API-Guthaben bei Anthropic, mit dem sich das automatisieren lässt. **[Kenso]** @@ -159,7 +161,7 @@ Es gibt zwei Eingänge, die in denselben Gesprächs- und Abschlussweg münden. - Bleibt der Interessent nach dem Link still, kommt nach 5 Tagen genau eine Erinnerung, danach ist Ruhe. **[Claude]** 4. **Weiterreden.** Clara (Plattform) und Max (Mail) beantworten Fragen im jeweiligen Faden. Theo übernimmt, sobald es ums Kaufen geht. Seine Nachrichten sind Entwürfe: Per Mail verschickt sie Max, auf Plattformen landen sie im Pult. - Er erklärt das Angebot und schickt den Weg ins Portal. - - Er nennt nur Preise und Angebote, die in den Workers-Einstellungen hinterlegt sind. + - Er nennt nur Preise und Angebote, die in den Hustler-Einstellungen hinterlegt sind. 5. **Abschluss.** **[Kenso, 17.10.]** - Der Interessent bekommt einen **„Website übernehmen“-Link ohne Login**, mit einem geheimen Token nur für ihn. - Der Link führt in einen Abo-Checkout mit denselben Zustimmungen wie der normale Portal-Checkout: aktuelle Abo-Bedingungen, Widerruf und Leistungsbeginn, Kundenart Betrieb oder Privat. Dazu kommen die Angaben, die Stripe braucht. @@ -216,7 +218,7 @@ Weitere Branchen und Quellen kommen als **neue Räume** dazu, zum Beispiel Beaut - Jede Aktion landet mit Rollen-Id, Auftrag, Kosten und Link im Ereignisstrom. Daraus werden der Raum und die Spur gezeichnet. **Was man sieht und anklicken kann** (Grundlage ist Entwurf C): -- **Echte Ereignisse statt Fantasie:** Jede Bewegung gehört zu einem echten Auftrag. Gibt es nichts zu tun, sitzen die Workers, trinken Kaffee oder schlafen. **[Claude]** +- **Echte Ereignisse statt Fantasie:** Jede Bewegung gehört zu einem echten Auftrag. Gibt es nichts zu tun, sitzen die Worker, trinken Kaffee oder schlafen. **[Claude]** - **Klick auf einen Worker** öffnet eine Karte mit Name, Rolle, Modell, aktueller Aufgabe, den Zählern von heute (zum Beispiel „Kim: 41 Gesuche gelesen, 2 passend“ oder „Max: 3 von 3 Mails“), Tokens und Kosten. Dazu kommt **seine Spur**: die letzten Aktionen mit Uhrzeit und Links zum Fund, zum Gesprächsfaden oder zur Vorschau. **[Kenso: Rolle nachverfolgbar]** - **Kennzahlen oben, alles echt:** - neue Abos und Umsatz (aus Stripe und dem Portal) @@ -227,7 +229,7 @@ Weitere Branchen und Quellen kommen als **neue Räume** dazu, zum Beispiel Beaut - API-Kosten heute im Vergleich zur Tagesgrenze - **Freigabe-Pult:** eine Liste mit allem, was wartet, jeweils mit Originalanfrage, Entwurf und den Knöpfen „Kopieren & öffnen“, „Als gesendet markieren“, „Verwerfen“ und (bei Mail-Fäden) „Als Mensch antworten“. - Alle WOMS-Mitarbeiter sehen das Pult. Mit „Ich kümmere mich“ übernimmt jemand einen Eintrag. **[Kenso: „ich gucke es mir an und drück auf Senden“; dass alle es sehen, ist mein Vorschlag]** - - Kenso bekommt jeden Morgen eine kurze Mail mit dem Nachtbericht. Bei einer Beschwerde oder rechtlichen Drohung kommt sofort eine Mail. + - Kenso bekommt jeden Morgen eine kurze Mail mit dem Nachtbericht (Betreff „Hustler-Nachtbericht …“; die Anrede darf weiter „hier ist Otto“ lauten). Bei einer Beschwerde oder rechtlichen Drohung kommt sofort eine Mail. - Einträge zu Plattform-Gesuchen gelten nach 3 Tagen als „veraltet“, weil Gesuche schnell vergeben sind. **[Claude]** - Folge-Antworten von Plattformen kommen über die Benachrichtigungs-Mails der Plattform ins Postfach oder werden von Hand ins Pult eingefügt. Clara schreibt dann den nächsten Entwurf. - Den Pausenschalter dürfen alle bedienen. Einstellungen wie Limits, Preise und Räume ändern nur Admins. @@ -279,7 +281,7 @@ Die 14 echten Kunden mit `customerStatus = lead` und der Reiter „Leads“ auf - Nachtarbeit, bei der es nicht auf Sekunden ankommt, läuft über die Batch-API (50 % günstiger). - **Otto** wird nur zu festen Zeitpunkten geweckt. Er läuft nicht dauernd. - **WOMS:** - - Der Menüpunkt „Leads“ (pausiert) wird zu „Workers“, die Route ist `/workers`. + - Der Menüpunkt „Leads“ (pausiert) wird zu „Hustler“, die Route ist `/hustler`. Die alten Adressen `/leads` und `/workers` leiten dorthin um. - Die Seite liest Ereignisse und Kennzahlen live, entweder über Appwrite Realtime oder über Abfragen in kurzen Abständen. - Zeichnung mit Canvas 2D auf Basis der Entwurfs-Sprites oder mit PixiJS, nur auf dieser Seite geladen. Die Wahl liegt beim Builder. - **Vorschauen:** Sie liegen auf der bestehenden Vorschau-Infrastruktur des Portals, öffentlich über einen geheimen Link und mit `noindex`. @@ -288,8 +290,8 @@ Die 14 echten Kunden mit `customerStatus = lead` und der Reiter „Leads“ auf - **Portal:** - Neuer Bestellweg „Website übernehmen“ ohne Login, gebunden an ein Token pro Interessent, mit denselben Zustimmungen und Nachweisen wie der normale Abo-Checkout. **[Kenso]** - Nach der Zahlung legt der Stripe-Webhook den Kunden an (Status `customer`), schaltet den Portal-Zugang frei und schickt die Zugangsdaten. Heute setzt nichts im Code diesen Status nach einem Kauf. - - Workers braucht einen eigenen Zugang zum Portal (eine Dienst-Route mit eigenem Schlüssel). Die Mitarbeiter-JWTs sind wegen 2FA dafür nicht nutzbar. -- **Schlüssel:** Der Anthropic-API-Schlüssel wird in der Console angelegt und gehört **nur** in die Konfiguration des Dienstes, nie in einen Prompt. In der Console wird zusätzlich ein Ausgabenlimit gesetzt. + - Der Workers-Dienst braucht einen eigenen Zugang zum Portal (eine Dienst-Route mit eigenem Schlüssel). Die Mitarbeiter-JWTs sind wegen 2FA dafür nicht nutzbar. +- **Schlüssel:** Der Anthropic-API-Schlüssel wird in der Console angelegt und gehört **nur** in die Konfiguration des Dienstes, nie in einen Prompt. In der Console wird zusätzlich ein Ausgabenlimit von 150 $ im Monat gesetzt (Regel 10). ## 8. Schutzregeln (verbindlich) @@ -304,7 +306,7 @@ Die 14 echten Kunden mit `customerStatus = lead` und der Reiter „Leads“ auf - Abmelden mit einem Klick, auch als `List-Unsubscribe`- und `List-Unsubscribe-Post`-Header. - Schlichter Text mit höchstens einem Link, kein Tracking-Pixel. 4. **Pflichtangaben in jeder Erstnachricht:** - - KI-Hinweis mit Auftraggeber, zum Beispiel „KI-Assistent von WEBklar“. + - KI-Hinweis mit Auftraggeber, zum Beispiel „KI-Assistent von WEBklar“. Der Text nennt weder „Hustler“ noch „Workers“. - Quelle der Kontaktdaten, zum Beispiel „Ihr Impressum“. - Kurzer Datenschutzhinweis mit Link. - Widerspruchshinweis. @@ -314,7 +316,7 @@ Die 14 echten Kunden mit `customerStatus = lead` und der Reiter „Leads“ auf 7. **Keine Umgehung:** kein Captcha- oder Cloudflare-Umgehen, kein Login-Scraping. `robots.txt` und Ratenlimits werden eingehalten. Gesperrte Quellen werden ausgelassen. **[Kenso]** 8. **Keine erfundenen Angebote:** Kein Bot nennt Preise, Rabatte, Termine oder Leistungen, die nicht in den Einstellungen stehen. 9. **Vorschau-Regeln:** eigene Texte und Bilder, Firmenname als Schriftzug, `noindex`, geheimer Link. -10. **Budget:** 10 $ pro Tag und 250 $ pro Monat für die ersten 4 Wochen. Die Grenze gilt im Code und zusätzlich als Ausgabenlimit in einem eigenen Anthropic-Console-Workspace „Workers“. Bei 80 % kommt eine Warnung an Kenso. Ist die Tagesgrenze erreicht, stoppt Otto alle neuen Aufträge, laufende Gespräche dürfen noch beantwortet werden. Nach 4 Wochen wird das Budget nach echten Zahlen neu eingestellt. **[Kenso, 17.10.]** +10. **Budget:** höchstens 150 € im Monat. **[Kenso, 17.10.: max. 150 € im Monat]** Anthropic rechnet in US-Dollar, deshalb gelten im Code **150 $ pro Monat** (`monthlyBudgetUsd`, ≈ 130 €, also unter 150 €, solange 1 € mindestens 1 $ wert ist) und **6 $ pro Tag** (`dailyBudgetUsd`, Ausreißer-Wächter; der Monatsschnitt liegt bei 5 $ pro Tag), zunächst für die ersten 4 Wochen. Die Grenzen gelten im Code und zusätzlich als Ausgabenlimit von 150 $ im Monat in einem eigenen Anthropic-Console-Workspace „Hustler“. Bei 80 % kommt eine Warnung an Kenso. Ist die Tagesgrenze erreicht, stoppt Otto alle neuen Aufträge, laufende Gespräche dürfen noch beantwortet werden. Nach 4 Wochen wird das Budget nach echten Zahlen neu eingestellt; eine Grenze über 150 € im Monat braucht eine neue Entscheidung von Kenso. 11. **Not-Aus:** In WOMS hält ein Pausenschalter alles sofort an. ## 9. Kosten (Schätzung) @@ -329,6 +331,8 @@ Kosten pro Posten: - Gespräche: Cent-Beträge pro Faden. - Separater Mail-Anbieter mit Domain: wenige Euro im Monat. +Diese Schätzung muss in die Grenzen aus Regel 10 passen: 150 $ im Monat und 6 $ am Tag **[Kenso, 17.10.: max. 150 € im Monat]**. Eine Nacht mit Suche und Bewertung plus eine Opus-Vorschau kann die Tagesgrenze erreichen. Dann stoppt Otto neue Aufträge, und die Arbeit läuft am nächsten Tag weiter, statt die Grenze zu überschreiten. + Der Probe-Scout hat bundesweit nur eine Handvoll echter Anfragen pro Woche gefunden. Die Etage „Anfragen“ wird also eher wenig Arbeit haben, die Etage „Ohne Website“ mehr. ## 10. Woran wir Erfolg messen @@ -349,12 +353,17 @@ Der Probe-Scout hat bundesweit nur eine Handvoll echter Anfragen pro Woche gefun - Wartung für 50 € nur auf Nachfrage. - Keine Rabatte, solange nichts in den Einstellungen steht. - Der Satz wird als Startwert der Angebotstexte in den Einstellungen hinterlegt. -3. **Budget:** 10 $ pro Tag und 250 $ pro Monat für die ersten 4 Wochen, mit eigenem Console-Workspace und Warnung bei 80 % (Regel 10). **[Kenso]** +3. **Budget:** höchstens 150 € im Monat. Im Code 150 $ pro Monat und 6 $ pro Tag (Ausreißer-Wächter) für die ersten 4 Wochen, mit eigenem Console-Workspace (Ausgabenlimit 150 $ im Monat) und Warnung bei 80 % (Regel 10). **[Kenso, 17.10.: max. 150 € im Monat]** 4. **Absender-Domain und Mail-Anbieter werden jetzt besorgt,** damit die Aufwärmphase parallel zum Bau läuft. Vor dem Kauf werden die Nutzungsbedingungen des Anbieters geprüft. Claude schlägt Domain und Anbieter vor, Kenso kauft. **[Kenso]** +5. **Name:** Der Bereich heißt in der Oberfläche **Hustler**, nicht „Workers“. **[Kenso, 17.10.]** + - Das gilt für alles, was Mitarbeiter sehen: Menüpunkt, Seitentitel und Überschrift, Browser-Tab-Titel, Route `/hustler`, Titel des Einstellungsdialogs, Leerzustände, Hinweismeldungen (Toasts), Fehlertexte, die Morgenbericht-Mail (Betreff „Hustler-Nachtbericht …“, die Anrede darf weiter „hier ist Otto“ lauten) und Team-Hinweismails (Betreff-Präfix „[Hustler]“). + - `/leads` und `/workers` leiten beide auf `/hustler` um. Links mit Reiter, zum Beispiel zu einem Bot-Lead, lauten `/hustler?tab=botleads&bot=<id>`. + - Technische Namen bleiben unverändert: Sammlungen `workers*`, Dienst und Container `webklar-workers`, Repo `knso/workers-dienst`, API-Pfade `/api/workers/*`, Datei- und Komponentennamen (`WorkersPage.jsx`, `workersApi.js`, `workersRoster.js` …), CSS-Präfix `wk-`, Umgebungsvariablen `WORKERS_*`, `workerId` und die Ereignisarten. + - Texte an Interessenten und Kunden (Mails, Antworten, Übernahme-Seite, Vorschau-Seiten) nennen weder „Hustler“ noch „Workers“. Der Absender spricht als „KI-Assistent(in) von WEBklar“. ## 12. Was vorbereitet werden muss (kann kein Bot) -- Anthropic Console: Workspace „Workers“ anlegen, API-Schlüssel erzeugen, Ausgabenlimit setzen. Den Schlüssel bekommt nur der Dienst. +- Anthropic Console: Workspace „Hustler“ anlegen, API-Schlüssel erzeugen, Ausgabenlimit auf 150 $ im Monat setzen. Den Schlüssel bekommt nur der Dienst. - Absender-Domain und Postfächer: eins zum Senden, eins als Eingang für Plattform-Benachrichtigungen. - Kostenlose Konten auf Kleinanzeigen, freelancermap und freelance.de unter WEBklar. Dort Suchaufträge anlegen, die an das Eingangs-Postfach gehen. - Den neuen Datenschutz-Abschnitt „Interessenten“ freigeben.