Workers P4: Prüfstand für das neue Pult – Antworten, Vorschau freigeben, Einfügen, Bauen, Fokus, Handy

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>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-18 22:39:52 +00:00
1 parent 6ecfc922d4
commit 5138efd2bf
4 files changed
+909 -10

No files matched your search

+206 -4
View File
@@ -6,6 +6,11 @@
// (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'
@@ -313,6 +318,120 @@ function pultFixtures(base) {
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 {
@@ -352,7 +471,8 @@ function defaultSettings(halted) {
/**
* 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).
* 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 }) {
@@ -361,9 +481,12 @@ export function createScriptedApi({ now, base = DEFAULT_BASE, halted = false, pu
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
// 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
const faults = { pult: null, claim: null, sent: null, discard: null, done: null, prospect: null } // je { status, message } oder null
// 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 = []
@@ -417,6 +540,81 @@ export function createScriptedApi({ now, base = DEFAULT_BASE, halted = false, pu
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,
@@ -424,6 +622,7 @@ export function createScriptedApi({ now, base = DEFAULT_BASE, halted = false, pu
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())
@@ -491,7 +690,8 @@ export function createScriptedApi({ now, base = DEFAULT_BASE, halted = false, pu
// 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 (!['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.')
@@ -499,6 +699,8 @@ export function createScriptedApi({ now, base = DEFAULT_BASE, halted = false, pu
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.')