Files
ClaudeandClaude Opus 5.5 2470f05acb Hustler-Pixelwelt T12: Prüfstand auf die Pixelwelt umgestellt (Szenarien A–M), Handy-Kopfzeile zweireihig
Prüfstand: entry.jsx zeigt nur noch die echte Seite WorkersPage (BrowserRouter, Adresse mit ?tab= lesbar); shot.cjs neu mit den Szenarien A–M
(Hochhaus, Regie, Handy, weniger Bewegung, Notbremse, Ausfall, Detail, Etagen, Pause, Pult P3, Pult P4, Keller, Handy Pult/Keller),
harte Prüfungen und Bildern mit Szenario-Vorsilbe; Befunde an der App stehen getrennt von den Verstößen. Skript-API: Gründe der Bot-Leads
wörtlich wie im Dienst. Kopfzeile am Handy: zwei Reihen (Geld + Pause/Zahnrad, dann die fünf Kennzahlen), schmaler Panel-Rand, 40-px-Knöpfe,
Querformat unter 500 px Höhe in einer Reihe; hustlerKopf.test um die Zusicherungen dazu ergänzt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 09:32:33 +00:00

950 lines
52 KiB
JavaScript

// Skript-API des Prüfstands: dieselbe Form wie createWorkersApi (src/lib/workersApi.js), aber alles kommt aus einem festen Drehbuch.
// Kein Netz, kein Appwrite, keine echte Zeit: die Uhr `now` (Millisekunden) wird von außen gegeben, die Basiszeit `base` ebenso.
// Die Antworten verhalten sich wie der Dienst P1: events(after) liefert aufsteigend alle fälligen Einträge ab `after - 5 s` (Überlappung,
// die Entdopplung übernimmt der Feed), höchstens `limit` Stück, die ältesten zuerst; nichts aus der Zukunft.
// Freigabe-Pult und Interessent (P3): pult, pultAction und prospect antworten mit festen Daten in der Form der Team-Routen des Dienstes
// (workers-dev/src/api/anfragenRoutes.js) und verhalten sich wie dessen Regeln (pult.js): übernehmen, als gesendet markieren, verwerfen,
// erledigen, 409 mit deutschem Satz bei fremder Übernahme. Die Schalter `faults` lassen einzelne Aktionen mit einem Status und einem
// deutschen Satz scheitern; `pultCalls`, `prospectCalls` und `pauseCalls` halten fest, was die Oberfläche geschickt hat.
// Gespräche und Werkstatt (P4): pultAction kennt auch reply (als Mensch antworten), approve-preview (Vorschau freigeben), start-build
// (Bau starten) und paste (Antwort des Kunden einfügen), mit den Regeln und Sätzen aus workers-dev/src/gespraeche/pultMail.js und
// paste.js (Prüfreihenfolge, 400/404/409). Eigener Satz Daten mit `pult: 'p4'` (p4Fixtures); die P3-Daten bleiben unberührt. Die Schalter
// `faults.reply`, `.approve-preview`, `.start-build` und `.paste` lassen eine Aktion mit { status, message } scheitern (409 mit deutschem
// Satz, 413 „Anfrage zu groß.“, 400), `pultDelayMs` hält jede Pult-Aktion so lange auf (Knöpfe sind währenddessen gesperrt).
// Bot-Leads (P6): botleads({ room, q, page, id }) antwortet mit 60 festen Einträgen in der Form von GET /botleads (workers-dev-p6/src/api/botleads.js:
// items, total, pageSize 50, Telefon nie bei Heilberufen, gekürzte Stubs nur mit id); Schalter und Fixtures siehe „Bot-Leads (P6)“ weiter unten.
import { MODEL_IDS, ROLES, SEARCH_ROOMS, roleOf } from '../../src/lib/workersRoster.js'
import { indexWorkers, reduceEvents } from '../../src/lib/workersEvents.js'
const DEFAULT_BASE = Date.parse('2026-10-17T20:00:00.000Z')
const OVERLAP_MS = 5000
const EVENTS_MAX = 200
const HALT_REASON = 'Zwei harte Bounces in den letzten 10 Mails.'
// Drehbuch: [{ at: Sekunden nach dem Start, event }]. Deckt alle 26 Ereignisarten und 16 Worker ab; das erste Ereignis liegt nicht vor 2 s,
// damit die Bühne vorher ruhig steht. Die Zeiten sind so verteilt, dass ein Abruf (alle 5 s) höchstens fünf bis sechs Ereignisse bringt.
const step = (at, workerId, kind, text, extra = {}) => ({ at, event: { workerId, kind, text, ...extra } })
export const SCRIPT = Object.freeze([
step(2.0, 'otto', 'auftrag', 'Rita sucht Anfragen der Nacht', { to: 'rita' }),
step(2.6, 'otto', 'auftrag', 'Ali sucht Praxen ohne Website', { to: 'ali' }),
step(4.0, 'rita', 'gelesen', 'Netz-Radar: 12 Beiträge gelesen', { meta: { count: 12 } }),
step(5.0, 'rita', 'fund', 'Neue Anfrage gefunden'),
step(6.2, 'ali', 'gelesen', 'Verzeichnis: 7 Praxen gelesen', { meta: { count: 7 } }),
step(7.2, 'ali', 'fund', 'Praxis ohne Website gefunden'),
step(8.2, 'arne', 'doppelt', 'Eine Praxis war schon im Register'),
step(9.4, 'rita', 'uebergabe', 'Anfrage an Bea übergeben', { to: 'bea' }),
step(10.6, 'ali', 'uebergabe', 'Praxis an Bea übergeben', { to: 'bea' }),
step(12.0, 'bea', 'bewertet', 'Anfrage mit 87 Punkten bewertet', { meta: { score: 87 } }),
step(13.2, 'bea', 'top3', 'In die Top 3 aufgenommen'),
step(14.0, 'arne', 'botlead', 'Kontakt in die Bot-Lead-Liste abgelegt'),
step(15.0, 'lea', 'leerlauf', 'Wartet auf den nächsten Auftrag'),
step(16.0, 'hanna', 'fund', 'Betrieb ohne Website gefunden'),
step(17.0, 'bea', 'uebergabe', 'Top 3 an Clara übergeben', { to: 'clara' }),
step(18.4, 'clara', 'entwurf', 'Antwort auf eine Anfrage entworfen'),
step(19.4, 'clara', 'pult', 'Entwurf liegt im Pult'),
step(20.6, 'max', 'entwurf', 'Erste Mail entworfen'),
step(21.6, 'max', 'gesendet', 'Mail verschickt'),
step(23.0, 'nora', 'eingang', 'Antwort eingegangen'),
step(24.0, 'nora', 'sortiert', 'Antwort als „Ja“ eingeordnet', { meta: { classification: 'ja' } }),
step(25.4, 'bob', 'bau_start', 'Vorschau wird gebaut'),
step(27.0, 'bob', 'bau_fertig', 'Vorschau ist gebaut'),
step(28.4, 'paul', 'pruefung_zurueck', 'Prüfung: Runde 1 zurück an Bob', { to: 'bob', meta: { round: 1 } }),
step(30.0, 'bob', 'bau_fertig', 'Vorschau überarbeitet'),
step(31.2, 'paul', 'pruefung_ok', 'Prüfung bestanden'),
step(32.4, 'max', 'vorschau_link', 'Vorschau-Link verschickt', { link: 'https://example.org/vorschau' }),
step(33.6, 'theo', 'angebot', 'Kauf-Link erzeugt'),
step(34.8, 'system', 'deal', 'Bezahlt: Hosting-Abo', { meta: { offerId: 'offer-1', customerId: 'customer-1', productKey: 'hosting', amountCents: 2000, kundenart: 'privat' } }),
step(36.0, 'system', 'gesperrt', 'Kontakt auf die Sperrliste gesetzt'),
step(37.2, 'otto', 'budget_warnung', 'Tagesbudget zu 80 % erreicht'),
step(38.4, 'otto', 'budget_stopp', 'Tagesbudget erreicht'),
step(39.6, 'sven', 'fehler', 'Quelle antwortet nicht'),
step(40.8, 'system', 'pause', 'Pause beendet', { meta: { paused: false } }),
// 12 Ziffern plus „ gelesen“ ergeben genau die längste erlaubte Sprechblase (20 Zeichen)
step(42.0, 'nora', 'gelesen', 'Posteingang gelesen', { meta: { count: 123456789012 } }),
step(43.4, 'bianca', 'bau_start', 'Zweite Vorschau wird gebaut'),
step(44.6, 'ben', 'leerlauf', 'Wartet auf einen Bauplan'),
step(45.8, 'sven', 'leerlauf', 'Quelle wieder erreichbar'),
])
// Zustand vor dem ersten Ereignis: gemischte Status; die drei nicht angeschlossenen Such-Worker (kim, pia, finn) sind grau
const NOT_CONNECTED = Object.freeze(['kim', 'pia', 'finn'])
const START = Object.freeze({
otto: ['arbeitet', 'Plant die Nacht'],
rita: ['arbeitet', 'Sucht frische Anfragen'],
kim: ['grau', ''],
pia: ['grau', ''],
finn: ['grau', ''],
ali: ['wartet', 'Wartet auf einen Auftrag'],
hanna: ['leerlauf', ''],
sven: ['leerlauf', ''],
lea: ['arbeitet', 'Liest Läden und Gastro'],
bea: ['wartet', 'Wartet auf Kandidaten'],
max: ['leerlauf', ''],
clara: ['arbeitet', 'Schreibt Antworten'],
nora: ['arbeitet', 'Sortiert den Posteingang'],
theo: ['leerlauf', ''],
bob: ['arbeitet', 'Baut eine Vorschau'],
bianca: ['wartet', 'Wartet auf einen Bauplan'],
ben: ['leerlauf', ''],
paul: ['leerlauf', ''],
arne: ['leerlauf', ''],
})
// Tageszähler je Worker (Schlüssel wie in counterLines)
const SCOUT_COUNTERS = Object.freeze({ gelesen: 24, gefunden: 3, passend: 2 })
const COUNTERS = Object.freeze({
rita: SCOUT_COUNTERS,
kim: Object.freeze({}),
pia: Object.freeze({}),
finn: Object.freeze({}),
ali: SCOUT_COUNTERS,
hanna: SCOUT_COUNTERS,
sven: SCOUT_COUNTERS,
lea: SCOUT_COUNTERS,
bea: Object.freeze({ bewertet: 9 }),
max: Object.freeze({ entwuerfe: 4, gesendet: 3 }),
clara: Object.freeze({ entwuerfe: 2 }),
theo: Object.freeze({ entwuerfe: 1 }),
nora: Object.freeze({ gelesen: 5 }),
bob: Object.freeze({ gebaut: 1 }),
bianca: Object.freeze({ gebaut: 1 }),
ben: Object.freeze({ gebaut: 0 }),
paul: Object.freeze({ geprueft: 2 }),
})
// Kennzahlen wie in den Test-Fixtures
const KPIS = Object.freeze({
newAbosMonth: 3,
mrrNewEur: 60,
dealsMonth: 3,
openThreads: 7,
pultOpen: 2,
botLeads: 112,
apiTodayUsd: 5.1,
apiMonthUsd: 50,
dailyBudgetUsd: 6,
monthlyBudgetUsd: 150,
})
// Spur: zwölf Einträge (neueste zuerst), darunter ein unsicherer Link (nur Text), ein sicherer externer und ein interner Link
const TRACE = Object.freeze([
['gelesen', 'Quelle geprüft', null],
['fund', 'Fund notiert', 'https://example.org/fund'],
['fehler', 'Link mit Skript abgelehnt', 'javascript:alert(1)'],
['uebergabe', 'Zur Übersicht der Hustler', '/hustler?tab=hochhaus'],
['gelesen', 'Beiträge der letzten Stunde gelesen', null],
['fund', 'Zweiten Kandidaten gefunden', null],
['leerlauf', 'Kurze Pause', null],
['gelesen', 'Neue Quelle gelesen', null],
['uebergabe', 'Dokument weitergereicht', null],
['fund', 'Kandidat notiert', null],
['gelesen', 'Postfach geleert', null],
['auftrag', 'Auftrag von Otto angenommen', null],
])
// ---------- Freigabe-Pult und Interessenten (P3) ----------
// Form wie die Team-Routen des Dienstes (workers-dev/src/api/anfragenRoutes.js): Pult-Einträge mit allen Feldern aus §4.11 (nur die gesetzten),
// dazu `prospect` als Kurzfassung (kein Telefon, kein Anfragetext); die Ansicht des Interessenten nur mit den Feldern der Whitelist.
// Telefonnummern gibt es in diesen Daten nirgends: die Prüfung "kein Telefon im Bild" ist damit eine Prüfung der Oberfläche, nicht der Daten.
const ME = 'mensch:u-me' // der angemeldete Prüfstand-Nutzer (stub-auth.js: Kennung u-me, Name Kenso)
const COLLEAGUE = 'mensch:u-kollege' // der Kollege, den stub-employees.js als „Justin“ kennt
const HOUR_MS = 3600 * 1000
const DAY_MS = 24 * HOUR_MS
const PULT_STATUS = ['offen', 'in_arbeit', 'gesendet', 'verworfen', 'veraltet', 'erledigt']
const PULT_LIMIT = 50
const STATUS_ERROR = `Der Status ist ungültig. Erlaubt sind: ${PULT_STATUS.join(', ')}.`
const REPLY_KINDS = ['plattform_antwort', 'mail_antwort']
// Felder der Kurzfassung in der Liste (kurzSicht) und der Ansicht des Interessenten (interessentSicht): kein Telefon, keine Nachweise
const PROSPECT_SHORT = ['entrance', 'name', 'source', 'sourceUrl', 'city', 'postalCode', 'email', 'score', 'scoreReason', 'requestedAt', 'status']
const PROSPECT_VIEW = [
'entrance', 'room', 'foundBy', 'source', 'sourceUrl', 'foundAt', 'name', 'contactName', 'email', 'city', 'postalCode',
'requestText', 'requestedAt', 'score', 'scoreReason', 'status', 'threadId', 'previewId', 'offerId', 'customerId',
]
const pick = (record, keys) => Object.fromEntries(keys.filter((key) => record[key] !== undefined).map((key) => [key, record[key]]))
const HAIR_URL = 'https://www.kleinanzeigen.de/s-anzeige/website-fuer-friseursalon/2870000001-1-1'
const WEBER_URL = 'https://www.kleinanzeigen.de/s-anzeige/homepage-handwerksbetrieb/2870000002-1-1'
const NORD_URL = 'https://www.freelancermap.de/projekt/wordpress-relaunch-agentur'
const FOTO_URL = 'https://www.kleinanzeigen.de/s-anzeige/portfolio-seite-fotograf/2870000003-1-1'
const SV_URL = 'https://www.freelancermap.de/projekt/website-sportverein'
// Lange Wörter ohne Leerzeichen: sie müssen am Handy umbrechen, statt die Seite zu verbreitern
const LONG_WORD = 'https://www.sv-namborn.example/vereinsleben/abteilungen/fussball/jugend/spielplan-und-trainingszeiten-saison-2026-2027-alle-mannschaften'
function pultFixtures(base) {
const at = (ms) => isoOf(base + ms)
const items = [
{
$id: 'pult-mail',
kind: 'mail_antwort',
prospectId: 'p-mail',
threadId: 'thr-mail',
title: 'Anfrage: Vereinswebsite für den SV Namborn',
originalText: `Hallo,\nwir suchen jemanden, der unsere Vereinsseite neu macht. Siehe auch ${LONG_WORD}\nBudget bis 1.500 Euro.`,
draft: 'Guten Tag Herr Beispiel,\n\nvielen Dank für Ihre Anfrage. Wir bauen Ihnen kostenlos eine erste Vorschau Ihrer neuen Vereinsseite.\n\nBeste Grüße\nKenso',
status: 'offen',
createdAt: at(-2.5 * HOUR_MS),
},
{
$id: 'pult-plat',
kind: 'plattform_antwort',
prospectId: 'p-plat',
title: 'Kleinanzeigen: Website für Friseursalon gesucht',
originalText: 'Suche jemanden für eine einfache Website mit Preisliste und Terminanfrage.\nBitte mit Referenzen melden.',
draft: 'Hallo, ich habe Ihre Anzeige gesehen. Wir bauen Ihnen kostenlos eine erste Vorschau, gern mit Preisliste und Terminanfrage.',
sourceUrl: HAIR_URL,
status: 'offen',
createdAt: at(-5 * HOUR_MS),
staleAt: at(20 * HOUR_MS),
},
{
$id: 'pult-kollege',
kind: 'plattform_antwort',
prospectId: 'p-kollege',
title: 'Kleinanzeigen: Homepage für Handwerksbetrieb',
originalText: 'Brauche eine Homepage für meinen Meisterbetrieb.',
draft: 'Guten Tag, gern bauen wir Ihnen eine erste Vorschau Ihrer Homepage, ohne Kosten und ohne Verpflichtung.',
sourceUrl: WEBER_URL,
status: 'in_arbeit',
claimedBy: COLLEAGUE,
claimedAt: at(-1 * HOUR_MS),
createdAt: at(-8 * HOUR_MS),
staleAt: at(3 * DAY_MS),
},
{
$id: 'pult-unsicher',
kind: 'unsicher',
title: 'Unsichere Antwort: bitte prüfen',
originalText: 'Nora war sich bei der Einordnung einer Antwort nicht sicher und bittet um einen Blick.',
status: 'offen',
createdAt: at(-26 * HOUR_MS),
},
{
$id: 'pult-alt',
kind: 'plattform_antwort',
prospectId: 'p-alt',
title: 'freelancermap: WordPress-Relaunch',
originalText: 'Relaunch einer bestehenden WordPress-Seite.',
draft: 'Guten Tag, wir übernehmen den Relaunch gern und zeigen vorab eine Vorschau.',
sourceUrl: NORD_URL,
status: 'veraltet',
createdAt: at(-6 * DAY_MS),
staleAt: at(-3 * DAY_MS),
},
{
$id: 'pult-gesendet',
kind: 'plattform_antwort',
prospectId: 'p-gesendet',
title: 'Kleinanzeigen: Fotograf sucht Portfolio-Seite',
draft: 'Hallo, wir bauen Ihnen gern eine Portfolio-Seite. Eine erste Vorschau gibt es kostenlos.',
sourceUrl: FOTO_URL,
status: 'gesendet',
claimedBy: ME,
claimedAt: at(-2 * DAY_MS + HOUR_MS),
doneBy: ME,
doneAt: at(-1 * DAY_MS),
createdAt: at(-2 * DAY_MS),
},
{
$id: 'pult-erledigt',
kind: 'beschwerde',
title: 'Beschwerde über eine Vorschau',
originalText: 'Der Kunde findet die Vorschau unvollständig.',
status: 'erledigt',
doneBy: COLLEAGUE,
doneAt: at(-3 * DAY_MS),
createdAt: at(-4 * DAY_MS),
},
]
// Interessenten: Kurzfassung (kurzSicht) und Zusatzfelder der Ansicht (interessentSicht); nur Felder aus der Whitelist
const prospects = {
'p-mail': {
entrance: 'anfrage', room: 'anfragen', foundBy: 'rita', source: 'freelancermap-alarm', sourceUrl: SV_URL,
foundAt: at(-3 * HOUR_MS), name: 'SV Namborn', contactName: 'Hans Beispiel', email: 'vorstand@sv-namborn.example',
city: 'Namborn', postalCode: '66640',
requestText: `Hallo,\nwir suchen jemanden, der unsere Vereinsseite neu macht.\n\nBudget bis 1.500 Euro, Start im November. Mehr dazu: ${LONG_WORD}`,
requestedAt: at(-3 * HOUR_MS), score: 84, scoreReason: 'Klarer Auftrag, Budget genannt, Region passt.',
status: 'bewertet', threadId: 'thr-mail', previewId: 'prev-mail',
},
'p-plat': {
entrance: 'anfrage', room: 'anfragen', foundBy: 'rita', source: 'kleinanzeigen-alarm', sourceUrl: HAIR_URL,
foundAt: at(-5 * HOUR_MS), name: 'Friseursalon Haarmonie', city: 'Kaiserslautern', postalCode: '67655',
requestText: 'Suche jemanden für eine einfache Website mit Preisliste und Terminanfrage.\nBitte mit Referenzen melden.',
requestedAt: at(-5 * HOUR_MS), score: 72, scoreReason: 'Passt zum Angebot, Budget unklar.', status: 'bewertet',
},
'p-kollege': {
entrance: 'anfrage', room: 'anfragen', foundBy: 'rita', source: 'kleinanzeigen-alarm', sourceUrl: WEBER_URL,
foundAt: at(-8 * HOUR_MS), name: 'Meisterbetrieb Weber', city: 'Landstuhl', postalCode: '66849', status: 'gefunden',
},
'p-alt': {
entrance: 'anfrage', room: 'anfragen', foundBy: 'rita', source: 'freelancermap-alarm', sourceUrl: NORD_URL,
foundAt: at(-6 * DAY_MS), name: 'Agentur Nord', city: 'Hamburg', postalCode: '20095', score: 65, status: 'bewertet',
scoreReason: 'Relaunch, aber weit weg.',
},
'p-gesendet': {
entrance: 'anfrage', room: 'anfragen', foundBy: 'rita', source: 'kleinanzeigen-alarm', sourceUrl: FOTO_URL,
foundAt: at(-2 * DAY_MS), name: 'Foto Lichtblick', city: 'Mainz', postalCode: '55116', score: 78, status: 'kontaktiert',
},
}
// Faden, Nachrichten und Vorschau gibt es nur für den Mail-Interessenten (zwei Nachrichten: eine eingegangene, eine von einem Kollegen)
const threads = {
'thr-mail': {
$id: 'thr-mail', prospectId: 'p-mail', channel: 'mail', toEmail: 'vorstand@sv-namborn.example', subject: 'Re: SV Namborn',
status: 'wartet_auf_uns', lastInboundAt: at(-1.5 * HOUR_MS), followupSent: false, reminderSent: false, createdAt: at(-3 * HOUR_MS),
},
}
const messages = {
'thr-mail': [
{
$id: 'msg-1', direction: 'in', author: 'kunde', subject: 'Anfrage Vereinswebsite', channel: 'mail', classification: 'frage',
body: `Guten Tag,\nkönnen Sie uns eine Vorschau zeigen?\nDie Seite ist heute unter ${LONG_WORD} erreichbar.`,
sentAt: at(-3 * HOUR_MS), createdAt: at(-3 * HOUR_MS),
},
{
$id: 'msg-2', direction: 'out', author: COLLEAGUE, subject: 'Re: SV Namborn', channel: 'mail',
body: 'Gern. Wir melden uns mit einer ersten Vorschau.',
sentAt: at(-2 * HOUR_MS), createdAt: at(-2 * HOUR_MS),
},
],
}
const previews = {
'prev-mail': { $id: 'prev-mail', prospectId: 'p-mail', builder: 'bob', status: 'fertig', round: 1, createdAt: at(-1 * HOUR_MS), deleteAfter: at(60 * DAY_MS) },
}
return { items, prospects, threads, messages, previews }
}
const clone = (v) => JSON.parse(JSON.stringify(v))
const isoOf = (ms) => new Date(ms).toISOString()
// ---------- Gespräche und Werkstatt (P4) ----------
// Eigener Satz Pult-Einträge für die Szenarien zu „Als Mensch antworten“, „Vorschau freigeben“, „Vorschau bauen“ und „Antwort des Kunden
// einfügen“. Nur Felder aus den Whitelists; kein Telefon. Wartet: fünf Einträge (Mail-Antwort, zwei Vorschau-Freigaben, unsicher, ein
// Eintrag, den ein Kollege hält). Erledigt: zwei gesendete Plattform-Antworten desselben Interessenten (Standard-Platz und Platz „angebot“),
// die Zeilen müssen unabhängig voneinander bleiben.
const PREVIEW_OK = 'https://entwurf.project.webklar.com/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/'
const PREVIEW_BAD = 'http://entwurf.project.webklar.com/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB/' // kein https: darf kein Link werden
const REPLY_TEXT_MAX = 8000
const REPLY_ACTIONS = ['reply', 'approve-preview', 'start-build', 'paste']
function p4Fixtures(base) {
const at = (ms) => isoOf(base + ms)
const items = [
{
$id: 'p4-mail',
kind: 'mail_antwort',
prospectId: 'p4-mail',
threadId: 'thr-p4-mail',
title: 'Anfrage: Vereinswebsite für den SV Namborn',
originalText: 'Hallo,\nwir suchen jemanden, der unsere Vereinsseite neu macht.',
draft: 'Guten Tag Herr Beispiel,\n\nvielen Dank für Ihre Anfrage. Wir bauen Ihnen kostenlos eine erste Vorschau Ihrer neuen Vereinsseite.\n\nBeste Grüße\nKenso',
status: 'offen',
createdAt: at(-1 * HOUR_MS),
},
{
$id: 'p4-freigabe',
kind: 'vorschau_freigabe',
prospectId: 'p4-vorschau',
previewId: 'prev-p4',
title: 'Vorschau freigeben: Friseur Muster',
draft: 'Guten Tag Frau Muster,\n\nhier ist Ihre erste Vorschau.',
sourceUrl: PREVIEW_OK,
status: 'offen',
createdAt: at(-2 * HOUR_MS),
},
{
$id: 'p4-freigabe-falsch',
kind: 'vorschau_freigabe',
prospectId: 'p4-vorschau2',
previewId: 'prev-p4-2',
title: 'Vorschau freigeben: Bäckerei Korn',
draft: 'Guten Tag, hier ist Ihre erste Vorschau.',
sourceUrl: PREVIEW_BAD,
status: 'offen',
createdAt: at(-3 * HOUR_MS),
},
{
$id: 'p4-unsicher',
kind: 'unsicher',
prospectId: 'p4-unsicher',
title: 'Unsichere Antwort: Ist das ein Ja?',
originalText: 'Nora war sich bei dieser Antwort nicht sicher: „Ja, aber erst nach dem Urlaub, vielleicht.“',
status: 'offen',
createdAt: at(-4 * HOUR_MS),
},
{
$id: 'p4-kollege',
kind: 'mail_antwort',
prospectId: 'p4-kollege',
title: 'Anfrage: Tischlerei Holz',
draft: 'Guten Tag, wir melden uns mit einer Vorschau.',
status: 'in_arbeit',
claimedBy: COLLEAGUE,
claimedAt: at(-1 * HOUR_MS),
createdAt: at(-5 * HOUR_MS),
},
{
$id: 'p4-plat-gesendet',
kind: 'plattform_antwort',
prospectId: 'p4-plat',
title: 'Kleinanzeigen: Website für Fotostudio',
draft: 'Hallo, wir bauen Ihnen gern eine erste Vorschau.',
sourceUrl: FOTO_URL,
status: 'gesendet',
claimedBy: ME,
claimedAt: at(-2 * DAY_MS + HOUR_MS),
doneBy: ME,
doneAt: at(-1 * DAY_MS),
createdAt: at(-2 * DAY_MS),
},
{
$id: 'p4-plat-angebot',
kind: 'plattform_antwort',
prospectId: 'p4-plat',
title: 'Kleinanzeigen: Website für Fotostudio (Angebot)',
draft: 'Hallo, hier ist das Angebot zu Ihrer Vorschau.',
sourceUrl: FOTO_URL,
status: 'gesendet',
claimedBy: ME,
claimedAt: at(-4 * HOUR_MS),
doneBy: ME,
doneAt: at(-3 * HOUR_MS),
createdAt: at(-1 * DAY_MS),
},
]
const common = { entrance: 'anfrage', room: 'anfragen', foundBy: 'rita' }
const prospects = {
'p4-mail': { ...common, source: 'freelancermap-alarm', sourceUrl: SV_URL, foundAt: at(-3 * HOUR_MS), name: 'SV Namborn', contactName: 'Hans Beispiel', email: 'vorstand@sv-namborn.example', city: 'Namborn', postalCode: '66640', score: 84, status: 'bewertet', threadId: 'thr-p4-mail' },
'p4-vorschau': { ...common, source: 'freelancermap-alarm', foundAt: at(-1 * DAY_MS), name: 'Friseur Muster', email: 'info@friseur-muster.example', city: 'Kaiserslautern', postalCode: '67655', score: 80, status: 'vorschau_bau', previewId: 'prev-p4' },
'p4-vorschau2': { ...common, source: 'freelancermap-alarm', foundAt: at(-1 * DAY_MS), name: 'Bäckerei Korn', email: 'info@baeckerei-korn.example', city: 'Landstuhl', postalCode: '66849', score: 75, status: 'vorschau_bau', previewId: 'prev-p4-2' },
'p4-unsicher': { ...common, source: 'freelancermap-alarm', foundAt: at(-2 * DAY_MS), name: 'Gasthaus Sonne', email: 'wirt@gasthaus-sonne.example', city: 'Pirmasens', postalCode: '66953', score: 70, status: 'gespraech' },
'p4-kollege': { ...common, source: 'freelancermap-alarm', foundAt: at(-1 * DAY_MS), name: 'Tischlerei Holz', email: 'holz@tischlerei-holz.example', city: 'Zweibrücken', postalCode: '66482', status: 'gespraech' },
'p4-plat': { ...common, source: 'kleinanzeigen-alarm', sourceUrl: FOTO_URL, foundAt: at(-3 * DAY_MS), name: 'Foto Lichtblick', city: 'Mainz', postalCode: '55116', score: 78, status: 'kontaktiert' },
}
const threads = {
'thr-p4-mail': { $id: 'thr-p4-mail', prospectId: 'p4-mail', channel: 'mail', toEmail: 'vorstand@sv-namborn.example', subject: 'Re: SV Namborn', status: 'wartet_auf_uns', followupSent: false, reminderSent: false, createdAt: at(-3 * HOUR_MS) },
}
const previews = {
'prev-p4': { $id: 'prev-p4', prospectId: 'p4-vorschau', builder: 'bob', status: 'freigabe', round: 1, createdAt: at(-4 * HOUR_MS) },
'prev-p4-2': { $id: 'prev-p4-2', prospectId: 'p4-vorschau2', builder: 'bob', status: 'freigabe', round: 1, createdAt: at(-5 * HOUR_MS) },
}
return { items, prospects, threads, messages: {}, previews }
}
// Standardwerte der Einstellungen (Übersicht §4.1)
function defaultSettings(halted) {
return {
paused: false,
dailyBudgetUsd: 6,
monthlyBudgetUsd: 150,
warnPct: 80,
mailColdPerDay: 3,
mailReplyCapPerDay: 15,
sendWindowStart: '09:00',
sendWindowEnd: '16:00',
sendWeekdaysOnly: true,
runsPerNight: 1,
candidatesPerRun: 10,
activeRooms: [...SEARCH_ROOMS],
connectedRooms: SEARCH_ROOMS.filter((room) => !NOT_CONNECTED.includes(ROLES.find((r) => r.room === room).workerId)),
regions: ['Rheinland-Pfalz'],
previewGate: 'erste_n',
previewGateN: 10,
previewsSent: 0,
offerText: 'Wir bauen Ihnen kostenlos eine erste Vorschau Ihrer neuen Website. Gefällt sie Ihnen, betreuen wir sie für 20 € im Monat.',
offerProductKey: 'hosting',
pultStaleDays: 3,
retentionDays: 90,
followupDays: 7,
previewReminderDays: 5,
previewDeleteDays: 60,
reportEmail: 'bericht@example.org',
privacyInteressentenLive: false,
sendingHalted: halted,
sendingHaltedReason: halted ? HALT_REASON : '',
updatedAt: null,
updatedBy: 'system',
}
}
// ---------- Bot-Leads (P6) ----------
// GET /botleads in der Form des Dienstes (workers-dev-p6/src/api/botleads.js, listBotLeads): { items, total, pageSize }, neueste zuerst, 50 je Seite.
// Der Speicher unten hält die Dokumente (Interessenten der Etage „Ohne Website“, status 'botlead') mit allem, was der Dienst kennt; nach außen geht wie
// im Dienst nur botleadEntry(): feste Auswahl der Felder, Telefon nur mit Quelle und nie bei einem Heilberuf (auch nicht aus einem Altbestand, in dem die
// Nummer noch steht; leere Zeichenkette statt null, wie im Dienst), Zeit mit „Z“. Gekürzte Stubs ('[gekürzt]', purgedAt) fehlen in der Liste und in
// total und kommen nur mit id (Ereignislink). 60 sichtbare Einträge (je 15 in den vier Räumen: zwei Seiten) und 3 gekürzte Stubs mit festen Kennungen.
// Besonderheiten: Raum aerzte und eine Physiotherapie im Raum selbst sind Heilberufe mit Altbestand-Telefon im Speicher (darf nie erscheinen); ein
// Eintrag hat eine http-Quelle, zwei haben eine feindliche Quelle (javascript:, Zugangsdaten in der Adresse), die der Dienst nie liefern würde und
// die Oberfläche trotzdem nicht als Link zeigen darf; ein langer Name und ein langer Grund ohne Umbruchstellen prüfen den Umbruch am Handy.
export const BOTLEAD_PAGE_SIZE = 50
export const BOTLEAD_ROOM_KEYS = Object.freeze(['aerzte', 'handwerk', 'selbst', 'laden'])
export const BOTLEAD_IDS = Object.freeze({
health: 'bl-aerzte-01', // Heilberuf mit Altbestand-Telefon im Speicher, Seite 1
physio: 'bl-selbst-01', // Heilberuf außerhalb des Raums aerzte
http: 'bl-handwerk-02', // Quelle mit http
javascript: 'bl-laden-04', // Quelle javascript:
credentials: 'bl-selbst-03', // Quelle mit Zugangsdaten in der Adresse
long: 'bl-handwerk-01', // langer Name und langer Grund ohne Umbruchstellen
cut: 'bl-gekuerzt-01', // gekürzter Stub (nur mit id sichtbar)
cuts: Object.freeze(['bl-gekuerzt-01', 'bl-gekuerzt-02', 'bl-gekuerzt-03']),
})
const BOTLEAD_ROOM_LABEL = Object.freeze({ aerzte: 'Ärzte & Praxen', handwerk: 'Handwerk', selbst: 'Selbstständige', laden: 'Läden & Gastro' })
const BOTLEAD_ANCHOR_MS = Date.parse('2026-10-17T20:00:00.000Z')
const BOTLEAD_STEP_MS = 14.5 * 60 * 60 * 1000
// Die 14 festen Grundtexte des Dienstes (workers-dev-p6/src/scoring/business.js, Konstante GRUND; wörtlich wie tests/fixtures/botlead-gruende.json): die Akte im Keller
// erkennt sie am Wortlaut und zeigt Symbol und Erklärung dazu. Die Stelle 2 („Website in gutem Zustand“) bestimmt unten, welche Einträge eine Website haben.
const BOTLEAD_REASONS = Object.freeze([
'keine veröffentlichte Mail',
'Kette oder Franchise',
'Website in gutem Zustand',
'Punktzahl unter 55',
'Adresse nicht zustellbar',
'veraltet (nicht ausgewählt)',
'schon bekannt (E-Mail oder Telefon)',
'schon bekannt (Name und PLZ)',
'Sperrliste',
'Lesen fehlgeschlagen',
'Impressum passt nicht zum Betrieb',
'Lauf unterbrochen',
'Bewertung ausgefallen',
'Name fehlt',
])
const BOTLEAD_LONG_REASON = 'Impressum nennt nur eine Postfachadresse und ein Kontaktformular; die einzige Mail-Adresse (verwaltung@sehr-langer-domainname-der-nicht-umbricht-handwerksbetrieb-westpfalz-gruppe.example) gehört einem Dritten.'
const BOTLEAD_LONG_NAME = 'Elektroinstallation-und-Haustechnik-Hoffmann-Sonnenschein-Wittgensteiner GmbH'
// [Name, PLZ, Ort] je Raum, 15 Einträge; Fund i gehört zum Raum i % 4 und dort zum Eintrag i / 4 (neueste zuerst, Raum für Raum abwechselnd)
const BOTLEAD_TABLE = Object.freeze({
aerzte: [
['Praxis Dr. Lindner', '67655', 'Kaiserslautern'], ['Zahnarztpraxis Brandt & Kollegen', '66849', 'Landstuhl'], ['Kieferorthopädie Dr. Yilmaz', '67433', 'Neustadt an der Weinstraße'],
['Augenarztpraxis am Markt', '76829', 'Landau in der Pfalz'], ['Frauenarztpraxis Dr. Schaal', '67098', 'Bad Dürkheim'], ['Kinderarzt Dr. Weigand', '66953', 'Pirmasens'],
['HNO-Praxis Dr. Reuter', '67227', 'Frankenthal'], ['Orthopädie Zentrum Westpfalz', '67697', 'Otterberg'], ['Hautarztpraxis Dr. Kessler', '66482', 'Zweibrücken'],
['Heilpraktikerin Sabine Roth', '67547', 'Worms'], ['Tierarztpraxis Dr. Keller', '67346', 'Speyer'], ['Psychotherapie Fuchs', '67269', 'Grünstadt'],
['Hausarztpraxis Dr. Baumann', '66869', 'Kusel'], ['Zahnärzte am Dom', '67346', 'Speyer'], ['Praxis für Allgemeinmedizin Dr. Heinz', '55411', 'Bingen'],
],
handwerk: [
[BOTLEAD_LONG_NAME, '67655', 'Kaiserslautern'], ['Elektro Hartmann GmbH', '67677', 'Enkenbach-Alsenborn'], ['Sanitär Weber', '66877', 'Ramstein-Miesenbach'],
['Malerbetrieb Schneider', '67806', 'Rockenhausen'], ['Schreinerei Ott', '67722', 'Winnweiler'], ['Dachdeckerei Lang & Söhne', '66849', 'Landstuhl'],
['Fliesen Becker', '67433', 'Neustadt an der Weinstraße'], ['Zimmerei Jung', '76829', 'Landau in der Pfalz'], ['Gartenbau Grünfinger', '66953', 'Pirmasens'],
['Metallbau Schmitt', '66482', 'Zweibrücken'], ['Kfz-Werkstatt Mayer', '67227', 'Frankenthal'], ['Autolackiererei Roth', '67547', 'Worms'],
['Bauunternehmen Kraus GmbH & Co. KG', '67547', 'Worms'], ['Heizungsbau Albrecht', '67098', 'Bad Dürkheim'], ['Glaserei Peters', '55543', 'Bad Kreuznach'],
],
selbst: [
['Physiotherapie Moser', '67655', 'Kaiserslautern'], ['Steuerberatung Klein', '67433', 'Neustadt an der Weinstraße'], ['Fotografie Lichtblick', '66849', 'Landstuhl'],
['Coaching Anja Berger', '67098', 'Bad Dürkheim'], ['Grafikdesign Pixelwerk', '66953', 'Pirmasens'], ['Architekturbüro Vogel', '67227', 'Frankenthal'],
['Hundeschule Pfotenglück', '66869', 'Kusel'], ['Nachhilfe Lernwerk', '76829', 'Landau in der Pfalz'], ['Fahrschule Schreiber', '67547', 'Worms'],
['Versicherungsmakler Böhm', '67346', 'Speyer'], ['Immobilien Kaufmann', '67269', 'Grünstadt'], ['Übersetzungsbüro Lingua', '55116', 'Mainz'],
['Steuerberater Dr. Franke', '67677', 'Enkenbach-Alsenborn'], ['Hundepension Waldblick', '66482', 'Zweibrücken'], ['Yogastudio Atemraum', '67697', 'Otterberg'],
],
laden: [
['Bäckerei Sommer', '67655', 'Kaiserslautern'], ['Metzgerei Kunz', '66849', 'Landstuhl'], ['Café Eselsburg', '67697', 'Otterberg'],
['Restaurant Zum Hirschen', '67433', 'Neustadt an der Weinstraße'], ['Blumenladen Rosengarten', '76829', 'Landau in der Pfalz'], ['Friseursalon Haarscharf', '67098', 'Bad Dürkheim'],
['Kosmetikstudio Belle', '66953', 'Pirmasens'], ['Boutique Mode & Mehr', '67227', 'Frankenthal'], ['Buchhandlung Seitenweise', '66482', 'Zweibrücken'],
['Eisdiele Dolce Vita', '67547', 'Worms'], ['Geschenkeladen Wunderbar', '67346', 'Speyer'], ['Fahrradladen Kettenreaktion', '67269', 'Grünstadt'],
['Weinstube Pfälzer Eck', '67433', 'Neustadt an der Weinstraße'], ['Pizzeria Da Marco', '67655', 'Kaiserslautern'], ['Kiosk am Bahnhof', '67806', 'Rockenhausen'],
],
})
function botleadDocs() {
const docs = []
const pad = (n) => String(n).padStart(2, '0')
for (let i = 0; i < 60; i += 1) {
const room = BOTLEAD_ROOM_KEYS[i % 4]
const k = Math.floor(i / 4)
const [name, postalCode, city] = BOTLEAD_TABLE[room][k]
const id = `bl-${room}-${pad(k + 1)}`
const health = room === 'aerzte' || id === BOTLEAD_IDS.physio
const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 24)
const source = room === 'selbst' ? 'impressum' : room === 'laden' ? 'osm' : 'places'
let sourceUrl = room === 'selbst' ? `https://www.${slug}.example/impressum` : room === 'laden' ? `https://www.openstreetmap.org/node/${4710000 + i * 37}` : `https://www.11880.com/branchenbuch/${slug}`
if (id === BOTLEAD_IDS.http) sourceUrl = 'http://www.elektro-hartmann-alt.example/kontakt'
if (id === BOTLEAD_IDS.javascript) sourceUrl = 'javascript:alert(document.domain)'
if (id === BOTLEAD_IDS.credentials) sourceUrl = 'https://fotografie-lichtblick.example@evil.example/impressum'
let reason = BOTLEAD_REASONS[(i * 3 + k) % BOTLEAD_REASONS.length]
if (id === BOTLEAD_IDS.long) reason = BOTLEAD_LONG_REASON
// Telefon: die meisten mit Quelle, einer nur mit Nummer (ohne Quelle gilt sie als nicht belegt), einer ganz ohne; Heilberufe tragen im Speicher eine Altbestand-Nummer samt Quelle
const phoneStyle = health ? 'alt' : k % 5 === 4 ? 'ohneQuelle' : k % 6 === 5 ? 'keine' : 'mitQuelle'
docs.push({
$id: id,
entrance: 'ohne_website',
status: 'botlead',
room,
name,
street: '',
postalCode,
city,
website: reason === BOTLEAD_REASONS[2] ? `https://www.${slug}.example/` : '',
score: 35 + ((i * 13) % 60),
botLeadReason: reason,
source,
sourceUrl,
foundAt: isoOf(BOTLEAD_ANCHOR_MS - i * BOTLEAD_STEP_MS - (i % 4) * 11 * 60 * 1000),
isHealth: health,
purgedAt: '',
phone: phoneStyle === 'keine' ? '' : `0${631 + (i % 5)} 555 ${String(100 + i * 7).padStart(4, '0')}`,
phoneSource: phoneStyle === 'mitQuelle' || phoneStyle === 'alt' ? (source === 'osm' ? 'osm' : 'impressum') : '',
})
}
// Gekürzte Stubs wie nach archive/kuerzen.js: Name '[gekürzt]', Ort, Raum, Punkte und Grund bleiben, alles Persönliche ist leer
const cuts = [['handwerk', 'Kaiserslautern', 51], ['laden', 'Landau in der Pfalz', 44], ['aerzte', 'Worms', 62]]
cuts.forEach(([room, city, score], n) => {
docs.push({
$id: BOTLEAD_IDS.cuts[n], entrance: 'ohne_website', status: 'botlead', room, name: '[gekürzt]', street: '', postalCode: '', city, website: '', score,
botLeadReason: BOTLEAD_REASONS[n], source: '', sourceUrl: '', foundAt: isoOf(BOTLEAD_ANCHOR_MS - (120 + n * 9) * 24 * 60 * 60 * 1000), isHealth: room === 'aerzte',
purgedAt: isoOf(BOTLEAD_ANCHOR_MS - (2 + n) * 24 * 60 * 60 * 1000), phone: '', phoneSource: '',
})
})
return docs
}
// Ein Eintrag der Antwort: feste Auswahl der Felder (wie eintrag() im Dienst; nie E-Mail, Quelle der Adresse, Ansprechpartner, Kennung des Orts, Anfragetext)
function botleadEntry(doc) {
const health = Boolean(doc.isHealth) || doc.room === 'aerzte'
const withNumber = !health && doc.phoneSource !== ''
const found = Date.parse(doc.foundAt)
return {
id: doc.$id,
room: doc.room,
roomLabel: BOTLEAD_ROOM_LABEL[doc.room] ?? doc.room,
name: doc.name,
street: doc.street,
postalCode: doc.postalCode,
city: doc.city,
website: doc.website,
score: Number.isFinite(doc.score) ? doc.score : 0,
reason: doc.botLeadReason,
source: doc.source,
sourceUrl: doc.sourceUrl,
foundAt: Number.isNaN(found) ? doc.foundAt : new Date(found).toISOString(),
isHealth: health,
gekuerzt: Boolean(doc.purgedAt),
phone: withNumber ? doc.phone : '',
phoneSource: withNumber ? doc.phoneSource : '',
}
}
// Suchtext und Bestand auf dieselbe Form bringen: klein, Umlaute als ae/oe/ue, Satzzeichen weg (wie normalizeName des Dienstes, ohne Rechtsformen)
const foldSearch = (value) => String(value ?? '').toLowerCase().replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue').replace(/ß/g, 'ss').replace(/[^a-z0-9]+/g, ' ').trim()
const BOTLEAD_ID_FORM = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/
/** listBotLeads des Dienstes auf dem Speicher docs: { items, total, pageSize }. Ein unbekannter Raum wirft 400 „Unbekannter Raum.“. */
export function listBotleads(docs, { room = '', q = '', page = 1, id = '' } = {}) {
const raum = room ?? ''
if (raum !== '' && !BOTLEAD_ROOM_KEYS.includes(raum)) throw Object.assign(new Error('Unbekannter Raum.'), { status: 400 })
const kennung = typeof id === 'string' ? id : ''
if (kennung !== '') {
const doc = BOTLEAD_ID_FORM.test(kennung) ? docs.find((d) => d.$id === kennung) : undefined
const items = doc ? [botleadEntry(doc)] : []
return { items, total: items.length, pageSize: BOTLEAD_PAGE_SIZE }
}
const words = foldSearch(String(q ?? '').slice(0, 100)).split(' ').filter(Boolean)
const hit = docs
.filter((d) => !d.purgedAt && (raum === '' || d.room === raum))
.filter((d) => { const hay = foldSearch([d.name, d.city, d.botLeadReason, BOTLEAD_ROOM_LABEL[d.room]].join(' ')); return words.every((w) => hay.includes(w)) })
.sort((x, y) => Date.parse(y.foundAt) - Date.parse(x.foundAt) || y.score - x.score || (x.$id < y.$id ? -1 : 1))
const seite = Number.isInteger(page) && page >= 1 ? page : 1
const from = (seite - 1) * BOTLEAD_PAGE_SIZE
return { items: hit.slice(from, from + BOTLEAD_PAGE_SIZE).map(botleadEntry), total: hit.length, pageSize: BOTLEAD_PAGE_SIZE }
}
/**
* Skript-API für den Prüfstand. now() = Uhr in Millisekunden (nur die Differenz zum Aufruf von createScriptedApi zählt),
* base = Zeitpunkt (ms) des Starts für die Zeitstempel der Ereignisse, halted = Bounce-Notbremse ist von Anfang an gesetzt (H4),
* pult = das Freigabe-Pult und die Interessenten (P3) mit den festen Daten füllen (sonst ist das Pult leer); 'p4' nimmt statt dessen den
* Satz für die Gespräche und die Werkstatt (p4Fixtures). botleadsEmpty = die Bot-Lead-Liste ist von Anfang an leer (Leerzustand ohne Filter).
* Bot-Leads (P6): botleads({ room, q, page, id }) antwortet wie GET /botleads des Dienstes (listBotleads oben). Schalter: `faults.botleads` ({ status, message }),
* `botleadsDelayMs` (Zahl oder Funktion (Abfrage) => ms), `botleadsEmpty`; `botleadCalls` hält die Abfragen fest, `botleadsHealthPhones` die Nummern, die im
* Speicher bei Heilberufen stehen und nie ausgeliefert werden dürfen.
* Rückgabe wie createWorkersApi; saved ist die Liste der Patches, die saveSettings bekam.
*/
export function createScriptedApi({ now, base = DEFAULT_BASE, halted = false, pult = false, botleadsEmpty = false }) {
const start = now()
const elapsed = () => Math.max(0, now() - start)
let paused = false
const settings = defaultSettings(halted === true)
const saved = []
// ohne `pult` ist das Pult leer wie bisher
const fx = pult === 'p4' ? p4Fixtures(base) : pult ? pultFixtures(base) : { items: [], prospects: {}, threads: {}, messages: {}, previews: {} }
const pultItems = fx.items
// je { status, message } oder null; die vier Schlüssel mit Bindestrich oder Namen der P4-Aktionen stehen ebenfalls hier
const faults = { pult: null, claim: null, sent: null, discard: null, done: null, prospect: null, reply: null, 'approve-preview': null, 'start-build': null, paste: null, botleads: null }
const pastedBefore = new Set() // Interessent + Text: dieselbe eingefügte Antwort ist beim zweiten Mal „doppelt“
const pultCalls = []
const prospectCalls = []
const pauseCalls = []
const botleadStore = botleadDocs()
const botleadCalls = []
// Alle Ereignisse des Drehbuchs mit festen Kennungen und Zeitstempeln (aufsteigend); due() = bisher fällige
const all = SCRIPT.map((s, i) => ({
id: `harness-${String(i + 1).padStart(3, '0')}`,
ts: isoOf(base + Math.round(s.at * 1000)),
workerId: s.event.workerId,
kind: s.event.kind,
text: s.event.text,
link: s.event.link ?? null,
to: s.event.to ?? null,
prospectId: null,
costUsd: 0,
meta: s.event.meta ?? null,
}))
const dueEvents = () => all.filter((e) => Date.parse(e.ts) <= base + elapsed())
// Zustand: Startwerte, fortgeschrieben mit den fälligen Ereignissen (wie der Dienst)
function buildState() {
const startList = ROLES.map((r) => ({
workerId: r.workerId,
status: START[r.workerId][0],
task: START[r.workerId][1],
counters: { ...(COUNTERS[r.workerId] ?? {}) },
connected: !NOT_CONNECTED.includes(r.workerId),
}))
const due = dueEvents()
const table = reduceEvents(indexWorkers({ workers: startList, lastEventTs: isoOf(base) }), due)
const count = (kind) => due.filter((e) => e.kind === kind).length
return {
paused,
workers: startList.map((w) => ({ ...w, status: table[w.workerId].status, task: table[w.workerId].task })),
kpis: {
...KPIS,
newAbosMonth: KPIS.newAbosMonth + count('deal'),
mrrNewEur: KPIS.mrrNewEur + 20 * count('deal'),
dealsMonth: KPIS.dealsMonth + count('deal'),
pultOpen: KPIS.pultOpen + count('pult'),
},
sendingHalted: settings.sendingHalted === true,
sendingHaltedReason: typeof settings.sendingHaltedReason === 'string' ? settings.sendingHaltedReason : '',
lastEventTs: due.length ? due[due.length - 1].ts : isoOf(base),
}
}
const notFound = () => Object.assign(new Error('Diesen Hustler gibt es nicht.'), { status: 404 })
const httpError = (status, message) => Object.assign(new Error(message), { status })
const failure = (fault) => httpError(fault.status, fault.message)
const nowIsoString = () => isoOf(base + elapsed())
const shortOf = (id) => (Object.hasOwn(fx.prospects, id) ? { $id: id, ...pick(fx.prospects[id], PROSPECT_SHORT) } : null)
// ---- P4: reply, approve-preview, start-build, paste (Regeln und Sätze wie pultMail.js und paste.js des Dienstes) ----
const TOT = ['verloren', 'deal'] // gesperrt hat seinen eigenen Satz
const conflict = (message) => httpError(409, message)
const NO_PROSPECT = 'Zu diesem Eintrag gibt es keinen Interessenten.'
const checkEditable = (item, editable) => {
if (!editable) throw conflict('Schon erledigt.')
if (item.status === 'in_arbeit' && item.claimedBy !== ME) throw conflict('Wird schon von einem Kollegen bearbeitet.')
}
const prospectOf = (item) => (Object.hasOwn(fx.prospects, item.prospectId) ? fx.prospects[item.prospectId] : null)
const needProspect = (item) => {
const prospect = prospectOf(item)
if (prospect === null) throw conflict(NO_PROSPECT)
return prospect
}
const checkAlive = (prospect) => {
if (prospect.status === 'gesperrt') throw conflict('Dieser Kontakt ist gesperrt.')
if (TOT.includes(prospect.status)) throw conflict('Dieser Kontakt ist nicht mehr aktiv.')
}
const close = (item, status, now) => Object.assign(item, { status, doneBy: ME, doneAt: now })
// Text einer Antwort: getrimmt, nicht leer, höchstens 8000 Zeichen (Code Points)
const replyText = (text, emptyMessage, wrongTypeMessage) => {
if (text !== undefined && text !== null && typeof text !== 'string' && wrongTypeMessage) throw httpError(400, wrongTypeMessage)
const value = typeof text === 'string' ? text.trim() : ''
if (value === '') throw httpError(400, emptyMessage)
if (Array.from(value).length > REPLY_TEXT_MAX) throw httpError(400, `Der Text ist zu lang (höchstens ${REPLY_TEXT_MAX} Zeichen).`)
return value
}
function p4Action(item, action, body, editable, now) {
const input = body !== null && typeof body === 'object' ? body : {}
if (action === 'reply') {
if (!['mail_antwort', 'unsicher', 'preisfrage', 'mensch_gewuenscht'].includes(item.kind)) throw conflict('Dazu gibt es keine Antwort-Funktion.')
if (!item.prospectId) throw conflict(NO_PROSPECT)
checkEditable(item, editable)
replyText(input.text, 'Der Text der Antwort fehlt.', 'Der Text der Antwort muss ein Text sein.')
const prospect = needProspect(item)
if (prospect.status === 'gesperrt') throw conflict('Dieser Kontakt ist gesperrt.')
const thread = prospect.threadId && Object.hasOwn(fx.threads, prospect.threadId) ? fx.threads[prospect.threadId] : null
if (thread !== null ? thread.channel !== 'mail' : !prospect.email) throw conflict('Nur für Mail-Fäden.')
return clone({ item: close(item, 'gesendet', now) })
}
if (action === 'approve-preview') {
if (item.kind !== 'vorschau_freigabe') throw conflict('Dieser Eintrag ist keine Vorschau-Freigabe.')
if (!item.prospectId) throw conflict(NO_PROSPECT)
checkEditable(item, editable)
const preview = item.previewId && Object.hasOwn(fx.previews, item.previewId) ? fx.previews[item.previewId] : null
if (preview === null || preview.status !== 'freigabe' || preview.convertedProjectId) throw conflict('Die Vorschau ist nicht freigabebereit.')
checkAlive(needProspect(item))
return clone({ item: close(item, 'erledigt', now), previewUrl: item.sourceUrl ?? null })
}
if (action === 'start-build') {
if (item.kind !== 'unsicher') throw conflict('Dazu gibt es keine Bau-Funktion.')
if (!item.prospectId) throw conflict(NO_PROSPECT)
checkEditable(item, editable)
const prospect = needProspect(item)
checkAlive(prospect)
if (prospect.previewId) throw conflict('Für diesen Interessenten gibt es schon eine Vorschau.')
prospect.status = 'vorschau_bau'
return clone({ item: close(item, 'erledigt', now) })
}
// paste: Text, Eintrag (daraus der Interessent), Interessent, Faden-Kanal, Sperre; Antwort { status, messageId, threadId }
const text = replyText(input.text, 'Bitte einen Text eingeben.', 'Bitte einen Text eingeben.')
if (!item.prospectId) throw conflict(NO_PROSPECT)
const prospect = prospectOf(item)
if (prospect === null) throw httpError(404, 'Interessent nicht gefunden.')
const thread = prospect.threadId && Object.hasOwn(fx.threads, prospect.threadId) ? fx.threads[prospect.threadId] : null
if (thread !== null && thread.channel !== 'plattform') throw conflict('Nur für Plattform-Fäden.')
if (thread === null && prospect.entrance !== 'anfrage') throw conflict('Nur für Plattform-Anfragen.')
if (prospect.status === 'gesperrt') throw conflict('Dieser Kontakt ist gesperrt.')
const key = `${item.prospectId}:${text.replace(/\s+/g, ' ')}`
const status = pastedBefore.has(key) ? 'doppelt' : 'neu'
pastedBefore.add(key)
return clone({ status, messageId: `msg-p4-${pastedBefore.size}`, threadId: `thr-p4-${item.prospectId}` })
}
const api = {
saved,
faults,
pultCalls,
prospectCalls,
pauseCalls,
botleadCalls,
botleadsHealthPhones: botleadStore.filter((d) => d.isHealth && d.phone !== '').map((d) => d.phone),
botleadsEmpty,
botleadsDelayMs: 0, // > 0 oder Funktion (Abfrage) => ms: die Antwort der Bot-Lead-Liste kommt erst nach so vielen Millisekunden
pultEmpty: false, // true: das Pult ist leer (Leerzustand)
pultDelayMs: 0, // > 0: jede Pult-Aktion antwortet erst nach so vielen Millisekunden (Knöpfe sind in der Zwischenzeit gesperrt)
async state() {
return clone(buildState())
},
async events(after, limit = EVENTS_MAX) {
const afterMs = after === null || after === undefined ? NaN : Date.parse(after)
const from = Number.isNaN(afterMs) ? -Infinity : afterMs - OVERLAP_MS
const n = Math.min(EVENTS_MAX, Math.max(1, Math.trunc(Number(limit)) || EVENTS_MAX))
return { events: clone(dueEvents().filter((e) => Date.parse(e.ts) >= from).slice(0, n)) }
},
async trace(workerId) {
const role = roleOf(workerId)
if (!role) throw notFound()
const nowMs = base + elapsed()
return clone({
worker: { ...role, modelId: MODEL_IDS[role.model] },
today: { counters: COUNTERS[workerId] ?? {}, tokens: { in: 18400, out: 2300 }, costUsd: 0.37 },
events: TRACE.map(([kind, text, link], i) => ({
id: `trace-${workerId}-${i + 1}`,
ts: isoOf(nowMs - (i * 2 + 1) * 60000),
workerId,
kind,
text,
link,
to: null,
prospectId: null,
costUsd: 0,
meta: null,
})),
})
},
async pause(next) {
pauseCalls.push(Boolean(next))
paused = Boolean(next)
return { paused }
},
async settings() {
return clone({ ...settings, paused })
},
// Der Patch wird gemerkt und übernommen; sendingHalted: false hebt die Notbremse auf und leert den Grund (H4)
async saveSettings(patch) {
const change = patch !== null && typeof patch === 'object' ? clone(patch) : {}
saved.push(change)
Object.assign(settings, clone(change))
if (change.sendingHalted === false) settings.sendingHaltedReason = ''
return clone({ ...settings, paused })
},
// GET /pult?status=…: neueste zuerst, höchstens 50, mit der Kurzfassung des Interessenten (null ohne Interessent)
async pult(status = 'offen,in_arbeit') {
if (faults.pult) throw failure(faults.pult)
const list = [...new Set((Array.isArray(status) ? status : String(status).split(',')).map((entry) => String(entry).trim()))]
if (list.some((entry) => !PULT_STATUS.includes(entry))) throw httpError(400, STATUS_ERROR)
const rows = api.pultEmpty
? []
: pultItems.filter((item) => list.includes(item.status)).sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt)).slice(0, PULT_LIMIT)
return clone({ items: rows.map((item) => ({ ...item, prospect: shortOf(item.prospectId) })) })
},
// POST /pult/:id/<claim|sent|discard|done>: Regeln wie pult.js (claimPult, markSent, discard, donePult); Antwort { item }
async pultAction(id, action, body) {
pultCalls.push({ id, action, body: body === undefined ? undefined : clone(body) })
if (!['claim', 'sent', 'discard', 'done', ...REPLY_ACTIONS].includes(action)) throw httpError(404, 'Unbekannte Aktion.')
if (api.pultDelayMs > 0) await new Promise((resolve) => setTimeout(resolve, api.pultDelayMs))
if (faults[action]) throw failure(faults[action])
const item = pultItems.find((entry) => entry.$id === id)
if (!item) throw httpError(404, 'Eintrag nicht gefunden.')
const editable = ['offen', 'in_arbeit', 'veraltet'].includes(item.status)
const prospect = Object.hasOwn(fx.prospects, item.prospectId) ? fx.prospects[item.prospectId] : null
const now = nowIsoString()
if (REPLY_ACTIONS.includes(action)) return p4Action(item, action, body, editable, now)
if (action === 'claim') {
if (item.status === 'in_arbeit') {
if (item.claimedBy !== ME) throw httpError(409, 'Wird schon von einem Kollegen bearbeitet.')
} else if (item.status === 'offen' || item.status === 'veraltet') {
Object.assign(item, { status: 'in_arbeit', claimedBy: ME, claimedAt: now })
} else {
throw httpError(409, 'Schon erledigt.')
}
} else if (action === 'sent') {
if (!REPLY_KINDS.includes(item.kind)) throw httpError(409, 'Dieser Eintrag ist keine Antwort zum Absenden.')
if (!editable) throw httpError(409, 'Schon erledigt.')
const text = body && typeof body.text === 'string' ? body.text.trim() : (item.draft ?? '').trim()
if (text === '') throw httpError(400, 'Der Text der Antwort fehlt.')
Object.assign(item, { status: 'gesendet', doneBy: ME, doneAt: now })
if (prospect && (prospect.status === 'gefunden' || prospect.status === 'bewertet')) prospect.status = 'kontaktiert'
} else if (action === 'discard') {
if (!editable) throw httpError(409, 'Schon erledigt.')
Object.assign(item, { status: 'verworfen', doneBy: ME, doneAt: now })
if (prospect && prospect.status === 'bewertet') prospect.status = 'verloren'
} else {
if (REPLY_KINDS.includes(item.kind)) throw httpError(409, 'Antworten schließt man mit „Als gesendet“ oder „Verwerfen“ ab.')
if (!editable) throw httpError(409, 'Schon erledigt.')
Object.assign(item, { status: 'erledigt', doneBy: ME, doneAt: now })
}
return clone({ item })
},
// GET /botleads?room=&q=&page=&id=: Antwort { items, total, pageSize } wie der Dienst (listBotleads); leer bei botleadsEmpty. Abfragen landen in botleadCalls.
async botleads(query = {}) {
const { room = '', q = '', page = 1, id = '' } = query ?? {}
botleadCalls.push(clone({ room, q, page, id }))
const wait = typeof api.botleadsDelayMs === 'function' ? api.botleadsDelayMs({ room, q, page, id }) : api.botleadsDelayMs
if (wait > 0) await new Promise((resolve) => setTimeout(resolve, wait))
if (faults.botleads) throw failure(faults.botleads)
return clone(listBotleads(api.botleadsEmpty ? [] : botleadStore, { room, q, page, id }))
},
// GET /prospect/:id: { prospect (nur Felder der Whitelist), thread, messages, preview, reply }; Faden und Vorschau fehlen als null
async prospect(id) {
prospectCalls.push(id)
if (faults.prospect) throw failure(faults.prospect)
if (!Object.hasOwn(fx.prospects, id)) throw httpError(404, 'Interessent nicht gefunden.')
const record = fx.prospects[id]
const thread = record.threadId && Object.hasOwn(fx.threads, record.threadId) ? fx.threads[record.threadId] : null
const preview = record.previewId && Object.hasOwn(fx.previews, record.previewId) ? fx.previews[record.previewId] : null
const reply = pultItems.find((item) => item.prospectId === id && REPLY_KINDS.includes(item.kind)) ?? null
return clone({
prospect: { $id: id, ...pick(record, PROSPECT_VIEW) },
thread,
messages: thread && Object.hasOwn(fx.messages, thread.$id) ? fx.messages[thread.$id] : [],
preview,
reply,
})
},
}
return api
}