Files
tickte-system/scripts/workers-harness/scripted-api.js
T

756 lines
39 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).
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',
}
}
/**
* 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).
* Rückgabe wie createWorkersApi; saved ist die Liste der Patches, die saveSettings bekam.
*/
export function createScriptedApi({ now, base = DEFAULT_BASE, halted = false, pult = 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 }
const pastedBefore = new Set() // Interessent + Text: dieselbe eingefügte Antwort ist beim zweiten Mal „doppelt“
const pultCalls = []
const prospectCalls = []
const pauseCalls = []
// 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,
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 })
},
// Bot-Leads (P6): leere Liste; der Reiter wird im Prüfstand gebaut und gezeigt, aber nicht mit Daten gefüllt (workersHarness.test.mjs pinnt die leere Antwort)
async botleads() {
return { items: [], total: 0 }
},
// 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
}