Workers P3: Prüfstand um Freigabe, Interessenten-Panel, Anhalten-Rückfrage und Handy erweitert

Echte Seite WorkersPage im Prüfstand (Austausch-Module für Anmeldung, Mitarbeiterliste und Sitzungs-JWT), Skript-API mit Pult, Aktionen und Interessent samt Schaltern für 409, 400, Ausfall und Leerzustand, vier neue Szenarien (Freigabe, Interessent, Anhalten, Handy), jedes Szenario läuft für sich, und der Szenario Desktop bestätigt jetzt die Rückfrage vor dem Anhalten.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-18 10:20:29 +00:00
1 parent 686db61e1c
commit 2ed2201b22
9 files changed
+847 -20

No files matched your search

+257 -7
View File
@@ -2,6 +2,10 @@
// 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'
@@ -132,6 +136,180 @@ const TRACE = Object.freeze([
['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()
@@ -182,6 +360,12 @@ export function createScriptedApi({ now, base = DEFAULT_BASE, halted = false })
let paused = false
const settings = defaultSettings(halted === true)
const saved = []
const fx = pultFixtures(base)
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) => ({
@@ -227,9 +411,18 @@ export function createScriptedApi({ now, base = DEFAULT_BASE, halted = false })
}
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)
return {
const api = {
saved,
faults,
pultCalls,
prospectCalls,
pauseCalls,
pultEmpty: false, // true: das Pult ist leer (Leerzustand)
async state() {
return clone(buildState())
@@ -265,6 +458,7 @@ export function createScriptedApi({ now, base = DEFAULT_BASE, halted = false })
},
async pause(next) {
pauseCalls.push(Boolean(next))
paused = Boolean(next)
return { paused }
},
@@ -282,20 +476,76 @@ export function createScriptedApi({ now, base = DEFAULT_BASE, halted = false })
return clone({ ...settings, paused })
},
async pult() {
return { items: [] }
// 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) })) })
},
async pultAction() {
return {}
// 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 }
},
async prospect() {
return {}
// 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
}