Workers P3: reine Pult-Logik für den Reiter Freigabe

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-18 09:27:49 +00:00
1 parent c6f7c74c52
commit c6c022678f
2 files changed
+420

No files matched your search

+192
View File
@@ -0,0 +1,192 @@
// Reine Logik des Reiters „Freigabe“ (Pult): Filter, Alter, Veraltet-Anzeige, sichere Links, Zeilenmodell, Kopieren & Öffnen.
// 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.
const MINUTE_MS = 60 * 1000
const HOUR_MS = 60 * MINUTE_MS
const DAY_MS = 24 * HOUR_MS
// Pult-Arten des Dienstes (Übersicht §4.11) mit ihrem deutschen Namen
export const PULT_KIND_LABELS = Object.freeze({
plattform_antwort: 'Antwort auf Plattform-Anfrage',
mail_antwort: 'Antwort per E-Mail',
vorschau_freigabe: 'Vorschau freigeben',
unsicher: 'Unsicher – bitte prüfen',
beschwerde: 'Beschwerde',
preisfrage: 'Preisfrage',
mensch_gewuenscht: 'Mensch gewünscht',
bau_fehlgeschlagen: 'Bau fehlgeschlagen',
})
// 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'])
// 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' }),
Object.freeze({ id: 'veraltet', label: 'Veraltet', statuses: 'veraltet' }),
Object.freeze({ id: 'erledigt', label: 'Erledigt', statuses: 'gesendet,verworfen,erledigt' }),
])
const MENSCH = 'mensch:' // Präfix von claimedBy/doneBy: mensch:<userId>
const KOLLEGE = 'Kollege'
const WARTEND = ['offen', 'in_arbeit'] // der Dienst setzt nur diese beiden auf veraltet
const BEARBEITBAR = ['offen', 'in_arbeit', 'veraltet'] // in diesen Status kann ein Mensch den Eintrag abschließen
const MAIL_SUBJECT_MAX = 78
const MAIL_BODY_MAX = 1800
function timeMs(iso) {
return typeof iso === 'string' && iso !== '' ? Date.parse(iso) : NaN
}
/**
* Veraltet-Anzeige eines Eintrags: veraltet (Status veraltet oder staleAt erreicht, nur bei offen/in_arbeit), bald veraltet
* (staleAt in weniger als 24 Stunden: „läuft in N Std. ab“, aufgerundet) oder nichts.
*/
export function staleView(item, nowMs) {
if (item.status === 'veraltet') return { stale: true, soon: false, label: 'veraltet' }
if (!WARTEND.includes(item.status)) return { stale: false, soon: false, label: '' }
const staleAt = timeMs(item.staleAt)
if (Number.isNaN(staleAt)) return { stale: false, soon: false, label: '' }
const remaining = staleAt - nowMs
if (remaining <= 0) return { stale: true, soon: false, label: 'veraltet' }
if (remaining < DAY_MS) return { stale: false, soon: true, label: `läuft in ${Math.ceil(remaining / HOUR_MS)} Std. ab` }
return { stale: false, soon: false, label: '' }
}
/** Alter für die Zeile: gerade eben / vor 5 Min. / vor 3 Std. / vor 1 Tag / vor 2 Tagen; kaputte Zeit ergibt ''. */
export function ageLabel(iso, nowMs) {
const then = timeMs(iso)
if (Number.isNaN(then)) return ''
const age = nowMs - then // negativ, wenn die Uhr des Rechners nachgeht: gilt als gerade eben
if (age < MINUTE_MS) return 'gerade eben'
if (age < HOUR_MS) return `vor ${Math.floor(age / MINUTE_MS)} Min.`
if (age < DAY_MS) return `vor ${Math.floor(age / HOUR_MS)} Std.`
const days = Math.floor(age / DAY_MS)
return days === 1 ? 'vor 1 Tag' : `vor ${days} Tagen`
}
/**
* Eine Adresse, die man in einem neuen Tab öffnen darf: nur https, ohne Zugangsdaten in der Adresse (`https://www.x.de@evil.example`
* sieht nach x.de aus und führt woandershin). Ergebnis ist die vom Browser normalisierte Adresse, sonst null.
*/
export function safeExternalUrl(url) {
if (typeof url !== 'string') return null
let parsed
try {
parsed = new URL(url)
} catch {
return null
}
if (parsed.protocol !== 'https:' || parsed.username !== '' || parsed.password !== '') return null
return parsed.href
}
// 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.
const MAIL_ADDRESS = /^[^\s@?&%,;:#/\\<>"\u0000-\u001f\u007f]+@[^\s@?&%,;:#/\\<>"\u0000-\u001f\u007f]+$/
/**
* mailto-Link für „Antwort per E-Mail“. `to` muss genau eine gültige Adresse sein, sonst kommt '' (die Oberfläche zeigt dann keinen
* Mail-Link). Betreff einzeilig und höchstens 78 Zeichen; Text nur bis 1800 Zeichen, sonst entfällt er (zu lange Links öffnet
* das Mailprogramm nicht). Betreff und Text sind mit encodeURIComponent kodiert, Zeilenumbrüche im Text als %0D%0A (RFC 6068).
*/
export function mailtoHref({ to, subject, body } = {}) {
if (typeof to !== 'string' || !MAIL_ADDRESS.test(to)) return ''
const params = []
const singleLine = String(subject ?? '').replace(/[\s\u0000-\u001f\u007f]+/g, ' ').trim()
const cut = Array.from(singleLine).slice(0, MAIL_SUBJECT_MAX).join('').trim()
if (cut !== '') params.push(`subject=${encodeURIComponent(cut)}`)
const text = String(body ?? '')
if (text.trim() !== '' && text.length <= MAIL_BODY_MAX) params.push(`body=${encodeURIComponent(text.replace(/\r\n|\r|\n/g, '\r\n'))}`)
return `mailto:${to}${params.length > 0 ? `?${params.join('&')}` : ''}`
}
// Anzeigename zu einer Kennung `mensch:<userId>` aus der Mitarbeiterliste ({ userId, displayName }); null, wenn keiner da ist
function listedName(by, employees) {
if (typeof by !== 'string' || by === '') return null
const userId = by.startsWith(MENSCH) ? by.slice(MENSCH.length) : by
const found = Array.isArray(employees) ? employees.find((e) => e && e.userId === userId) : undefined
return typeof found?.displayName === 'string' && found.displayName !== '' ? found.displayName : null
}
/** Name zu einer Kennung `mensch:<userId>` über die Mitarbeiterliste; unbekannt oder ohne Namen: „Kollege“. */
export function employeeName(by, employees) {
return listedName(by, employees) ?? KOLLEGE
}
/**
* Zeilenmodell eines Pult-Eintrags (GET /pult: Eintrag plus `prospect`). me = { id, name } des Angemeldeten, employees = Mitarbeiterliste.
* Rechte nach Status (offen, in_arbeit, veraltet sind bearbeitbar) und Art (nur Antwort-Arten sind sendbar); die Zeile sperrt
* nichts wegen eines Kollegen, der übernommen hat: der Dienst entscheidet (409 mit deutschem Satz).
*/
export function rowModel(item, { me, nowMs, employees }) {
const editable = BEARBEITBAR.includes(item.status)
const sendable = SENDABLE_KINDS.includes(item.kind)
const prospect = item.prospect ?? null
// Übernommen ist nur ein Eintrag in Arbeit; bei veraltet steht der frühere Bearbeiter noch drin, ohne etwas zu halten
const held = item.status === 'in_arbeit' && typeof item.claimedBy === 'string' && item.claimedBy !== ''
const claimedByMe = held && me != null && me.id != null && item.claimedBy === `${MENSCH}${me.id}`
const claimedByOther = held && !claimedByMe
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
}
const draft = typeof item.draft === 'string' ? item.draft : ''
let mail = ''
if (item.kind === 'mail_antwort' && prospect !== null && prospect.email) {
mail = mailtoHref({ to: prospect.email, subject: prospect.name ? `Re: ${prospect.name}` : '', body: draft })
}
return {
id: item.$id,
kind: item.kind,
kindLabel: PULT_KIND_LABELS[item.kind] ?? item.kind,
title: item.title,
ageLabel: ageLabel(item.createdAt, nowMs),
stale: staleView(item, nowMs),
canClaim: item.status === 'offen' || item.status === 'veraltet',
canSend: sendable && editable,
canDiscard: editable,
canDone: !sendable && editable,
claimedByMe,
claimedByOther,
claimedByLabel,
copyText: draft,
sourceHref: safeExternalUrl(item.sourceUrl),
mailtoHref: mail,
prospect,
}
}
/**
* „Kopieren & öffnen“: startet das Kopieren und öffnet danach sofort die Quelle, ohne dazwischen zu warten (der Browser erlaubt
* das Öffnen nur im Zug der Nutzer-Geste). Erst danach wird auf das Kopieren gewartet. `copy(text)` liefert ein Versprechen, `open(url)`
* öffnet den Tab. Ergebnis: copied = das Versprechen von copy wurde erfüllt; opened = open wurde mit einer sicheren Adresse aufgerufen
* und hat nicht geworfen (der Rückgabewert zählt nicht: window.open mit noopener liefert immer null). Ein Fehler beim Kopieren ändert
* das Öffnen nicht und umgekehrt.
*/
export async function copyAndOpen({ text, url, copy, open }) {
let copied
try {
copied = Promise.resolve(copy(text)).then(() => true, () => false)
} catch {
copied = Promise.resolve(false)
}
let opened = false
const href = safeExternalUrl(url)
if (href !== null) {
try {
open(href)
opened = true
} catch {
opened = false
}
}
return { copied: await copied, opened }
}