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

553 lines
27 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.
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()
// 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).
* 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 = []
const fx = pult ? pultFixtures(base) : { items: [], prospects: {}, threads: {}, messages: {}, previews: {} } // ohne `pult` ist das Pult leer wie bisher
const pultItems = fx.items
const faults = { pult: null, claim: null, sent: null, discard: null, done: null, prospect: null } // je { status, message } oder null
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)
const api = {
saved,
faults,
pultCalls,
prospectCalls,
pauseCalls,
pultEmpty: false, // true: das Pult ist leer (Leerzustand)
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'].includes(action)) throw httpError(404, 'Unbekannte Aktion.')
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 (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 })
},
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
}