Workers P4: Pult – als Mensch antworten, Vorschau freigeben, Antwort des Kunden einfügen

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-18 22:08:48 +00:00
1 parent be2e6786b6
commit 533091c333
7 files changed
+1001 -48

No files matched your search

+151 -21
View File
@@ -5,11 +5,13 @@ import EmptyState from '../ui/EmptyState'
import { copyTextChecked } from '../CopyableCredential'
import { useEmployees } from '../../hooks/useEmployees'
import { usePultFeed } from '../../hooks/usePultFeed'
import { PULT_FILTERS, copyAndOpen, mailtoHref, rowModel } from '../../lib/workersPult'
import {
ACTION_FAILED, PULT_FILTERS, REPLY_TEXT_MAX, actionErrorText, charCount, copyAndOpen, mailtoHref, pasteDoneText, rowModel, validateReplyText,
} from '../../lib/workersPult'
import ProspectPanel from './ProspectPanel'
const CONFIRM_MS = 4000 // so lange gilt die Rückfrage „Wirklich verwerfen?“ nach dem ersten Klick
const ACTION_FAILED = 'Das hat nicht geklappt.'
const CONFIRM_MS = 4000 // so lange gilt die Rückfrage „Wirklich verwerfen?“ bzw. „Wirklich freigeben?“ nach dem ersten Klick
const NO_FIELDS = Object.freeze({}) // Eintrag ohne bearbeitete Felder
// Leerzustand je Filter
const EMPTY = {
@@ -42,12 +44,29 @@ function rowName(label, title, joiner = ': ') {
return t === '' ? label : `${label}${joiner}${t}`
}
// Aufgeklapptes Feld unter den Aktionen: „Als Mensch antworten“ (.wk-pult-reply) und „Antwort des Kunden einfügen“ (.wk-pult-paste).
// Textfeld, Zähler (Zeichen wie der Dienst sie zählt, höchstens REPLY_TEXT_MAX) und ein Knopf; die Prüfung macht die Zeile beim Absenden.
function TextPanel({ className, label, hint, value, onChange, submitLabel, onSubmit, busy }) {
const count = charCount(value)
return (
<div className={className}>
{hint && <p className="wk-hint">{hint}</p>}
<textarea className="textarea" value={value} onChange={(e) => onChange(e.target.value)} aria-label={label} autoFocus />
<div className="wk-pult-panel-foot">
<span className={count > REPLY_TEXT_MAX ? 'wk-pult-count over' : 'wk-pult-count'}>{count} / {REPLY_TEXT_MAX}</span>
<Button type="button" variant="primary" onClick={onSubmit} disabled={busy}>{submitLabel}</Button>
</div>
</div>
)
}
// Eine Zeile des Pults. Die Zeile hält nur, was ihr gehört (läuft gerade etwas, Rückfrage, Meldung); der bearbeitete Text liegt in der
// Liste, damit er einen Wechsel des Filters und das Neuladen überlebt. api.pultAction führt aus, der Dienst entscheidet über Rechte und
// Reihenfolge (409 mit deutschem Satz); danach wird die Liste neu geholt.
function PultRow({ api, item, row, text, onText, refresh, onFinished, onDetails }) {
function PultRow({ api, item, row, text, fields, onText, onField, refresh, onFinished, onDetails }) {
const [busy, setBusy] = useState(false)
const [confirming, setConfirming] = useState(false)
const [confirming, setConfirming] = useState(null) // null | 'discard' | 'approve': welche Rückfrage gerade gilt
const [open, setOpen] = useState(null) // null | 'reply' | 'paste': welches Feld aufgeklappt ist
const [note, setNote] = useState(null) // { kind: 'ok' | 'err', text }
const busyRef = useRef(false) // sperrt schon im selben Takt, in dem busy noch nicht greift (Doppelklick)
const aliveRef = useRef(true)
@@ -57,10 +76,10 @@ function PultRow({ api, item, row, text, onText, refresh, onFinished, onDetails
return () => { aliveRef.current = false }
}, [])
// Die Rückfrage vor dem Verwerfen verfällt nach 4 s
// Die Rückfrage vor dem Verwerfen und vor dem Freigeben verfällt nach 4 s
useEffect(() => {
if (!confirming) return undefined
const timer = window.setTimeout(() => setConfirming(false), CONFIRM_MS)
if (confirming === null) return undefined
const timer = window.setTimeout(() => setConfirming(null), CONFIRM_MS)
return () => window.clearTimeout(timer)
}, [confirming])
@@ -68,7 +87,7 @@ function PultRow({ api, item, row, text, onText, refresh, onFinished, onDetails
if (busyRef.current) return false
busyRef.current = true
setBusy(true)
setConfirming(false)
setConfirming(null)
setNote(null)
return true
}
@@ -78,17 +97,23 @@ function PultRow({ api, item, row, text, onText, refresh, onFinished, onDetails
}
// Aktion am Dienst. finished: der Eintrag verlässt die Liste, die Erfolgsmeldung steht deshalb oben bei der Liste und nicht in der Zeile.
const run = async (call, done, finished) => {
if (!begin()) return
// done: Meldung bei Erfolg, als Text oder als Funktion der Antwort des Dienstes. action: Name der Aktion für den Fehlertext (bei reply,
// approve-preview und start-build heißt eine Zeitüberschreitung „Kann gesendet sein – Pult neu laden.“, D14). Ergebnis: ob es geklappt hat.
const run = async (call, done, finished, action) => {
if (!begin()) return false
let ok = false
try {
await call()
if (finished) onFinished(row.id, `„${row.title}“ ${done}`)
else if (aliveRef.current) setNote({ kind: 'ok', text: done })
const result = await call()
const message = typeof done === 'function' ? done(result) : done
if (finished) onFinished(row.id, `„${row.title}“ ${message}`)
else if (aliveRef.current) setNote({ kind: 'ok', text: message })
ok = true
} catch (err) {
if (aliveRef.current) setNote({ kind: 'err', text: err?.message || ACTION_FAILED })
if (aliveRef.current) setNote({ kind: 'err', text: actionErrorText(err, action) })
}
await refresh()
end()
return ok
}
// Kopieren & öffnen: copyAndOpen steht ganz vorn und ohne await davor, nur im Zug des Klicks darf der Browser den Tab öffnen.
@@ -113,13 +138,57 @@ function PultRow({ api, item, row, text, onText, refresh, onFinished, onDetails
}
const discard = () => {
if (!confirming) {
setConfirming(true)
if (confirming !== 'discard') {
setConfirming('discard')
return
}
run(() => api.pultAction(row.id, 'discard', {}), 'verworfen.', true)
}
// Vorschau freigeben: der erste Klick fragt nach, der zweite binnen 4 s reiht den Link an den Interessenten ein (Max sendet im Sendefenster)
const approve = () => {
if (confirming !== 'approve') {
setConfirming('approve')
return
}
run(() => api.pultAction(row.id, 'approve-preview'), 'freigegeben. Max sendet den Link im nächsten Sendefenster.', true, 'approve-preview')
}
// Bau starten wie bei einem „Ja“ des Interessenten (für Einträge „unsicher“)
const startBuild = () => run(() => api.pultAction(row.id, 'start-build'), 'wird gebaut.', true, 'start-build')
// Die Felder „Als Mensch antworten“ und „Antwort einfügen“ sind getrennt: die Antwort ist mit dem Entwurf (oder dem bearbeiteten Text)
// vorbelegt, das Einfüge-Feld beginnt leer. Beide liegen in der Liste, damit der Text einen Wechsel des Filters überlebt.
const replyValue = fields.reply ?? text
const pasteValue = fields.paste ?? ''
const toggle = (which) => setOpen((current) => (current === which ? null : which))
const sendReply = () => {
const check = validateReplyText(replyValue)
if (!check.ok) {
setNote({ kind: 'err', text: check.error })
return
}
run(() => api.pultAction(row.id, 'reply', { text: check.text }), 'gesendet.', true, 'reply')
}
// Eingefügte Kundenantwort: bei Erfolg wird das Feld geleert und zugeklappt (der Eintrag bleibt in der Liste, Nora sortiert die Antwort ein)
const sendPaste = () => {
const check = validateReplyText(pasteValue)
if (!check.ok) {
setNote({ kind: 'err', text: check.error })
return
}
run(async () => {
const result = await api.pultAction(row.id, 'paste', { text: check.text })
onField(row.id, 'paste', '')
if (aliveRef.current) setOpen(null)
return result
}, pasteDoneText, false, 'paste')
}
const hasActions = row.canSend || row.canClaim || row.canDone || row.canDiscard || row.canReply || row.canApprovePreview || row.canStartBuild || row.canPaste
const { prospect } = row
const place = prospect ? [prospect.postalCode, prospect.city].filter(Boolean).join(' ') : ''
const info = [place, prospect && prospect.score != null ? `Score ${prospect.score}` : ''].filter(Boolean).join(' · ')
@@ -173,7 +242,7 @@ function PultRow({ api, item, row, text, onText, refresh, onFinished, onDetails
)
)}
{(row.canSend || row.canClaim || row.canDone || row.canDiscard) && (
{hasActions && (
<div className="row-actions wk-pult-actions" role="group" aria-label={rowName('Aktionen', row.title)}>
{row.canSend && row.kind === 'mail_antwort' && (
mail
@@ -195,19 +264,72 @@ function PultRow({ api, item, row, text, onText, refresh, onFinished, onDetails
Als gesendet markieren
</Button>
)}
{row.canReply && (
<Button type="button" onClick={() => toggle('reply')} aria-expanded={open === 'reply'} disabled={busy}>
Als Mensch antworten
</Button>
)}
{row.canApprovePreview && (
<>
{row.previewHref
? <a className="btn" href={row.previewHref} target="_blank" rel="noopener noreferrer" aria-label={rowName('Vorschau ansehen', row.title)}>Vorschau ansehen</a>
: <span className="wk-hint">Keine gültige Vorschau-Adresse hinterlegt.</span>}
<Button type="button" variant="primary" onClick={approve} disabled={busy}>
{confirming === 'approve' ? 'Wirklich freigeben?' : 'Freigeben & senden'}
</Button>
</>
)}
{row.canStartBuild && (
<Button type="button" onClick={startBuild} disabled={busy}>
Vorschau bauen
</Button>
)}
{row.canPaste && (
<Button type="button" onClick={() => toggle('paste')} aria-expanded={open === 'paste'} disabled={busy}>
Antwort des Kunden einfügen
</Button>
)}
{row.canDone && (
<Button type="button" onClick={() => run(() => api.pultAction(row.id, 'done'), 'erledigt.', true)} disabled={busy}>
Erledigt
</Button>
)}
{row.canDiscard && (
<Button type="button" variant="danger" onClick={discard} disabled={busy}>
{confirming ? 'Wirklich verwerfen?' : 'Verwerfen'}
</Button>
<>
<Button type="button" variant="danger" onClick={discard} disabled={busy}>
{confirming === 'discard' ? 'Wirklich verwerfen?' : 'Verwerfen'}
</Button>
{/* Eine Vorschau-Freigabe wird nur durch Freigeben gesendet; Verwerfen schließt sie, ohne dass etwas hinausgeht (D20) */}
{row.kind === 'vorschau_freigabe' && <span className="wk-hint">„Verwerfen“ sendet die Vorschau nicht.</span>}
</>
)}
</div>
)}
{open === 'reply' && row.canReply && (
<TextPanel
className="wk-pult-reply"
label={rowName('Text der Antwort', row.title)}
value={replyValue}
onChange={(value) => onField(row.id, 'reply', value)}
submitLabel="Jetzt senden"
onSubmit={sendReply}
busy={busy}
/>
)}
{open === 'paste' && row.canPaste && (
<TextPanel
className="wk-pult-paste"
label={rowName('Antwort des Kunden', row.title)}
hint="Kopiere die Antwort aus der Plattform hierher. Nora sortiert sie ein; bei einer Frage schreibt Clara den nächsten Entwurf."
value={pasteValue}
onChange={(value) => onField(row.id, 'paste', value)}
submitLabel="Einfügen"
onSubmit={sendPaste}
busy={busy}
/>
)}
{note && <div className={`msg ${note.kind}`} role={note.kind === 'err' ? 'alert' : 'status'}>{note.text}</div>}
</li>
)
@@ -219,6 +341,7 @@ function PultRow({ api, item, row, text, onText, refresh, onFinished, onDetails
export default function PultList({ api, me }) {
const [filterId, setFilterId] = useState('offen')
const [drafts, setDrafts] = useState({}) // bearbeitete Texte je Eintrag; was fehlt, zeigt den Entwurf des Dienstes
const [fields, setFields] = useState({}) // Texte der Felder „Als Mensch antworten“ (reply) und „Antwort einfügen“ (paste) je Eintrag
const [finished, setFinished] = useState('') // Meldung über den zuletzt abgeschlossenen Eintrag (seine Zeile ist dann schon weg)
const [detailId, setDetailId] = useState(null) // Interessent, dessen Panel offen ist (null = keins)
const filter = PULT_FILTERS.find((f) => f.id === filterId) ?? PULT_FILTERS[0]
@@ -231,12 +354,17 @@ export default function PultList({ api, me }) {
setFinished('')
}
const setText = useCallback((id, value) => setDrafts((d) => ({ ...d, [id]: value })), [])
const setField = useCallback((id, which, value) => setFields((f) => ({ ...f, [id]: { ...f[id], [which]: value } })), [])
const onFinished = useCallback((id, message) => {
setFinished(message)
setDrafts((d) => {
const { [id]: _gone, ...rest } = d
return rest
})
setFields((f) => {
const { [id]: _gone, ...rest } = f
return rest
})
}, [])
const rows = items.map((item) => ({ item, row: rowModel(item, { me, nowMs, employees }) }))
@@ -265,7 +393,9 @@ export default function PultList({ api, me }) {
item={item}
row={row}
text={Object.hasOwn(drafts, row.id) ? drafts[row.id] : row.copyText}
fields={Object.hasOwn(fields, row.id) ? fields[row.id] : NO_FIELDS}
onText={setText}
onField={setField}
refresh={refresh}
onFinished={onFinished}
onDetails={setDetailId}
+17 -10
View File
@@ -11,6 +11,9 @@ export const INVALID_ID_TEXT = 'Ungültige Kennung.'
// Zeitgrenze je Abruf (JWT holen, senden, Antwort lesen): ohne sie bliebe ein Abruf auf toter Verbindung (Rechner im Ruhezustand,
// Netzwechsel) minutenlang offen und der Feed (ein Abruf je Schleife) stünde still, ohne Fehlermeldung.
export const REQUEST_TIMEOUT_MS = 20000
// Zeitgrenze für Pult-Aktionen, die im Dienst länger dauern (Mail senden, Freigabe einreihen, Bau anlegen): nach 20 s als „nicht erreichbar“
// abzubrechen, obwohl die Mail gerade hinausgeht, ließe den Menschen ein zweites Mal klicken. Alle anderen Abrufe behalten die kurze Frist.
export const ACTION_TIMEOUT_MS = 60000
// Nach einer 401-Folge (frisches JWT wird ebenfalls abgelehnt oder Appwrite lehnt createJWT ab) wird so lange kein frisches JWT mehr
// erzwungen, und lehnt Appwrite createJWT ab (HTTP-Code ab 400, z. B. 429; kein Übertragungsfehler), wird so lange gar kein JWT mehr geholt: Appwrite begrenzt
// createJWT je Nutzer, und die Schleifen (5 s / 15 s) würden das Limit binnen Minuten aufbrauchen und damit auch Serverpuls,
@@ -20,7 +23,9 @@ export const AUTH_COOLDOWN_MS = 120000
// Pult-Status des Dienstes (Übersicht §4.11)
export const PULT_STATUS = Object.freeze(['offen', 'in_arbeit', 'gesendet', 'verworfen', 'veraltet', 'erledigt'])
// Erlaubte Aktionen am Pult-Eintrag; alles andere wird abgewiesen, bevor ein Pfad entsteht
const PULT_ACTIONS = Object.freeze(['claim', 'sent', 'discard', 'done', 'reply', 'approve-preview', 'paste'])
const PULT_ACTIONS = Object.freeze(['claim', 'sent', 'discard', 'done', 'reply', 'approve-preview', 'paste', 'start-build'])
// Die Aktionen mit der langen Frist (ACTION_TIMEOUT_MS); bei einer Zeitüberschreitung ist offen, ob der Dienst sie ausgeführt hat
export const SLOW_PULT_ACTIONS = Object.freeze(['reply', 'approve-preview', 'start-build'])
const BASE = '/api/workers'
const EVENTS_LIMIT = 200
@@ -67,22 +72,23 @@ export function createWorkersApi({
getJwt,
fetchImpl = (...args) => globalThis.fetch(...args),
timeoutMs = REQUEST_TIMEOUT_MS,
actionTimeoutMs = ACTION_TIMEOUT_MS,
authCooldownMs = AUTH_COOLDOWN_MS,
now = Date.now,
}) {
let freshBlockedUntil = 0 // bis dahin wird nach einer 401-Folge kein frisches JWT erzeugt und nach einem gescheiterten createJWT gar keins (AUTH_COOLDOWN_MS)
let mintFailed = false // Appwrite hat createJWT abgelehnt (HTTP-Code ab 400, kein Übertragungsfehler): bis zum Ende der Sperre wird getJwt nicht mehr gerufen, auch nicht bei leerem Cache
// Führt fn(signal) mit Zeitgrenze aus: nach timeoutMs scheitert der Aufruf mit DOWN_TEXT (status 0) und das Signal wird
// Führt fn(signal) mit Zeitgrenze aus: nach limitMs (Standard timeoutMs) scheitert der Aufruf mit DOWN_TEXT (status 0) und das Signal wird
// abgebrochen. Das gilt auch, wenn fn das Signal nicht beachtet (hängendes createJWT, fetchImpl ohne Abbruch-Unterstützung).
function withDeadline(fn) {
function withDeadline(fn, limitMs = timeoutMs) {
const controller = new AbortController()
let timer
const expired = new Promise((_, reject) => {
timer = setTimeout(() => {
reject(statusError(DOWN_TEXT, 0)) // zuerst ablehnen, dann abbrechen: das Abbruch-Ergebnis von fn soll nicht gewinnen
controller.abort()
}, timeoutMs)
}, limitMs)
})
return Promise.race([fn(controller.signal), expired]).finally(() => clearTimeout(timer))
}
@@ -142,23 +148,24 @@ export function createWorkersApi({
}
// Ein Versuch mit Zeitgrenze: JWT holen, senden, auswerten. { unauthorized: true } bei 401, sonst { data }.
function attempt(path, method, body, options) {
function attempt(path, method, body, options, limitMs) {
return withDeadline(async (signal) => {
const res = await send(path, method, body, await jwtFor(options), signal)
if (res.status === 401) return { unauthorized: true }
freshBlockedUntil = 0 // der Dienst hat das JWT angenommen: die nächste 401-Folge darf wieder einmal erneuern
return { data: await read(res) }
})
}, limitMs)
}
async function request(path, { method = 'GET', body } = {}) {
let out = await attempt(path, method, body)
// limitMs: Zeitgrenze je Versuch (Standard timeoutMs); auch der Wiederholungsversuch nach einer 401 bekommt sie
async function request(path, { method = 'GET', body, limitMs } = {}) {
let out = await attempt(path, method, body, undefined, limitMs)
if (out.unauthorized) {
// Während der Sperre gilt die 401 sofort als abgelaufene Sitzung (kein createJWT, keine zweite Anfrage)
if (now() < freshBlockedUntil) throw statusError(SESSION_TEXT, 401)
// sonst einmal mit frischem JWT wiederholen (lehnt Appwrite createJWT ab, beginnt die Sperre in jwtFor), danach gilt die
// Sitzung als abgelaufen und die Sperre beginnt
out = await attempt(path, method, body, { fresh: true })
out = await attempt(path, method, body, { fresh: true }, limitMs)
if (out.unauthorized) {
freshBlockedUntil = now() + authCooldownMs
throw statusError(SESSION_TEXT, 401)
@@ -178,7 +185,7 @@ export function createWorkersApi({
pult: async (status = 'offen') => request(`/pult?status=${pultStatusList(status).join(',')}`),
pultAction: async (id, action, body) => {
if (!PULT_ACTIONS.includes(action)) throw statusError(`Unbekannte Aktion: ${action}`, 400)
return request(`/pult/${pathId(id)}/${action}`, { method: 'POST', body })
return request(`/pult/${pathId(id)}/${action}`, { method: 'POST', body, limitMs: SLOW_PULT_ACTIONS.includes(action) ? actionTimeoutMs : undefined })
},
botleads: ({ room, q, page, id } = {}) => request(`/botleads${query([['room', room], ['q', q], ['page', page], ['id', id]])}`),
prospect: async (id) => request(`/prospect/${pathId(id)}`),
+84 -7
View File
@@ -1,7 +1,10 @@
// Reine Logik des Reiters „Freigabe“ (Pult): Filter, Alter, Veraltet-Anzeige, sichere Links, Zeilenmodell, Kopieren & Öffnen.
// Reine Logik des Reiters „Freigabe“ (Pult): Filter, Alter, Veraltet-Anzeige, sichere Links, Zeilenmodell, Kopieren & Öffnen,
// Prüfung der Texte und Meldungen für Antworten als Mensch, Vorschau-Freigabe und eingefügte Kundenantworten (P4).
// Kein React, kein Appwrite, kein window/document beim Import: unter Node testbar (loadModule). Nichts hier versendet etwas:
// der Mensch schickt die Antwort selbst ab (Plattform oder eigenes Mailprogramm) und markiert sie danach als gesendet.
// Plattform-Antworten schickt der Mensch selbst ab (Plattform) und markiert sie danach als gesendet; Mail-Antworten und die Freigabe
// einer Vorschau gehen über den Dienst (api.pultAction), der sie durch das Sende-Tor führt.
import { SLOW_PULT_ACTIONS } from './workersApi'
import { roleOf } from './workersRoster'
const MINUTE_MS = 60 * 1000
@@ -23,6 +26,16 @@ export const PULT_KIND_LABELS = Object.freeze({
// Nur diese Arten sind Antworten, die ein Mensch absendet; alle anderen schließt er mit „Erledigt“ ab
export const SENDABLE_KINDS = Object.freeze(['plattform_antwort', 'mail_antwort'])
// P4: Arten, auf die ein Mensch aus dem Pult heraus per Mail antwortet (Spiegel von REPLY_KINDS in src/gespraeche/pultMail.js des Dienstes)
export const REPLY_KINDS = Object.freeze(['mail_antwort', 'unsicher', 'preisfrage', 'mensch_gewuenscht'])
// P4: Arten, zu denen ein Mensch die Antwort des Kunden von der Plattform einfügt (Dienst: POST /pult/:id/paste)
export const PASTE_KINDS = Object.freeze(['plattform_antwort'])
// Längster Text einer Antwort oder einer eingefügten Kundenantwort (Grenze des Dienstes, gezählt in Zeichen/Code Points)
export const REPLY_TEXT_MAX = 8000
export const ACTION_FAILED = 'Das hat nicht geklappt.'
// Bei reply, approve-preview und start-build ist nach einer Zeitüberschreitung offen, ob der Dienst die Aktion ausgeführt hat (D14)
export const UNCERTAIN_TEXT = 'Kann gesendet sein – Pult neu laden.'
// Die drei Reiter der Liste; `statuses` geht unverändert als Komma-Liste an GET /pult
export const PULT_FILTERS = Object.freeze([
Object.freeze({ id: 'offen', label: 'Wartet', statuses: 'offen,in_arbeit' }),
@@ -42,6 +55,8 @@ const DONE_VIEW = Object.freeze({
erledigt: Object.freeze({ label: 'Erledigt', tone: 'muted' }),
})
const PREVIEW_HOST = 'entwurf.project.webklar.com' // Vorschauen liegen nur hier (Vorschau-Server des Dienstes)
const MAIL_SUBJECT_MAX = 78
const MAIL_BODY_MAX = 1800
@@ -92,6 +107,16 @@ export function safeExternalUrl(url) {
return parsed.href
}
/** Die Adresse einer Vorschau: nur https://entwurf.project.webklar.com/… (Standard-Port, ohne Zugangsdaten), sonst null. */
function previewUrl(url) {
const href = safeExternalUrl(url)
if (href === null) return null
const { hostname, port } = new URL(href)
return hostname === PREVIEW_HOST && port === '' ? href : null
}
const filled = (value) => typeof value === 'string' && value.trim() !== ''
// Genau eine Adresse: ein '@', links und rechts etwas, kein Leerraum (CR/LF eingeschlossen) und keine Zeichen, die in der
// mailto-Adresse einen zweiten Empfänger, weitere Kopfzeilen oder ein anderes Schema einschleusen würden (? & % , ; : # / \ < > ").
// Ein führendes „mailto:“ fällt über den Doppelpunkt heraus.
@@ -126,6 +151,14 @@ export function employeeName(by, employees) {
return listedName(by, employees) ?? KOLLEGE
}
// Anzeigename einer Worker-Kennung wie `max` oder `clara` („Max (automatisch)“); ein Mensch (`mensch:<userId>`) und jede unbekannte Kennung
// ergeben null. Ein Worker darf nie als „Kollege“ erscheinen, deshalb geht eine solche Kennung nie durch listedName.
function workerLabel(by) {
if (typeof by !== 'string' || by.startsWith(MENSCH)) return null
const role = roleOf(by)
return role ? `${role.name} (automatisch)` : null
}
/**
* Wer einen Eintrag abgeschlossen hat, als Satzteil: `mensch:<userId>` über die Mitarbeiterliste („von Anna“, unbekannt „von Kollege“),
* eine Kennung wie `max` (Worker, der automatisch gesendet hat, P4) mit dem Namen aus dem Rollen-Register („von Max (automatisch)“),
@@ -135,8 +168,8 @@ export function employeeName(by, employees) {
function doneByPhrase(by, employees) {
if (typeof by !== 'string' || by === '') return ''
if (by.startsWith(MENSCH)) return `von ${employeeName(by, employees)}`
const role = roleOf(by)
return role ? `von ${role.name} (automatisch)` : 'automatisch'
const worker = workerLabel(by)
return worker ? `von ${worker}` : 'automatisch'
}
/**
@@ -156,8 +189,11 @@ export function rowModel(item, { me, nowMs, employees }) {
let claimedByLabel = ''
if (held) {
const ownName = claimedByMe && typeof me.name === 'string' && me.name !== '' ? me.name : null // eigener Name, falls die Liste ihn nicht kennt
claimedByLabel = listedName(item.claimedBy, employees) ?? ownName ?? KOLLEGE
claimedByLabel = workerLabel(item.claimedBy) ?? listedName(item.claimedBy, employees) ?? ownName ?? KOLLEGE // Max übernimmt beim Senden selbst (P4)
}
// P4: Antworten, Freigeben und Bauen nur an einem bearbeitbaren Eintrag, den kein anderer hält (Einfügen kennt diese Sperre nicht, siehe canPaste)
const free = editable && !claimedByOther
const freigabe = item.kind === 'vorschau_freigabe'
const draft = typeof item.draft === 'string' ? item.draft : ''
let mail = ''
@@ -186,12 +222,23 @@ export function rowModel(item, { me, nowMs, employees }) {
canClaim: item.status === 'offen' || item.status === 'veraltet',
canSend: sendable && editable,
canDiscard: editable,
canDone: !sendable && editable,
// Erledigt würde eine Vorschau-Freigabe schließen, ohne dass der Link hinausgeht: dort bleibt nur Freigeben oder Verwerfen (D20)
canDone: !sendable && editable && !freigabe,
// Mail-Antwort als Mensch: braucht einen Interessenten mit Adresse („Mail ohne Faden“ und Plattform-Interessenten haben keinen Mail-Weg)
canReply: REPLY_KINDS.includes(item.kind) && free && prospect !== null && filled(prospect.email),
canApprovePreview: freigabe && free,
// Bau starten wie ein „Ja“: Eintrag „unsicher“ mit Interessent; ob der noch lebt, entscheidet der Dienst (409 mit deutschem Satz)
canStartBuild: item.kind === 'unsicher' && free && filled(item.prospectId),
// Die Rückmeldung des Kunden kommt meist erst, nachdem jemand Claras Entwurf auf der Plattform gesendet hat: auch bei gesendet,
// erledigt oder von einem Kollegen übernommen. Nur ein verworfener Eintrag nimmt nichts mehr an.
canPaste: PASTE_KINDS.includes(item.kind) && item.status !== 'verworfen' && prospect !== null,
claimedByMe,
claimedByOther,
claimedByLabel,
copyText: draft,
sourceHref: safeExternalUrl(item.sourceUrl),
// Bei vorschau_freigabe ist sourceUrl die Vorschau, keine Anzeige: die Zeile bietet dort previewHref statt „Anzeige öffnen“
sourceHref: freigabe ? null : safeExternalUrl(item.sourceUrl),
previewHref: freigabe ? previewUrl(item.sourceUrl) : null,
mailtoHref: mail,
prospect,
}
@@ -223,3 +270,33 @@ export async function copyAndOpen({ text, url, copy, open }) {
}
return { copied: await copied, opened }
}
/** Zeichen eines Textes wie der Dienst sie zählt: getrimmt, in Code Points (ein Emoji ist ein Zeichen). */
export function charCount(text) {
return typeof text === 'string' ? Array.from(text.trim()).length : 0
}
/**
* Prüft den Text von „Als Mensch antworten“ und „Antwort des Kunden einfügen“ vor dem Senden (beide Grenzen sind die des Dienstes).
* Ergebnis { ok: true, text } mit getrimmtem Text oder { ok: false, error } mit deutschem Satz.
*/
export function validateReplyText(text) {
const clean = typeof text === 'string' ? text.trim() : ''
if (clean === '') return { ok: false, error: 'Bitte einen Text eingeben.' }
if (charCount(clean) > REPLY_TEXT_MAX) return { ok: false, error: `Der Text ist zu lang (höchstens ${REPLY_TEXT_MAX} Zeichen).` }
return { ok: true, text: clean }
}
/**
* Meldung der Zeile bei einer gescheiterten Pult-Aktion: der Satz des Dienstes. Eine Zeitüberschreitung oder ein Netzfehler (status 0) bei
* reply, approve-preview und start-build heißt nicht „nicht gesendet“: der Dienst kann die Aktion noch ausgeführt haben (D14).
*/
export function actionErrorText(err, action) {
if (SLOW_PULT_ACTIONS.includes(action) && err?.status === 0) return UNCERTAIN_TEXT
return typeof err?.message === 'string' && err.message !== '' ? err.message : ACTION_FAILED
}
/** Meldung nach „Antwort des Kunden einfügen“: der Dienst antwortet mit status neu | fortgesetzt | doppelt. */
export function pasteDoneText(result) {
return result?.status === 'doppelt' ? 'Diese Antwort war schon eingefügt.' : 'Eingefügt. Nora sortiert die Antwort ein.'
}
+19
View File
@@ -199,6 +199,22 @@
.wk-pult-row .wk-pult-draft { display: block; width: 100%; min-height: 140px; margin: 0 0 12px; }
.wk-pult-row .wk-pult-actions { align-items: center; gap: 8px 16px; }
/* Aufgeklappte Felder unter den Aktionen: „Als Mensch antworten“ (reply) und „Antwort des Kunden einfügen“ (paste). Haarlinie darüber, volle Breite. */
.wk-pult-row .wk-pult-reply, .wk-pult-row .wk-pult-paste {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--line);
}
.wk-pult-reply .wk-hint, .wk-pult-paste .wk-hint { margin: 0; }
.wk-pult-reply .textarea, .wk-pult-paste .textarea { display: block; width: 100%; min-height: 140px; }
.wk-pult-panel-foot { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px 16px; }
.wk-pult-count { color: var(--text-2); font-size: 13px; font-variant-numeric: tabular-nums; }
.wk-pult-count.over { color: var(--red-fg); }
/* ---------- Interessenten-Panel (Dialog) ---------- */
/* Fremdtext (Anfrage, Nachrichten): Zeilenumbrüche des Absenders bleiben, lange Wörter und Adressen brechen um */
@@ -236,6 +252,9 @@
.wk-pult-row { padding: 12px 0; }
.wk-pult-row .wk-pult-actions { flex-direction: column; align-items: stretch; gap: 8px; }
.wk-pult-row .wk-pult-actions .btn, .wk-pult-row .wk-pult-actions .link-danger { width: 100%; min-height: 40px; justify-content: center; text-align: center; }
/* Felder: Zähler und Knopf untereinander, der Knopf über die volle Breite */
.wk-pult-panel-foot { flex-direction: column; align-items: stretch; }
.wk-pult-panel-foot .btn { width: 100%; min-height: 40px; justify-content: center; text-align: center; }
}
/* ---------- Weniger Bewegung ---------- */
+39 -6
View File
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict'
import { loadModule } from './load.mjs'
// API-Client der Hustler-Seite: URLs, Methoden, Header, deutsche Fehlertexte, JWT-Wiederholung, Pult-Prüfungen
const { createWorkersApi, REQUEST_TIMEOUT_MS, AUTH_COOLDOWN_MS } = await loadModule('src/lib/workersApi.js')
const { createWorkersApi, REQUEST_TIMEOUT_MS, AUTH_COOLDOWN_MS, ACTION_TIMEOUT_MS, SLOW_PULT_ACTIONS } = await loadModule('src/lib/workersApi.js')
const { createJwtCache } = await loadModule('src/lib/serverpulsAuth.js')
const jwts = []
@@ -118,19 +118,21 @@ test('pult: ein Status, Komma-Liste oder Array (Übersicht §7); unbekannte Stat
})
test('pultAction: nur bekannte Aktionen, Pfad-Tricks abgewiesen', async () => {
const f = fakeFetch(okReplies(7))
const f = fakeFetch(okReplies(8))
const api = createWorkersApi({ getJwt, fetchImpl: f })
for (const bad of ['../x', 'delete', '']) {
for (const bad of ['../x', 'delete', '', 'start-build/../x', 'Start-Build']) {
await assert.rejects(api.pultAction('p1', bad), /Unbekannte Aktion/)
}
assert.equal(f.calls.length, 0)
for (const a of ['claim', 'sent', 'discard', 'done', 'reply', 'approve-preview', 'paste']) {
for (const a of ['claim', 'sent', 'discard', 'done', 'reply', 'approve-preview', 'paste', 'start-build']) {
await api.pultAction('p1', a, a === 'reply' || a === 'paste' ? { text: 'x' } : undefined)
}
assert.deepEqual(f.calls.map((c) => c.url.split('/').pop()), ['claim', 'sent', 'discard', 'done', 'reply', 'approve-preview', 'paste'])
assert.deepEqual(f.calls.map((c) => c.url.split('/').pop()), ['claim', 'sent', 'discard', 'done', 'reply', 'approve-preview', 'paste', 'start-build'])
assert.equal(f.calls[4].init.body, JSON.stringify({ text: 'x' }))
// Plattform-Folge-Antwort einfügen (P4-T34)
assert.deepEqual([f.calls[6].url, f.calls[6].init.method, f.calls[6].init.body], ['/api/workers/pult/p1/paste', 'POST', JSON.stringify({ text: 'x' })])
// Bau starten (P4-T32): POST ohne Body
assert.deepEqual([f.calls[7].url, f.calls[7].init.method, f.calls[7].init.body], ['/api/workers/pult/p1/start-build', 'POST', undefined])
})
test('limit wird geklemmt (Ereignisse 1..200, Spur 1..50)', async () => {
@@ -192,9 +194,40 @@ test('Zeitgrenze: rechtzeitige Antworten bleiben unberührt, ein späterer Abruf
assert.deepEqual(await quick.state(), {})
})
test('Zeitgrenze: Standardwerte 20 s und 2 min', () => {
test('Zeitgrenze: Standardwerte 20 s und 2 min, für langsame Pult-Aktionen 60 s', () => {
assert.equal(REQUEST_TIMEOUT_MS, 20000)
assert.equal(AUTH_COOLDOWN_MS, 120000)
assert.equal(ACTION_TIMEOUT_MS, 60000)
assert.deepEqual(SLOW_PULT_ACTIONS, ['reply', 'approve-preview', 'start-build'])
assert.ok(Object.isFrozen(SLOW_PULT_ACTIONS))
})
// P4-T32 (D14): Antworten, Freigeben und Bauen laufen im Dienst länger (SMTP, Warteschlange); bricht der Client nach 20 s ab, sähe der Mensch einen Fehler,
// obwohl die Mail hinausgeht. Nur diese drei Aktionen bekommen die lange Frist, alles andere bleibt bei der kurzen.
test('Zeitgrenze (D14): reply, approve-preview und start-build warten länger als die übrigen Pult-Aktionen', async () => {
const slowReply = (ms) => async () => {
await new Promise((resolve) => setTimeout(resolve, ms))
return { ok: true, status: 200, json: async () => ({ item: { $id: 'p1' } }) }
}
// kurze Frist 30 ms, lange Frist 400 ms, Antwort nach 120 ms
for (const action of ['reply', 'approve-preview', 'start-build']) {
const api = createWorkersApi({ getJwt, fetchImpl: slowReply(120), timeoutMs: 30, actionTimeoutMs: 400 })
assert.deepEqual(await api.pultAction('p1', action, action === 'reply' ? { text: 'x' } : undefined), { item: { $id: 'p1' } }, action)
}
for (const action of ['claim', 'sent', 'discard', 'done', 'paste']) {
const api = createWorkersApi({ getJwt, fetchImpl: slowReply(120), timeoutMs: 30, actionTimeoutMs: 400 })
await assert.rejects(api.pultAction('p1', action, action === 'paste' ? { text: 'x' } : undefined), { message: DOWN, status: 0 }, action)
}
// Lesen (Liste, Zustand) behält die kurze Frist
await assert.rejects(createWorkersApi({ getJwt, fetchImpl: slowReply(120), timeoutMs: 30, actionTimeoutMs: 400 }).pult(), { message: DOWN, status: 0 })
// auch die lange Frist endet: ein hängender Abruf meldet nach ihr die Dienst-Meldung (status 0) und bricht ab
const signals = []
const hang = (url, init) => { signals.push(init.signal); return new Promise(() => {}) }
const api = createWorkersApi({ getJwt, fetchImpl: hang, timeoutMs: 20, actionTimeoutMs: 150 })
const t0 = Date.now()
await assert.rejects(api.pultAction('p1', 'reply', { text: 'x' }), { message: DOWN, status: 0 })
assert.ok(Date.now() - t0 >= 120, 'wartet die lange Frist ab')
assert.equal(signals[0].aborted, true)
})
test('401-Folge: danach kein frisches JWT mehr, bis die Sperre abgelaufen ist (höchstens ein createJWT je Sperre)', async () => {
+160 -3
View File
@@ -3,8 +3,10 @@ import assert from 'node:assert/strict'
import { loadModule } from './load.mjs'
// Reine Pult-Logik des Reiters „Freigabe“: Filter, Alter, Veraltet-Anzeige, sichere Links, Zeilenmodell, Kopieren & Öffnen
const { PULT_KIND_LABELS, SENDABLE_KINDS, PULT_FILTERS, staleView, ageLabel, safeExternalUrl, mailtoHref, employeeName, rowModel, copyAndOpen } =
await loadModule('src/lib/workersPult.js')
const {
PULT_KIND_LABELS, SENDABLE_KINDS, PULT_FILTERS, REPLY_KINDS, PASTE_KINDS, REPLY_TEXT_MAX, UNCERTAIN_TEXT,
staleView, ageLabel, safeExternalUrl, mailtoHref, employeeName, rowModel, copyAndOpen, validateReplyText, charCount, actionErrorText, pasteDoneText,
} = await loadModule('src/lib/workersPult.js')
const NOW = Date.parse('2026-10-19T09:00:00Z')
const MIN = 60 * 1000
@@ -147,10 +149,13 @@ test('rowModel: veraltet bleibt übernehmbar, senden und verwerfen; Abgeschlosse
m = rowModel({ ...base, status }, ctx)
assert.deepEqual([m.canClaim, m.canSend, m.canDiscard, m.canDone, m.stale.stale], [false, false, false, false, false], status)
}
for (const kind of ['vorschau_freigabe', 'unsicher', 'beschwerde', 'preisfrage', 'mensch_gewuenscht', 'bau_fehlgeschlagen']) {
for (const kind of ['unsicher', 'beschwerde', 'preisfrage', 'mensch_gewuenscht', 'bau_fehlgeschlagen']) {
m = rowModel({ ...base, kind, status: 'offen' }, ctx)
assert.deepEqual([m.canSend, m.canDiscard, m.canDone], [false, true, true], kind)
}
// P4-T32 (D20): „Erledigt“ würde die Vorschau-Freigabe schließen, ohne dass der Link hinausgeht: bei vorschau_freigabe gibt es nur Freigeben oder Verwerfen
m = rowModel({ ...base, kind: 'vorschau_freigabe', status: 'offen' }, ctx)
assert.deepEqual([m.canSend, m.canDiscard, m.canDone], [false, true, false])
m = rowModel({ ...base, kind: 'mail_antwort', status: 'in_arbeit' }, ctx)
assert.deepEqual([m.canSend, m.canDone], [true, false])
})
@@ -277,3 +282,155 @@ test('copyAndOpen: open läuft noch im selben Zug (Nutzer-Geste), auch wenn copy
const bad = await copyAndOpen({ text: 'D', url: 'javascript:alert(1)', copy: async () => {}, open: () => { opened += 1; return null } })
assert.deepEqual(bad, { copied: true, opened: false }); assert.equal(opened, 0)
})
// ---- P4-T32: als Mensch antworten, Vorschau freigeben, Bau starten, Antwort des Kunden einfügen ----
const T32_CTX = { me: { id: 'u1', name: 'Justin' }, nowMs: NOW, employees: [{ userId: 'u2', displayName: 'Kenso' }] }
const PREVIEW_URL = 'https://entwurf.project.webklar.com/AAA/'
const entry = (over = {}) => ({ $id: 'e1', kind: 'unsicher', status: 'offen', title: 'T', draft: 'D', createdAt: iso(-HOUR), prospectId: 'p1', prospect: { name: 'X', email: 'x@verein.example' }, ...over })
const flags = (item, ctx = T32_CTX) => {
const m = rowModel(item, ctx)
return [m.canReply, m.canApprovePreview, m.canStartBuild, m.canPaste]
}
test('Arten: REPLY_KINDS und PASTE_KINDS sind eingefroren und liegen im Dienst genauso', () => {
assert.deepEqual(REPLY_KINDS, ['mail_antwort', 'unsicher', 'preisfrage', 'mensch_gewuenscht'])
assert.deepEqual(PASTE_KINDS, ['plattform_antwort'])
assert.ok(Object.isFrozen(REPLY_KINDS) && Object.isFrozen(PASTE_KINDS))
assert.equal(REPLY_TEXT_MAX, 8000)
})
test('rowModel canReply: Art, Status, Übernahme und Mail-Kanal (Interessent mit Adresse)', () => {
// Art unsicher, offen, Interessent mit Adresse: ja; vorschau_freigabe und beschwerde: nein
assert.deepEqual(flags(entry()), [true, false, true, false])
assert.equal(rowModel(entry({ kind: 'beschwerde' }), T32_CTX).canReply, false)
assert.equal(rowModel(entry({ kind: 'beschwerde' }), T32_CTX).canApprovePreview, false)
for (const kind of REPLY_KINDS) assert.equal(rowModel(entry({ kind }), T32_CTX).canReply, true, kind)
for (const kind of ['plattform_antwort', 'vorschau_freigabe', 'beschwerde', 'bau_fehlgeschlagen', 'neue_art']) assert.equal(rowModel(entry({ kind }), T32_CTX).canReply, false, kind)
// „Mail ohne Faden“: kein Interessent ⇒ keine Antwort-Funktion; ohne Adresse (Plattform-Interessent) ebenso (Ruling D13)
assert.equal(rowModel(entry({ prospect: null }), T32_CTX).canReply, false)
assert.equal(rowModel(entry({ prospect: undefined }), T32_CTX).canReply, false)
assert.equal(rowModel(entry({ prospect: { name: 'X' } }), T32_CTX).canReply, false)
for (const email of ['', ' ', null, 7]) assert.equal(rowModel(entry({ prospect: { name: 'X', email } }), T32_CTX).canReply, false, String(email))
// Status: offen, in_arbeit (nur eigene Übernahme) und veraltet; alles Abgeschlossene nicht
for (const status of ['offen', 'veraltet']) assert.equal(rowModel(entry({ status }), T32_CTX).canReply, true, status)
assert.equal(rowModel(entry({ status: 'in_arbeit', claimedBy: 'mensch:u1' }), T32_CTX).canReply, true)
assert.equal(rowModel(entry({ status: 'in_arbeit', claimedBy: 'mensch:u2' }), T32_CTX).canReply, false) // Kollege hat übernommen
assert.equal(rowModel(entry({ status: 'in_arbeit', claimedBy: 'max' }), T32_CTX).canReply, false) // Max sendet gerade selbst
assert.equal(rowModel(entry({ status: 'veraltet', claimedBy: 'mensch:u2' }), T32_CTX).canReply, true) // veraltet hält nichts mehr
for (const status of ['gesendet', 'erledigt', 'verworfen']) assert.equal(rowModel(entry({ status }), T32_CTX).canReply, false, status)
})
test('rowModel canApprovePreview und previewHref: nur Art vorschau_freigabe, gleiche Status-Regeln, nur die Entwurfs-Adresse', () => {
const freigabe = (over = {}) => entry({ kind: 'vorschau_freigabe', sourceUrl: PREVIEW_URL, ...over })
let m = rowModel(freigabe(), T32_CTX)
assert.deepEqual([m.canApprovePreview, m.canReply, m.canStartBuild, m.canPaste, m.previewHref], [true, false, false, false, PREVIEW_URL])
// dieselben Status-Regeln wie bei canReply
for (const status of ['offen', 'veraltet']) assert.equal(rowModel(freigabe({ status }), T32_CTX).canApprovePreview, true, status)
assert.equal(rowModel(freigabe({ status: 'in_arbeit', claimedBy: 'mensch:u1' }), T32_CTX).canApprovePreview, true)
assert.equal(rowModel(freigabe({ status: 'in_arbeit', claimedBy: 'mensch:u2' }), T32_CTX).canApprovePreview, false)
for (const status of ['gesendet', 'erledigt', 'verworfen']) assert.equal(rowModel(freigabe({ status }), T32_CTX).canApprovePreview, false, status)
// andere Arten geben nichts frei, auch mit Entwurfs-Adresse
for (const kind of ['unsicher', 'mail_antwort', 'plattform_antwort', 'beschwerde']) {
m = rowModel(freigabe({ kind }), T32_CTX)
assert.deepEqual([m.canApprovePreview, m.previewHref], [false, null], kind)
}
// previewHref: nur https://entwurf.project.webklar.com/…, alles andere null (der Link steht neben dem Knopf „Freigeben“)
for (const bad of ['https://fremd.example/', 'javascript:alert(1)', 'http://entwurf.project.webklar.com/AAA/', 'https://entwurf.project.webklar.com.fremd.example/AAA/',
'https://entwurf.project.webklar.com@fremd.example/AAA/', 'https://user:pw@entwurf.project.webklar.com/AAA/', 'https://entwurf.project.webklar.com:8443/AAA/',
'https://www.project.webklar.com/AAA/', 'https://project.webklar.com/AAA/', '//entwurf.project.webklar.com/AAA/', '', null, undefined, 7]) {
m = rowModel(freigabe({ sourceUrl: bad }), T32_CTX)
assert.equal(m.previewHref, null, String(bad))
assert.equal(m.canApprovePreview, true, String(bad)) // der Knopf bleibt, die Adresse fehlt nur; der Dienst prüft die Freigabe selbst
}
assert.equal(rowModel(freigabe({ sourceUrl: 'HTTPS://ENTWURF.PROJECT.WEBKLAR.COM/AAA/' }), T32_CTX).previewHref, PREVIEW_URL) // vom Browser normalisiert
// abgeschlossen bleibt die Adresse im Modell (die Zeile braucht sie dort nicht)
assert.equal(rowModel(freigabe({ status: 'erledigt' }), T32_CTX).previewHref, PREVIEW_URL)
})
test('rowModel (D20): vorschau_freigabe zeigt keine „Anzeige öffnen“-Adresse (sourceUrl ist die Vorschau) und kein „Erledigt“', () => {
const m = rowModel(entry({ kind: 'vorschau_freigabe', sourceUrl: PREVIEW_URL }), T32_CTX)
assert.deepEqual([m.sourceHref, m.canDone, m.canDiscard, m.canSend, m.canClaim], [null, false, true, false, true])
// andere Arten behalten ihre Anzeige-Adresse
assert.equal(rowModel(entry({ kind: 'plattform_antwort', sourceUrl: 'https://www.kleinanzeigen.de/s-anzeige/x/1-2-3' }), T32_CTX).sourceHref, 'https://www.kleinanzeigen.de/s-anzeige/x/1-2-3')
})
test('rowModel canStartBuild (GB16): nur Art unsicher mit prospectId, bearbeitbar und nicht von einem anderen übernommen', () => {
assert.equal(rowModel(entry(), T32_CTX).canStartBuild, true)
assert.equal(rowModel(entry({ prospect: null }), T32_CTX).canStartBuild, true) // die Kurzfassung des Interessenten fehlt, die Id nicht: der Dienst prüft
for (const prospectId of [undefined, null, '', ' ', 7]) assert.equal(rowModel(entry({ prospectId }), T32_CTX).canStartBuild, false, String(prospectId))
for (const kind of ['mail_antwort', 'preisfrage', 'mensch_gewuenscht', 'beschwerde', 'vorschau_freigabe', 'plattform_antwort', 'bau_fehlgeschlagen']) {
assert.equal(rowModel(entry({ kind }), T32_CTX).canStartBuild, false, kind)
}
assert.equal(rowModel(entry({ status: 'in_arbeit', claimedBy: 'mensch:u1' }), T32_CTX).canStartBuild, true)
assert.equal(rowModel(entry({ status: 'in_arbeit', claimedBy: 'mensch:u2' }), T32_CTX).canStartBuild, false)
assert.equal(rowModel(entry({ status: 'veraltet' }), T32_CTX).canStartBuild, true)
for (const status of ['gesendet', 'erledigt', 'verworfen']) assert.equal(rowModel(entry({ status }), T32_CTX).canStartBuild, false, status)
})
test('rowModel canPaste: Art plattform_antwort mit Interessent, in jedem Status außer verworfen, auch bei einem Kollegen', () => {
const plattform = (over = {}) => entry({ kind: 'plattform_antwort', prospect: { name: 'X' }, ...over })
for (const status of ['offen', 'in_arbeit', 'veraltet', 'gesendet', 'erledigt']) assert.equal(rowModel(plattform({ status }), T32_CTX).canPaste, true, status)
// von einem Kollegen übernommen oder schon gesendet: bleibt einfügbar (die Rückmeldung kommt meist erst nach dem Senden)
assert.equal(rowModel(plattform({ status: 'in_arbeit', claimedBy: 'mensch:u2' }), T32_CTX).canPaste, true)
assert.equal(rowModel(plattform({ status: 'in_arbeit', claimedBy: 'max' }), T32_CTX).canPaste, true)
assert.equal(rowModel(plattform({ status: 'verworfen' }), T32_CTX).canPaste, false)
assert.equal(rowModel(plattform({ prospect: null }), T32_CTX).canPaste, false)
assert.equal(rowModel(plattform({ prospect: undefined }), T32_CTX).canPaste, false)
for (const kind of ['mail_antwort', 'unsicher', 'vorschau_freigabe', 'beschwerde', 'preisfrage', 'mensch_gewuenscht', 'bau_fehlgeschlagen']) {
assert.equal(rowModel(plattform({ kind }), T32_CTX).canPaste, false, kind)
}
// Einfügen und Antworten schließen sich aus: eine Plattform-Antwort geht über die Plattform, eine Mail-Art nie über das Einfügen
assert.deepEqual(flags(plattform()), [false, false, false, true])
})
test('rowModel: eine Übernahme durch einen Worker (max) heißt „Max (automatisch)“, nie „Kollege“ (wie bei doneBy)', () => {
const held = (claimedBy) => rowModel(entry({ kind: 'mail_antwort', status: 'in_arbeit', claimedBy }), T32_CTX)
let m = held('max')
assert.deepEqual([m.claimedByLabel, m.claimedByOther, m.claimedByMe], ['Max (automatisch)', true, false])
assert.equal(held('clara').claimedByLabel, 'Clara (automatisch)')
// Menschen wie bisher; unbekannte Kennungen (auch Objekt-Schlüssel) sind keine Worker
assert.equal(held('mensch:u2').claimedByLabel, 'Kenso')
assert.equal(held('mensch:u9').claimedByLabel, 'Kollege')
for (const by of ['constructor', '__proto__', 'system']) assert.equal(held(by).claimedByLabel, 'Kollege', by)
// ein Mensch, dessen Nutzer-Kennung zufällig wie ein Worker heißt, bleibt ein Mensch
assert.equal(held('mensch:max').claimedByLabel, 'Kollege')
})
test('validateReplyText: getrimmt, leer und zu lang mit den Sätzen des Dienstes, Grenze 8000 Zeichen (Code Points)', () => {
assert.deepEqual(validateReplyText(' '), { ok: false, error: 'Bitte einen Text eingeben.' })
for (const bad of ['', '\n\t ', undefined, null, 5, {}]) assert.deepEqual(validateReplyText(bad), { ok: false, error: 'Bitte einen Text eingeben.' }, String(bad))
assert.deepEqual(validateReplyText(' Hallo '), { ok: true, text: 'Hallo' })
assert.deepEqual(validateReplyText('Zeile 1\n\nZeile 2'), { ok: true, text: 'Zeile 1\n\nZeile 2' }) // innere Umbrüche bleiben
const tooLong = { ok: false, error: 'Der Text ist zu lang (höchstens 8000 Zeichen).' }
assert.deepEqual(validateReplyText('a'.repeat(8001)), tooLong)
assert.equal(validateReplyText('a'.repeat(8000)).ok, true)
assert.equal(validateReplyText(` ${'a'.repeat(8000)} `).ok, true) // gezählt wird der getrimmte Text
// Code Points wie im Dienst: 8000 Emoji sind 8000 Zeichen (16000 UTF-16-Einheiten), 8001 sind zu viel
assert.equal(validateReplyText('😀'.repeat(8000)).ok, true)
assert.deepEqual(validateReplyText('😀'.repeat(8001)), tooLong)
assert.equal(charCount(' 😀a '), 2); assert.equal(charCount(undefined), 0); assert.equal(charCount('a'.repeat(8001)), 8001)
})
test('actionErrorText (D14): bei reply, approve-preview und start-build heißt „keine Antwort“ nicht „nicht gesendet“', () => {
assert.equal(UNCERTAIN_TEXT, 'Kann gesendet sein – Pult neu laden.')
const down = Object.assign(new Error('Der Hustler-Dienst ist gerade nicht erreichbar.'), { status: 0 })
for (const action of ['reply', 'approve-preview', 'start-build']) assert.equal(actionErrorText(down, action), UNCERTAIN_TEXT, action)
// alle anderen Aktionen und alle echten Antworten des Dienstes behalten ihren Satz
for (const action of ['paste', 'claim', 'sent', 'discard', 'done', undefined]) assert.equal(actionErrorText(down, action), down.message, String(action))
const conflict = Object.assign(new Error('Wird schon von einem Kollegen bearbeitet.'), { status: 409 })
assert.equal(actionErrorText(conflict, 'reply'), 'Wird schon von einem Kollegen bearbeitet.')
assert.equal(actionErrorText(Object.assign(new Error('Anfrage zu groß.'), { status: 413 }), 'reply'), 'Anfrage zu groß.')
assert.equal(actionErrorText(Object.assign(new Error('Anmeldung abgelaufen. Bitte neu anmelden.'), { status: 401 }), 'reply'), 'Anmeldung abgelaufen. Bitte neu anmelden.')
// kein Satz da: allgemeiner Hinweis
assert.equal(actionErrorText(new Error(''), 'reply'), 'Das hat nicht geklappt.')
assert.equal(actionErrorText(undefined, 'paste'), 'Das hat nicht geklappt.')
assert.equal(actionErrorText(null, 'reply'), 'Das hat nicht geklappt.')
})
test('pasteDoneText: neu und fortgesetzt melden das Einsortieren, doppelt meldet „schon eingefügt“', () => {
assert.equal(pasteDoneText({ status: 'neu', messageId: 'm1', threadId: 't1' }), 'Eingefügt. Nora sortiert die Antwort ein.')
assert.equal(pasteDoneText({ status: 'fortgesetzt' }), 'Eingefügt. Nora sortiert die Antwort ein.')
assert.equal(pasteDoneText({ status: 'doppelt' }), 'Diese Antwort war schon eingefügt.')
for (const odd of [undefined, null, {}, 'doppelt', { status: 7 }]) assert.equal(pasteDoneText(odd), 'Eingefügt. Nora sortiert die Antwort ein.', String(odd))
})
+531 -1
View File
@@ -334,8 +334,12 @@ test('PultList (gerendert): Aktionen je Art und Status, Fremdtext nur als Text,
// nicht sendbare Art: Erledigt statt Senden, aber Verwerfen bleibt; abgeschlossene Einträge haben keine Aktion
html = show([{ ...base, kind: 'unsicher', draft: '' }])
assert.ok(html.includes(erledigt)); assert.ok(html.includes('Verwerfen')); assert.ok(!html.includes('Als gesendet markieren')); assert.ok(!html.includes('<textarea'))
html = show([{ ...base, status: 'gesendet' }])
// gesendet: nichts mehr zu senden oder zu verwerfen; ohne Interessent bleibt die Zeile ohne Aktionen, mit Interessent nur „Antwort des Kunden einfügen“ (P4-T32, D13)
html = show([{ ...base, status: 'gesendet', prospect: null }])
assert.ok(!html.includes('row-actions')); assert.ok(!html.includes('<textarea')); assert.ok(!html.includes('Verwerfen')); assert.ok(html.includes('<summary>Entwurf</summary>'))
html = show([{ ...base, status: 'gesendet' }])
assert.ok(html.includes('row-actions')); assert.ok(html.includes('Antwort des Kunden einfügen')); assert.ok(!html.includes('<textarea')); assert.ok(!html.includes('Verwerfen'))
assert.ok(!html.includes('Kopieren')); assert.ok(!html.includes('Als gesendet markieren')); assert.ok(!html.includes('Ich kümmere mich'))
// Mail-Antwort: Link mit genau einer Adresse und kodiertem Text; eine eingeschleuste zweite Adresse ergibt keinen Link
const mail = { ...base, kind: 'mail_antwort', draft: 'Guten Tag\nBis bald', sourceUrl: '' }
@@ -419,3 +423,529 @@ test('PultList (gerendert): wiederkehrende Bedienelemente haben je Zeile einen e
}
} finally { delete globalThis.__pultFeed; delete globalThis.__pultEmployees }
})
// ---- P4-T32: als Mensch antworten, Vorschau freigeben, Vorschau bauen, Antwort des Kunden einfügen ----
test('PultList (P4-T32): Quelltext – Aktionen über pultAction, Prüfung mit validateReplyText, Freigabe mit Rückfrage, sichere Vorschau-Adresse', () => {
const s = read('src/components/workers/PultList.jsx')
for (const t of [
"'reply'", "'approve-preview'", "'paste'", "'start-build'", 'validateReplyText', 'canReply', 'canApprovePreview', 'canStartBuild', 'canPaste', 'previewHref',
'actionErrorText', 'pasteDoneText', 'rel="noopener noreferrer"', 'target="_blank"',
'Als Mensch antworten', 'Jetzt senden', 'Vorschau ansehen', 'Freigeben & senden', 'Wirklich freigeben?', 'Vorschau bauen',
'Antwort des Kunden einfügen', 'Einfügen', 'wk-pult-reply', 'wk-pult-paste',
'Kopiere die Antwort aus der Plattform hierher. Nora sortiert sie ein; bei einer Frage schreibt Clara den nächsten Entwurf.',
]) assert.ok(s.includes(t), t)
assert.doesNotMatch(s, /dangerouslySetInnerHTML/); assert.doesNotMatch(s, /innerHTML/)
// jede der vier neuen Aktionen geht über den Client: Antwort mit dem geprüften Text, Freigabe und Bau ohne Body, Einfügen mit dem geprüften Text
assert.match(s, /api\.pultAction\(row\.id, 'reply', \{ text: [\w.]+ \}\)/)
assert.match(s, /api\.pultAction\(row\.id, 'approve-preview'\)/)
assert.match(s, /api\.pultAction\(row\.id, 'start-build'\)/)
assert.match(s, /api\.pultAction\(row\.id, 'paste', \{ text: [\w.]+ \}\)/)
// beide Texte werden vor dem Senden mit validateReplyText geprüft, ein Fehler erscheint als Meldung in der Zeile
assert.equal((s.match(/validateReplyText\(/g) ?? []).length, 2)
// die Vorschau-Adresse kommt aus rowModel (previewHref) und öffnet ohne Rückbezug in einem neuen Tab
assert.match(s, /href=\{row\.previewHref\}[^>]*target="_blank"[^>]*rel="noopener noreferrer"/)
// die Rückfrage vor dem Freigeben nutzt dieselbe Frist wie das Verwerfen (4 s)
assert.match(s, /CONFIRM_MS = 4000/)
// „Als Mensch antworten“ ist mit dem Entwurf vorbelegt, „Einfügen“ nie: die Felder sind getrennt und das Einfüge-Feld beginnt leer
assert.match(s, /fields\.reply \?\? text/); assert.match(s, /fields\.paste \?\? ''/)
assert.doesNotMatch(s, /fields\.paste \?\? (text|row\.copyText)/); assert.doesNotMatch(s, /fields\.reply \?\? (fields\.paste|'')/)
// das Fehlerschema der Zeile bleibt: Meldung mit msg ok|err, Fehlertext über actionErrorText (D14: Zeitüberschreitung ⇒ „Kann gesendet sein“)
assert.match(s, /actionErrorText\(err, action\)/)
})
test('PultList (P4-T32): Aktionsfeld erscheint auch bei gesendet, wenn eine der vier neuen Aktionen möglich ist (D13)', () => {
const s = read('src/components/workers/PultList.jsx')
assert.match(s, /row\.canSend \|\| row\.canClaim \|\| row\.canDone \|\| row\.canDiscard \|\| row\.canReply \|\| row\.canApprovePreview \|\| row\.canStartBuild \|\| row\.canPaste/)
})
test('CSS (P4-T32): .wk-pult-reply und .wk-pult-paste mit Haarlinie, volle Breite, auf dem Handy untereinander', () => {
const css = read('src/styles/workers.css')
assert.match(css, /\.wk-pult-reply/); assert.match(css, /\.wk-pult-paste/)
const rule = css.match(/\.wk-pult-row \.wk-pult-reply[^{]*\{[^}]*\}/)?.[0] ?? ''
assert.match(rule, /\.wk-pult-paste/); assert.match(rule, /border-top: 1px solid var\(--line\)/); assert.match(rule, /width: 100%/)
// schmale Bildschirme: die Fußzeile des Feldes (Zähler, Knopf) steht untereinander
const phone = css.slice(css.indexOf('@media (max-width: 860px)'))
assert.match(phone, /\.wk-pult-panel-foot/); assert.match(phone, /flex-direction: column/)
})
test('PultList (gerendert, P4-T32): Aktionen je Art – Antworten, Freigeben, Bauen, Einfügen; Felder bleiben zu, Fremdtext nur als Text', async () => {
const { render } = await loadList()
const now = Date.now()
const iso = (ms) => new Date(ms).toISOString()
const show = (items) => {
globalThis.__pultFeed = { items, loading: false, error: null, refresh: async () => {} }
globalThis.__pultEmployees = [{ userId: 'u2', displayName: 'Kenso' }]
return render({ api: {}, me: { id: 'u1', name: 'Justin' } })
}
const base = { $id: 'a', kind: 'unsicher', status: 'offen', title: 'Unklare Antwort', draft: '', createdAt: iso(now - 3600e3), prospectId: 'p1', prospect: { $id: 'p1', name: 'Verein', email: 'x@verein.example' } }
const buttonOf = (html, label) => new RegExp(`<button class="(?:btn|link-danger)[^"]*"[^>]*>${label}</button>`).test(html) // nur Knöpfe der Zeile (die Reiter „Wartet“ … „Erledigt“ haben keine Klasse btn)
try {
// unsicher mit Interessent und Adresse: antworten, bauen, erledigen, verwerfen; die Felder sind zu
let html = show([base])
for (const t of ['Als Mensch antworten', 'Vorschau bauen', 'Erledigt', 'Verwerfen', 'Ich kümmere mich']) assert.ok(buttonOf(html, t), t)
for (const t of ['Antwort des Kunden einfügen', 'Freigeben', 'Jetzt senden', 'wk-pult-reply', 'wk-pult-paste', '<textarea']) assert.ok(!html.includes(t), t)
assert.match(html, /<button[^>]*aria-expanded="false"[^>]*>Als Mensch antworten<\/button>/)
// „Mail ohne Faden“: weder Interessent noch Adresse ⇒ weder Antworten noch Bauen
html = show([{ ...base, prospectId: undefined, prospect: null }])
assert.ok(!html.includes('Als Mensch antworten')); assert.ok(!html.includes('Vorschau bauen')); assert.ok(buttonOf(html, 'Erledigt'))
// Interessent ohne Adresse (Plattform): kein Mail-Kanal ⇒ nicht antworten, aber bauen
html = show([{ ...base, prospect: { $id: 'p1', name: 'Verein' } }])
assert.ok(!html.includes('Als Mensch antworten')); assert.ok(html.includes('Vorschau bauen'))
// von einem Kollegen übernommen: weder antworten noch bauen, der Rest bleibt wie bisher
html = show([{ ...base, status: 'in_arbeit', claimedBy: 'mensch:u2' }])
assert.ok(html.includes('übernommen von Kenso')); assert.ok(!html.includes('Als Mensch antworten')); assert.ok(!html.includes('Vorschau bauen')); assert.ok(buttonOf(html, 'Erledigt'))
// von Max übernommen: „Max (automatisch)“, nie „Kollege“
html = show([{ ...base, status: 'in_arbeit', claimedBy: 'max' }])
assert.ok(html.includes('übernommen von Max (automatisch)')); assert.ok(!html.includes('Kollege'))
// mail_antwort: Mail-Link und Antwort-Funktion nebeneinander
html = show([{ ...base, kind: 'mail_antwort', draft: 'Guten Tag' }])
assert.ok(html.includes('E-Mail schreiben')); assert.ok(html.includes('Als Mensch antworten')); assert.ok(!html.includes('Vorschau bauen'))
// preisfrage, mensch_gewuenscht: antworten
for (const kind of ['preisfrage', 'mensch_gewuenscht']) assert.ok(show([{ ...base, kind }]).includes('Als Mensch antworten'), kind)
assert.ok(!show([{ ...base, kind: 'beschwerde' }]).includes('Als Mensch antworten'))
// abgeschlossen: keine Antwort-Funktion mehr
for (const status of ['gesendet', 'erledigt', 'verworfen']) {
html = show([{ ...base, status }])
assert.ok(!html.includes('Als Mensch antworten'), status); assert.ok(!html.includes('Vorschau bauen'), status); assert.ok(!html.includes('row-actions'), status)
}
// vorschau_freigabe: Vorschau ansehen (sicherer Link) + Freigeben & senden + Hinweis zu Verwerfen; kein Erledigt, keine Anzeige-Adresse, keine Antwort-Funktion
const freigabe = { ...base, kind: 'vorschau_freigabe', title: 'Vorschau <b>prüfen</b>', draft: 'Hallo, hier ist Ihre Vorschau.', sourceUrl: 'https://entwurf.project.webklar.com/AAA/' }
html = show([freigabe])
// der Name trägt den Titel der Zeile (W8), Fremdtext darin ist kodiert
assert.match(html, /<a class="btn" href="https:\/\/entwurf\.project\.webklar\.com\/AAA\/" target="_blank" rel="noopener noreferrer" aria-label="Vorschau ansehen: Vorschau &lt;b&gt;prüfen&lt;\/b&gt;">Vorschau ansehen<\/a>/)
assert.ok(html.includes('Freigeben &amp; senden')); assert.ok(buttonOf(html, 'Verwerfen')); assert.ok(html.includes('sendet die Vorschau nicht'))
for (const t of ['Anzeige öffnen', 'Als Mensch antworten', 'Vorschau bauen', 'Wirklich freigeben?']) assert.ok(!html.includes(t), t)
assert.ok(!buttonOf(html, 'Erledigt')); assert.ok(!html.includes('<b>prüfen</b>'))
// fremde, unsichere oder fehlende Adresse: kein Link, Hinweis statt Link, der Knopf zum Freigeben bleibt
for (const sourceUrl of ['https://fremd.example/AAA/', 'javascript:alert(1)', 'http://entwurf.project.webklar.com/AAA/', '']) {
html = show([{ ...freigabe, sourceUrl }])
assert.ok(!html.includes('Vorschau ansehen</a>'), sourceUrl); assert.ok(!html.includes('href="javascript:'), sourceUrl); assert.ok(!html.includes('fremd.example'), sourceUrl)
assert.ok(html.includes('Keine gültige Vorschau-Adresse hinterlegt.'), sourceUrl); assert.ok(html.includes('Freigeben &amp; senden'), sourceUrl)
}
// freigegeben (erledigt) oder von einem Kollegen übernommen: nichts mehr freizugeben
assert.ok(!show([{ ...freigabe, status: 'erledigt' }]).includes('Freigeben'))
html = show([{ ...freigabe, status: 'in_arbeit', claimedBy: 'mensch:u2' }])
assert.ok(!html.includes('Freigeben')); assert.ok(buttonOf(html, 'Verwerfen'))
// plattform_antwort: „Antwort des Kunden einfügen“ in jedem Status außer verworfen, auch bei einem Kollegen; das Feld ist zu
const plattform = { ...base, kind: 'plattform_antwort', draft: 'Hallo', sourceUrl: 'https://www.kleinanzeigen.de/s-anzeige/x/1-2-3', prospect: { $id: 'p1', name: 'Verein' } }
for (const status of ['offen', 'veraltet', 'gesendet', 'erledigt']) {
html = show([{ ...plattform, status }])
assert.ok(buttonOf(html, 'Antwort des Kunden einfügen'), status); assert.ok(!html.includes('wk-pult-paste'), status); assert.ok(!html.includes('Als Mensch antworten'), status)
}
html = show([{ ...plattform, status: 'in_arbeit', claimedBy: 'mensch:u2' }])
assert.ok(buttonOf(html, 'Antwort des Kunden einfügen')); assert.ok(html.includes('übernommen von Kenso'))
assert.ok(!show([{ ...plattform, status: 'verworfen' }]).includes('Antwort des Kunden einfügen'))
assert.ok(!show([{ ...plattform, prospect: null }]).includes('Antwort des Kunden einfügen'))
assert.match(show([{ ...plattform, status: 'in_arbeit', claimedBy: 'mensch:u2' }]), /aria-expanded="false"[^>]*>Antwort des Kunden einfügen<\/button>/)
// in den anderen Arten gibt es kein Einfügen
for (const kind of ['mail_antwort', 'unsicher', 'vorschau_freigabe']) assert.ok(!show([{ ...base, kind }]).includes('Antwort des Kunden einfügen'), kind)
} finally { delete globalThis.__pultFeed; delete globalThis.__pultEmployees }
})
// ---- PultList wird bedient (P4-T32): Mini-Läufer statt React und DOM. Die Komponenten laufen wirklich (Zustand, Effekte, Ereignisse), ----
// ---- gerendert wird in einen Baum aus { tag, props, children }; Klicks und Eingaben ruft der Test von Hand auf. Der Dienst ist eine Attrappe. ----
const FAKE_UI = `
export const Fragment = Symbol('fragment')
export function jsx(type, props, key) { return { type, props: props ?? {}, key } }
export const jsxs = jsx
let current = null
let hookIndex = 0
let effects = []
const flat = (children, out = []) => {
if (Array.isArray(children)) children.forEach((c) => flat(c, out))
else if (children !== null && children !== undefined && children !== false && children !== true) out.push(children)
return out
}
export function __mount(element) {
const instances = new Map()
let tree = null
let rendering = false
let dirty = false
const expand = (node, path, visited) => {
if (typeof node === 'string' || typeof node === 'number') return String(node)
const { type, props } = node
if (typeof type === 'function') {
visited.add(path)
let inst = instances.get(path)
if (!inst) { inst = { hooks: [], alive: true, rerender: render }; instances.set(path, inst) }
current = inst
hookIndex = 0
const out = type(props)
current = null
return out === null || out === undefined || out === false ? null : expand(out, path + '/c', visited)
}
const children = flat(props.children).map((c, i) => expand(c, path + '/' + (c && c.key != null ? 'k:' + c.key : i), visited))
return { tag: type === Fragment ? 'fragment' : type, props, children }
}
function render() {
if (rendering) { dirty = true; return }
rendering = true
do {
dirty = false
const visited = new Set()
effects = []
tree = expand(element, 'r', visited)
for (const [path, inst] of [...instances]) {
if (visited.has(path)) continue
inst.alive = false
for (const h of inst.hooks) if (h && typeof h.cleanup === 'function') h.cleanup()
instances.delete(path)
}
for (const run of effects.splice(0)) run()
} while (dirty)
rendering = false
}
render()
return { tree: () => tree, unmount: () => { for (const inst of instances.values()) { inst.alive = false; for (const h of inst.hooks) if (h && typeof h.cleanup === 'function') h.cleanup() } instances.clear() } }
}
export function useState(initial) {
const inst = current
const i = hookIndex++
if (!inst.hooks[i]) inst.hooks[i] = { value: typeof initial === 'function' ? initial() : initial }
const slot = inst.hooks[i]
return [slot.value, (next) => {
if (!inst.alive) return
const value = typeof next === 'function' ? next(slot.value) : next
if (Object.is(value, slot.value)) return
slot.value = value
inst.rerender()
}]
}
export function useRef(initial) {
const inst = current
const i = hookIndex++
if (!inst.hooks[i]) inst.hooks[i] = { current: initial }
return inst.hooks[i]
}
export function useCallback(fn, deps) {
const inst = current
const i = hookIndex++
const slot = inst.hooks[i]
if (slot && deps.every((d, k) => Object.is(d, slot.deps[k]))) return slot.fn
inst.hooks[i] = { fn, deps }
return fn
}
export function useEffect(fn, deps) {
const inst = current
const i = hookIndex++
if (!inst.hooks[i]) inst.hooks[i] = { deps: undefined, cleanup: undefined, ran: false }
const slot = inst.hooks[i]
if (slot.ran && deps && slot.deps && deps.every((d, k) => Object.is(d, slot.deps[k]))) return
effects.push(() => {
if (typeof slot.cleanup === 'function') slot.cleanup()
slot.deps = deps
slot.ran = true
slot.cleanup = fn()
})
}
`
const UI_STUBS = {
usePultFeed: 'export function usePultFeed({ statuses }) { return globalThis.__pultFeedFor(statuses) }',
useEmployees: 'export function useEmployees() { return { employees: globalThis.__pultEmployees ?? [] } }',
CopyableCredential: 'export async function copyTextChecked() {}',
ProspectPanel: 'export default function ProspectPanel() { return null }', // das Panel (Dialog) gehört nicht zu diesen Tests
}
async function loadUi() {
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..')
const plugin = {
name: 'pult-ui',
setup(build) {
build.onResolve({ filter: /^react(\/jsx-runtime)?$/ }, () => ({ path: 'react', namespace: 'fake-ui' }))
build.onLoad({ filter: /.*/, namespace: 'fake-ui' }, () => ({ contents: FAKE_UI, loader: 'js' }))
build.onResolve({ filter: /hooks\/(usePultFeed|useEmployees)$|CopyableCredential$|\/ProspectPanel$/ }, (a) => ({ path: a.path.split('/').pop(), namespace: 'pult-ui-stub' }))
build.onLoad({ filter: /.*/, namespace: 'pult-ui-stub' }, (a) => ({ contents: UI_STUBS[a.path], loader: 'js' }))
},
}
const out = await esbuild.build({
stdin: { contents: "export { __mount, jsx } from 'react'\nexport { default as PultList } from './src/components/workers/PultList.jsx'", resolveDir: root, loader: 'js' },
bundle: true, format: 'esm', platform: 'neutral', write: false, jsx: 'automatic', loader: { '.jsx': 'jsx' }, plugins: [plugin],
})
return import(`data:text/javascript;base64,${Buffer.from(out.outputFiles[0].text).toString('base64')}`)
}
// Baum durchsuchen und bedienen
const nodes = (node, test, out = []) => {
if (node === null || typeof node === 'string') return out
if (test(node)) out.push(node)
for (const child of node.children) nodes(child, test, out)
return out
}
const textOf = (node) => (typeof node === 'string' ? node : node === null ? '' : node.children.map(textOf).join(''))
const hasClass = (n, name) => n.tag !== undefined && String(n.props.className ?? '').split(' ').includes(name)
const clickable = (tree, label) => nodes(tree, (n) => (n.tag === 'button' || n.tag === 'a') && textOf(n).trim() === label)
// Knöpfe der Zeilen (Liste) und der Reiter (Filter) getrennt: „Erledigt“ ist beides
const inList = (tree) => nodes(tree, (n) => n.tag === 'ul' && hasClass(n, 'wk-pult-list'))[0] ?? { tag: 'fragment', props: {}, children: [] }
const inFilters = (tree) => nodes(tree, (n) => n.tag === 'div' && hasClass(n, 'wk-pult-filters'))[0]
const fieldOf = (tree, labelStart) => nodes(tree, (n) => n.tag === 'textarea' && String(n.props['aria-label'] ?? '').startsWith(labelStart))
const messages = (tree) => nodes(tree, (n) => n.tag === 'div' && String(n.props.className ?? '').split(' ').includes('msg')).map((n) => ({ kind: n.props.className.split(' ')[1], text: textOf(n), role: n.props.role }))
// Ein Lauf: Liste mit den Einträgen des Reiters „Wartet“ (alle anderen Reiter sind leer), fester Dienst, Takt von Hand
async function startList(items, api) {
const { __mount, jsx, PultList } = await loadUi()
const refreshes = []
const timers = new Map()
const opened = []
let nextTimer = 1
const saved = { window: globalThis.window }
globalThis.window = {
setTimeout: (fn, ms) => { const id = nextTimer++; timers.set(id, { fn, ms }); return id },
clearTimeout: (id) => { timers.delete(id) },
open: (...args) => { opened.push(args) },
}
globalThis.__pultEmployees = [{ userId: 'u2', displayName: 'Kenso' }]
globalThis.__pultFeedFor = (statuses) => ({ items: statuses === 'offen,in_arbeit' ? items : [], loading: false, error: null, refresh: async () => { refreshes.push(statuses) } })
const mounted = __mount(jsx(PultList, { api, me: { id: 'u1', name: 'Justin' } }))
const view = {
refreshes, timers, opened,
tree: () => mounted.tree(),
// Klick auf den (einzigen) Knopf/Link mit dieser Beschriftung; ein gesperrter Knopf löst nichts aus (wie im Browser)
// row: Nummer der Zeile, wenn mehrere Zeilen denselben Knopf haben
async click(label, row) {
const scope = row === undefined ? inList(view.tree()) : nodes(inList(view.tree()), (n) => n.tag === 'li')[row]
const found = clickable(scope, label)
assert.equal(found.length, 1, `Knopf „${label}“ (gefunden: ${found.length})`)
if (!found[0].props.disabled) found[0].props.onClick?.({ preventDefault() {} })
await settle()
},
async tab(label) {
const found = clickable(inFilters(view.tree()), label)
assert.equal(found.length, 1, `Reiter „${label}“`)
found[0].props.onClick()
await settle()
},
link: (label) => clickable(inList(view.tree()), label)[0],
has: (label) => clickable(inList(view.tree()), label).length > 0,
disabled: (label) => clickable(inList(view.tree()), label).map((n) => n.props.disabled === true),
async type(labelStart, value) {
const found = fieldOf(view.tree(), labelStart)
assert.equal(found.length, 1, `Textfeld „${labelStart}“ (gefunden: ${found.length})`)
found[0].props.onChange({ target: { value } })
await settle()
},
value: (labelStart) => fieldOf(view.tree(), labelStart).map((n) => n.props.value),
messages: () => messages(view.tree()),
text: () => textOf(view.tree()),
fireTimers() { for (const [id, t] of [...timers]) { timers.delete(id); t.fn() } },
end() { mounted.unmount(); globalThis.window = saved.window; delete globalThis.__pultFeedFor; delete globalThis.__pultEmployees },
}
await settle()
return view
}
// Dienst-Attrappe: jeder Aufruf wird festgehalten, die Antworten kommen der Reihe nach (Wert, Fehler oder offenes Versprechen)
function scriptedApi(...outcomes) {
const calls = []
return {
calls,
pultAction: (...args) => {
calls.push(args)
const next = outcomes.shift()
if (next instanceof Error) return Promise.reject(next)
return next ?? Promise.resolve({})
},
}
}
const failure = (message, status) => Object.assign(new Error(message), { status })
const unsicher = { $id: 'e1', kind: 'unsicher', status: 'offen', title: 'Unklare Antwort', draft: 'Entwurf von Clara', createdAt: new Date().toISOString(), prospectId: 'p1', prospect: { $id: 'p1', name: 'Verein', email: 'x@verein.example' } }
test('PultList (bedient, P4-T32): „Als Mensch antworten“ klappt auf, ist mit dem Entwurf vorbelegt, prüft den Text und zeigt die Sätze des Dienstes', async () => {
const api = scriptedApi(failure('Der Versand ist wegen Bounces gestoppt; ein Admin muss ihn freigeben.', 409), failure('Der Hustler-Dienst ist gerade nicht erreichbar.', 0), Promise.resolve({ item: { $id: 'e1' } }))
const ui = await startList([unsicher], api)
try {
assert.deepEqual(ui.value('Text der Antwort'), []) // zu: kein Textfeld
await ui.click('Als Mensch antworten')
assert.deepEqual(ui.value('Text der Antwort'), ['Entwurf von Clara']) // vorbelegt mit dem Entwurf
assert.ok(ui.text().includes('17 / 8000'))
assert.equal(nodes(ui.tree(), (n) => n.tag === 'button' && textOf(n) === 'Als Mensch antworten')[0].props['aria-expanded'], true)
// leer ⇒ Hinweis in der Zeile, nichts geht hinaus
await ui.type('Text der Antwort', ' ')
await ui.click('Jetzt senden')
assert.deepEqual(ui.messages(), [{ kind: 'err', text: 'Bitte einen Text eingeben.', role: 'alert' }])
assert.equal(api.calls.length, 0)
// zu lang ⇒ Zähler rot, Hinweis, nichts geht hinaus
await ui.type('Text der Antwort', 'a'.repeat(8001))
assert.match(textOf(nodes(ui.tree(), (n) => n.tag === 'span' && String(n.props.className).includes('wk-pult-count'))[0]), /8001 \/ 8000/)
assert.ok(String(nodes(ui.tree(), (n) => n.tag === 'span' && String(n.props.className).includes('wk-pult-count'))[0].props.className).includes('over'))
await ui.click('Jetzt senden')
assert.deepEqual(ui.messages(), [{ kind: 'err', text: 'Der Text ist zu lang (höchstens 8000 Zeichen).', role: 'alert' }])
assert.equal(api.calls.length, 0)
// Dienst lehnt ab (409): der Satz des Dienstes erscheint als Fehler, der Text bleibt zum Wiederholen stehen, die Liste wird neu geholt
await ui.type('Text der Antwort', ' Guten Tag, gern. ')
await ui.click('Jetzt senden')
assert.deepEqual(api.calls, [['e1', 'reply', { text: 'Guten Tag, gern.' }]]) // getrimmt, an die Id des Eintrags
assert.deepEqual(ui.messages(), [{ kind: 'err', text: 'Der Versand ist wegen Bounces gestoppt; ein Admin muss ihn freigeben.', role: 'alert' }])
assert.deepEqual(ui.value('Text der Antwort'), [' Guten Tag, gern. ']); assert.equal(ui.refreshes.length, 1)
// keine Antwort (Zeitüberschreitung, status 0): die Mail kann hinausgegangen sein
await ui.click('Jetzt senden')
assert.deepEqual(ui.messages(), [{ kind: 'err', text: 'Kann gesendet sein – Pult neu laden.', role: 'alert' }])
assert.equal(ui.refreshes.length, 2)
// Erfolg: der Eintrag verlässt die Liste, die Meldung steht oben, das Feld ist leer geräumt (zurück beim Entwurf), die Liste wird neu geholt
await ui.click('Jetzt senden')
assert.deepEqual(ui.messages(), [{ kind: 'ok', text: '„Unklare Antwort“ gesendet.', role: 'status' }])
assert.equal(ui.refreshes.length, 3); assert.equal(api.calls.length, 3)
assert.deepEqual(ui.value('Text der Antwort'), ['Entwurf von Clara'])
} finally { ui.end() }
})
test('PultList (bedient, P4-T32): während einer Anfrage sind alle Knöpfe der Zeile gesperrt, ein zweiter Klick sendet nichts', async () => {
let finish
const pending = new Promise((resolve) => { finish = resolve })
const api = scriptedApi(pending)
const ui = await startList([unsicher], api)
try {
await ui.click('Als Mensch antworten')
await ui.click('Jetzt senden')
assert.equal(api.calls.length, 1)
for (const label of ['Jetzt senden', 'Als Mensch antworten', 'Vorschau bauen', 'Erledigt', 'Verwerfen', 'Ich kümmere mich']) assert.deepEqual(ui.disabled(label), [true], label)
await ui.click('Jetzt senden'); await ui.click('Vorschau bauen')
assert.equal(api.calls.length, 1)
finish({ item: { $id: 'e1' } }); await settle()
assert.deepEqual(ui.disabled('Jetzt senden'), [false]); assert.deepEqual(ui.disabled('Vorschau bauen'), [false])
} finally { ui.end() }
})
test('PultList (bedient, P4-T32): ein „unsicher“-Eintrag ohne Entwurf öffnet „Als Mensch antworten“ mit leerem Feld; „Vorschau bauen“ startet den Bau', async () => {
const api = scriptedApi(failure('Für diesen Interessenten gibt es schon eine Vorschau.', 409), Promise.resolve({ item: { $id: 'e1' } }))
const ui = await startList([{ ...unsicher, draft: undefined }], api)
try {
await ui.click('Als Mensch antworten')
assert.deepEqual(ui.value('Text der Antwort'), ['']); assert.ok(ui.text().includes('0 / 8000'))
// Bau starten: ohne Body, Fehler des Dienstes als Satz, Erfolg oben bei der Liste
await ui.click('Vorschau bauen')
assert.deepEqual(api.calls, [['e1', 'start-build']])
assert.deepEqual(ui.messages(), [{ kind: 'err', text: 'Für diesen Interessenten gibt es schon eine Vorschau.', role: 'alert' }])
await ui.click('Vorschau bauen')
assert.deepEqual(ui.messages(), [{ kind: 'ok', text: '„Unklare Antwort“ wird gebaut.', role: 'status' }])
assert.equal(api.calls.length, 2)
} finally { ui.end() }
})
test('PultList (bedient, P4-T32): Vorschau freigeben – erster Klick fragt nach (4 s), zweiter sendet; keine Antwort heißt „Kann gesendet sein“', async () => {
const freigabe = { ...unsicher, $id: 'f1', kind: 'vorschau_freigabe', title: 'Vorschau prüfen', draft: 'Hier ist Ihre Vorschau.', sourceUrl: 'https://entwurf.project.webklar.com/AAA/' }
const api = scriptedApi(failure('Die Vorschau ist nicht freigabebereit.', 409), failure('Der Hustler-Dienst ist gerade nicht erreichbar.', 0), Promise.resolve({ item: { $id: 'f1' }, previewUrl: freigabe.sourceUrl }))
const ui = await startList([freigabe], api)
try {
// der Link zur Vorschau öffnet in einem neuen Tab ohne Rückbezug
const link = ui.link('Vorschau ansehen')
assert.deepEqual([link.tag, link.props.href, link.props.target, link.props.rel], ['a', 'https://entwurf.project.webklar.com/AAA/', '_blank', 'noopener noreferrer'])
assert.ok(!ui.has('Anzeige öffnen')); assert.ok(!ui.has('Erledigt'))
// erster Klick: Rückfrage, nichts geht hinaus; sie verfällt nach 4 s
await ui.click('Freigeben & senden')
assert.equal(api.calls.length, 0); assert.ok(ui.has('Wirklich freigeben?')); assert.ok(!ui.has('Freigeben & senden'))
assert.deepEqual([...ui.timers.values()].map((t) => t.ms), [4000])
ui.fireTimers(); await settle()
assert.ok(ui.has('Freigeben & senden')); assert.ok(!ui.has('Wirklich freigeben?'))
// Rückfrage und zweiter Klick ⇒ Aktion ohne Body; der Dienst lehnt ab: sein Satz erscheint
await ui.click('Freigeben & senden'); await ui.click('Wirklich freigeben?')
assert.deepEqual(api.calls, [['f1', 'approve-preview']])
assert.deepEqual(ui.messages(), [{ kind: 'err', text: 'Die Vorschau ist nicht freigabebereit.', role: 'alert' }])
assert.ok(ui.has('Freigeben & senden')) // nach der Aktion beginnt die Rückfrage von vorn
// Zeitüberschreitung: Hinweis, die Liste wird neu geholt
await ui.click('Freigeben & senden'); await ui.click('Wirklich freigeben?')
assert.deepEqual(ui.messages(), [{ kind: 'err', text: 'Kann gesendet sein – Pult neu laden.', role: 'alert' }]); assert.equal(ui.refreshes.length, 2)
// Erfolg
await ui.click('Freigeben & senden'); await ui.click('Wirklich freigeben?')
assert.equal(api.calls.length, 3)
assert.deepEqual(ui.messages(), [{ kind: 'ok', text: '„Vorschau prüfen“ freigegeben. Max sendet den Link im nächsten Sendefenster.', role: 'status' }])
// Verwerfen fragt weiterhin für sich nach, und die Rückfragen stören sich nicht
await ui.click('Verwerfen'); assert.ok(ui.has('Wirklich verwerfen?')); assert.ok(ui.has('Freigeben & senden'))
await ui.click('Freigeben & senden'); assert.ok(ui.has('Wirklich freigeben?')); assert.ok(ui.has('Verwerfen'))
} finally { ui.end() }
})
test('PultList (bedient, P4-T32): „Antwort des Kunden einfügen“ – leeres Feld mit Hinweis, Prüfung, Einfügen, doppelt, Fehler des Dienstes, Text bleibt beim Filterwechsel', async () => {
const plattform = { ...unsicher, $id: 'k1', kind: 'plattform_antwort', status: 'in_arbeit', claimedBy: 'mensch:u2', title: 'Suche Webdesigner', draft: 'Claras Entwurf', sourceUrl: 'https://www.kleinanzeigen.de/s-anzeige/x/1-2-3', prospect: { $id: 'p1', name: 'Verein' } }
const api = scriptedApi(Promise.resolve({ status: 'neu', messageId: 'm1', threadId: 't1' }), Promise.resolve({ status: 'doppelt', messageId: 'm1', threadId: 't1' }), failure('Dieser Kontakt ist gesperrt.', 409))
const ui = await startList([plattform], api)
try {
assert.deepEqual(ui.value('Antwort des Kunden'), [])
await ui.click('Antwort des Kunden einfügen') // auch bei einem Kollegen, der den Eintrag hält
assert.deepEqual(ui.value('Antwort des Kunden'), ['']) // leer, nie mit Claras Entwurf vorbelegt
assert.ok(ui.text().includes('Kopiere die Antwort aus der Plattform hierher. Nora sortiert sie ein; bei einer Frage schreibt Clara den nächsten Entwurf.'))
// leer ⇒ Hinweis; nichts geht hinaus
await ui.click('Einfügen')
assert.deepEqual(ui.messages(), [{ kind: 'err', text: 'Bitte einen Text eingeben.', role: 'alert' }]); assert.equal(api.calls.length, 0)
// einfügen: getrimmter Text an die Id des Eintrags; danach ist das Feld geleert und zu, die Zeile meldet das Einsortieren
await ui.type('Antwort des Kunden', ' Danke, das passt. ')
await ui.click('Einfügen')
assert.deepEqual(api.calls, [['k1', 'paste', { text: 'Danke, das passt.' }]])
assert.deepEqual(ui.messages(), [{ kind: 'ok', text: 'Eingefügt. Nora sortiert die Antwort ein.', role: 'status' }])
assert.deepEqual(ui.value('Antwort des Kunden'), []); assert.equal(ui.refreshes.length, 1)
await ui.click('Antwort des Kunden einfügen')
assert.deepEqual(ui.value('Antwort des Kunden'), ['']) // nach dem Einfügen wieder leer
// dieselbe Antwort noch einmal: der Dienst kennt sie schon
await ui.type('Antwort des Kunden', 'Danke, das passt.')
await ui.click('Einfügen')
assert.deepEqual(ui.messages(), [{ kind: 'ok', text: 'Diese Antwort war schon eingefügt.', role: 'status' }])
assert.deepEqual(ui.value('Antwort des Kunden'), [])
// Dienstfehler: Satz als Fehler, der Text bleibt stehen (auch wenn der Reiter gewechselt wird und die Zeile verschwindet)
await ui.click('Antwort des Kunden einfügen')
await ui.type('Antwort des Kunden', 'Noch eine Antwort')
await ui.click('Einfügen')
assert.deepEqual(ui.messages(), [{ kind: 'err', text: 'Dieser Kontakt ist gesperrt.', role: 'alert' }])
assert.deepEqual(ui.value('Antwort des Kunden'), ['Noch eine Antwort'])
await ui.tab('Veraltet'); assert.deepEqual(ui.value('Antwort des Kunden'), [])
await ui.tab('Wartet')
await ui.click('Antwort des Kunden einfügen')
assert.deepEqual(ui.value('Antwort des Kunden'), ['Noch eine Antwort'])
// Einfügen und Antworten sind getrennt: an einer Plattform-Antwort gibt es kein „Als Mensch antworten“
assert.ok(!ui.has('Als Mensch antworten'))
} finally { ui.end() }
})
test('PultList (bedient, P4-T32): ein Text von „Als Mensch antworten“ überlebt den Wechsel des Reiters; nach dem Absenden ist er weg', async () => {
const api = scriptedApi(Promise.resolve({ item: { $id: 'e1' } }))
const ui = await startList([unsicher], api)
try {
await ui.click('Als Mensch antworten')
await ui.type('Text der Antwort', 'Mein eigener Text')
await ui.tab('Veraltet'); assert.deepEqual(ui.value('Text der Antwort'), []) // die Zeile ist weg
await ui.tab('Wartet')
await ui.click('Als Mensch antworten')
assert.deepEqual(ui.value('Text der Antwort'), ['Mein eigener Text'])
await ui.click('Jetzt senden')
assert.deepEqual(api.calls, [['e1', 'reply', { text: 'Mein eigener Text' }]])
assert.deepEqual(ui.value('Text der Antwort'), ['Entwurf von Clara'])
} finally { ui.end() }
})
test('PultList (bedient, P4-T32): mehrere Einträge desselben Interessenten – Felder, Texte und Aktionen hängen an der Eintrags-Id', async () => {
const zweite = { ...unsicher, $id: 'e3', title: 'Zweite Rückfrage', draft: 'Entwurf drei' } // gleicher Interessent p1 wie e1
const nachfolge = { ...unsicher, $id: 'k9', kind: 'plattform_antwort', title: 'Folge-Antwort', draft: 'Claras Folge', prospect: { $id: 'p1', name: 'Verein' } }
const api = scriptedApi(Promise.resolve({ item: { $id: 'e3' } }), Promise.resolve({ status: 'neu' }))
const ui = await startList([{ ...unsicher, draft: 'Entwurf eins' }, zweite, nachfolge], api)
try {
assert.equal(nodes(ui.tree(), (n) => n.tag === 'li').length, 3)
await ui.click('Als Mensch antworten', 0)
assert.deepEqual(ui.value('Text der Antwort'), ['Entwurf eins']) // nur Zeile 1 ist aufgeklappt
await ui.click('Als Mensch antworten', 1)
await ui.type('Text der Antwort: Zweite Rückfrage', 'Text drei')
assert.deepEqual(ui.value('Text der Antwort'), ['Entwurf eins', 'Text drei']) // die Texte stören sich nicht
// gesendet wird der Text der Zeile, deren Knopf gedrückt wurde, an die Id dieses Eintrags
await ui.click('Jetzt senden', 1)
assert.deepEqual(api.calls, [['e3', 'reply', { text: 'Text drei' }]])
assert.deepEqual(ui.value('Text der Antwort'), ['Entwurf eins', 'Entwurf drei']) // e3 ist abgeschlossen (Text zurückgesetzt), e1 unberührt
// Einfügen an der dritten Zeile: nur dort, mit der Id des Eintrags k9
await ui.click('Antwort des Kunden einfügen')
await ui.type('Antwort des Kunden: Folge-Antwort', 'Ja, gern')
await ui.click('Einfügen')
assert.deepEqual(api.calls[1], ['k9', 'paste', { text: 'Ja, gern' }])
} finally { ui.end() }
})