// 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. import { roleOf } from './workersRoster' 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: 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 // Abgeschlossene Einträge (Reiter „Erledigt“): Bezeichnung und Badge-Ton. gesendet fällt grün auf, verworfen und erledigt bleiben neutral. const DONE_VIEW = Object.freeze({ gesendet: Object.freeze({ label: 'Gesendet', tone: 'green' }), verworfen: Object.freeze({ label: 'Verworfen', tone: 'muted' }), erledigt: Object.freeze({ label: 'Erledigt', tone: 'muted' }), }) 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:` 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:` über die Mitarbeiterliste; unbekannt oder ohne Namen: „Kollege“. */ export function employeeName(by, employees) { return listedName(by, employees) ?? KOLLEGE } /** * 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)“), * jede andere nichtleere Kennung „automatisch“. Ohne doneBy bleibt der Satzteil leer. Wichtig: ein Worker darf nie als „Kollege“ erscheinen, * deshalb geht doneBy nicht durch listedName (das jede Kennung ohne Präfix als Nutzer-ID liest). */ 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' } /** * 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 }) } // Nur abgeschlossene Einträge: Ergebnis, wer es getan hat und wann (doneAt). Sonst leer, die Zeile zeigt dann das Alter ab Anlage. const doneView = Object.hasOwn(DONE_VIEW, item.status) ? DONE_VIEW[item.status] : null const doneByLabel = doneView ? doneByPhrase(item.doneBy, employees) : '' const doneAgeLabel = doneView ? ageLabel(item.doneAt, nowMs) : '' const doneLine = doneView ? [[doneView.label, doneByLabel].filter(Boolean).join(' '), doneAgeLabel].filter(Boolean).join(' · ') : '' return { id: item.$id, kind: item.kind, kindLabel: PULT_KIND_LABELS[item.kind] ?? item.kind, title: item.title, ageLabel: ageLabel(item.createdAt, nowMs), statusLabel: doneView ? doneView.label : '', statusTone: doneView ? doneView.tone : '', doneByLabel, doneAgeLabel, doneLine, 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 } }