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:
1 parent
c6f7c74c52
commit
c6c022678f
2 files changed
+420
No files matched your search
@@ -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 }
|
||||
}
|
||||
Reference in new issue
Block a user