Die Skript-API kennt jetzt reply, approve-preview, start-build und paste mit den Prüfreihenfolgen und Sätzen des Dienstes (pultMail.js, paste.js) und einen eigenen Datensatz (?p4=1: Mail-Antwort, zwei Vorschau-Freigaben, unsicher, Kollegen-Eintrag, zwei gesendete Plattform-Antworten desselben Interessenten). Schalter für 400/409/413, Verbindungsabbruch (status 0) und eine Wartezeit je Aktion. Sechs neue Szenarien in shot.cjs: antworten, vorschau, einfuegen, bauen, fokus, handy-p4 (109 Prüfpunkte, Bilder je Zustand). Drei veraltete P3-Prüfpunkte angepasst (seit P4 bietet eine Plattform-Zeile auch „Antwort des Kunden einfügen“). Bricht ein Szenario ab, hält abbruch-<Szenario>.png den Zustand fest. Der Lauf deckt Fehler der App auf (Fokus nach Aktionen, kein Esc/Abbrechen an den Feldern, 32-px-Schließen am Handy); die Prüfpunkte dazu bleiben rot, an der App ist nichts geändert. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
755 lines
38 KiB
JavaScript
755 lines
38 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 })
|
|
},
|
|
|
|
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
|
|
}
|