diff --git a/src/components/workers/PultList.jsx b/src/components/workers/PultList.jsx
index 0d839ec..1fe2f5b 100644
--- a/src/components/workers/PultList.jsx
+++ b/src/components/workers/PultList.jsx
@@ -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 (
+
+ )
+}
+
// 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 && (
{row.canSend && row.kind === 'mail_antwort' && (
mail
@@ -195,19 +264,72 @@ function PultRow({ api, item, row, text, onText, refresh, onFinished, onDetails
Als gesendet markieren
)}
+ {row.canReply && (
+
toggle('reply')} aria-expanded={open === 'reply'} disabled={busy}>
+ Als Mensch antworten
+
+ )}
+ {row.canApprovePreview && (
+ <>
+ {row.previewHref
+ ?
Vorschau ansehen
+ :
Keine gültige Vorschau-Adresse hinterlegt. }
+
+ {confirming === 'approve' ? 'Wirklich freigeben?' : 'Freigeben & senden'}
+
+ >
+ )}
+ {row.canStartBuild && (
+
+ Vorschau bauen
+
+ )}
+ {row.canPaste && (
+
toggle('paste')} aria-expanded={open === 'paste'} disabled={busy}>
+ Antwort des Kunden einfügen
+
+ )}
{row.canDone && (
run(() => api.pultAction(row.id, 'done'), 'erledigt.', true)} disabled={busy}>
Erledigt
)}
{row.canDiscard && (
-
- {confirming ? 'Wirklich verwerfen?' : 'Verwerfen'}
-
+ <>
+
+ {confirming === 'discard' ? 'Wirklich verwerfen?' : 'Verwerfen'}
+
+ {/* Eine Vorschau-Freigabe wird nur durch Freigeben gesendet; Verwerfen schließt sie, ohne dass etwas hinausgeht (D20) */}
+ {row.kind === 'vorschau_freigabe' &&
„Verwerfen“ sendet die Vorschau nicht. }
+ >
)}
)}
+ {open === 'reply' && row.canReply && (
+ onField(row.id, 'reply', value)}
+ submitLabel="Jetzt senden"
+ onSubmit={sendReply}
+ busy={busy}
+ />
+ )}
+ {open === 'paste' && row.canPaste && (
+ onField(row.id, 'paste', value)}
+ submitLabel="Einfügen"
+ onSubmit={sendPaste}
+ busy={busy}
+ />
+ )}
+
{note && {note.text}
}
)
@@ -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}
diff --git a/src/lib/workersApi.js b/src/lib/workersApi.js
index 9babcfd..3a64a13 100644
--- a/src/lib/workersApi.js
+++ b/src/lib/workersApi.js
@@ -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)}`),
diff --git a/src/lib/workersPult.js b/src/lib/workersPult.js
index 577f37d..51c6b61 100644
--- a/src/lib/workersPult.js
+++ b/src/lib/workersPult.js
@@ -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:`) 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:` ü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.'
+}
diff --git a/src/styles/workers.css b/src/styles/workers.css
index 50a8945..5a66745 100644
--- a/src/styles/workers.css
+++ b/src/styles/workers.css
@@ -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 ---------- */
diff --git a/tests/unit/workersApi.test.mjs b/tests/unit/workersApi.test.mjs
index 5d75837..e86789f 100644
--- a/tests/unit/workersApi.test.mjs
+++ b/tests/unit/workersApi.test.mjs
@@ -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 () => {
diff --git a/tests/unit/workersPult.test.mjs b/tests/unit/workersPult.test.mjs
index 61b359f..f5ee02c 100644
--- a/tests/unit/workersPult.test.mjs
+++ b/tests/unit/workersPult.test.mjs
@@ -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))
+})
diff --git a/tests/unit/workersPultUi.test.mjs b/tests/unit/workersPultUi.test.mjs
index 49168bd..344529f 100644
--- a/tests/unit/workersPultUi.test.mjs
+++ b/tests/unit/workersPultUi.test.mjs
@@ -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('