302 lines
12 KiB
JavaScript
302 lines
12 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.
|
|
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],
|
|
])
|
|
|
|
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).
|
|
* Rückgabe wie createWorkersApi; saved ist die Liste der Patches, die saveSettings bekam.
|
|
*/
|
|
export function createScriptedApi({ now, base = DEFAULT_BASE, halted = false }) {
|
|
const start = now()
|
|
const elapsed = () => Math.max(0, now() - start)
|
|
let paused = false
|
|
const settings = defaultSettings(halted === true)
|
|
const saved = []
|
|
|
|
// 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 })
|
|
|
|
return {
|
|
saved,
|
|
|
|
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) {
|
|
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 })
|
|
},
|
|
|
|
async pult() {
|
|
return { items: [] }
|
|
},
|
|
|
|
async pultAction() {
|
|
return {}
|
|
},
|
|
|
|
async botleads() {
|
|
return { items: [], total: 0 }
|
|
},
|
|
|
|
async prospect() {
|
|
return {}
|
|
},
|
|
}
|
|
}
|