Release 0.6.0

This commit is contained in:
hnzio committed 2026-10-25 09:45:31 +01:00
1 parent fd47910d09
commit b80dded55d
40 files changed
+3382 -158

No files matched your search

+1 -1
View File
@@ -3,7 +3,7 @@
"title": "Sendewerk", "title": "Sendewerk",
"description": "Redaktionelle Planung und Durchführung von heinzlive-Sendungen.", "description": "Redaktionelle Planung und Durchführung von heinzlive-Sendungen.",
"tagline": "Vom Fundstück bis zur Livesendung", "tagline": "Vom Fundstück bis zur Livesendung",
"version": "0.5.12", "version": "0.6.0",
"healthCheckPath": "/healthz", "healthCheckPath": "/healthz",
"httpPort": 3000, "httpPort": 3000,
"addons": { "addons": {
+34
View File
@@ -506,3 +506,37 @@ vollständige erneute Matrix.
- Zusätzliche visuelle Chromium-Prüfung bei 656 × 400 und 390 × 700 Pixeln: - Zusätzliche visuelle Chromium-Prüfung bei 656 × 400 und 390 × 700 Pixeln:
Sendungskarte unter 130 Pixel hoch, Menü-/Ausgabe-/Timericons sichtbar und Sendungskarte unter 130 Pixel hoch, Menü-/Ausgabe-/Timericons sichtbar und
Pausebutton wechselt bedienbar zu Fortsetzen. Pausebutton wechselt bedienbar zu Fortsetzen.
## Etappe 6
| Kriterium | Nachweis | Stand |
| -------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | --------------------- |
| Spätere Originaländerung verändert Historie nicht | Verhaltenstest ändert Piece und Abschnittskategorie nach Abschluss; historische Snapshots bleiben gleich | erfüllt am 25.10.2026 |
| Abschluss nicht doppelt; übersprungen ist nicht gesendet | idempotente Befehlswiederholung, fremde Befehls-ID und materialisierte Usage-Historie im Verhaltenstest | erfüllt am 25.10.2026 |
| Bewusste Auswahl archiviert nur gewählte Originale | Abschlusskandidaten und explizite Archivauswahl im Service; Dialog trennt gesendet, übersprungen und offen | erfüllt am 25.10.2026 |
| Archivierte Sendung stabil und duplizierbar | Service- und Browsertest prüfen Nur-Lese-Ansicht sowie neuen Entwurf ohne Live-, Historien- oder Freigabedaten | erfüllt am 25.10.2026 |
| Gast sieht genau eine Sendung und nur erlaubte Felder | anonymer Chromium-Kontext und Scope-Verhaltenstests für Ablauf, Moderation und Recherche | erfüllt am 25.10.2026 |
| Gast-Schreibzugriff und fremde IDs abgelehnt | anonymer direkter POST erhält 403; fremde Medien-ID erhält 404 | erfüllt am 25.10.2026 |
| Passwort, Ablauf und Widerruf schützen direkte Aufrufe | Service- und Browsertests prüfen Seite, Gast-Sitzung und direkten Medienrequest vor und nach Widerruf | erfüllt am 25.10.2026 |
| Barkeeper verwaltet Freigaben | Anlage, Bearbeitung und Löschung direkt in der Sendung sowie gruppiert in der Verwaltung im Browser geprüft | erfüllt am 25.10.2026 |
| Offene Sendungen sind sicher freigebbar | Service und anonymer Browser prüfen aktuellen Plan vor Abschluss und stabilen Stand danach | erfüllt am 25.10.2026 |
| Bestehende Abschlüsse werden einmalig übernommen | Upgrade-Test wendet `0015` auf einen Stand bis `0014` zweimal an und findet genau eine Historie | erfüllt am 25.10.2026 |
| Bestehende Freigaben bleiben bei Erweiterung erhalten | Migration `0016` ersetzt nur die Fremdschlüsselgrenze und kopiert Freigaben sowie Gast-Sitzungen | erfüllt am 25.10.2026 |
## Ausgeführte Prüfungen Etappe 6
- `npm run check`: 0 Fehler und 0 Warnungen
- `npm test`: 52 Tests in 11 Dateien erfolgreich
- gezielter Chromium-Ablauf für Abschluss, Archiv, historische Ansicht,
offene und abgeschlossene Freigabe, Bearbeitung und Löschung in beiden
Barkeeper-Oberflächen, Passwort, privaten Scope, Reserve-Medienschutz, fremde
ID und Gast-POST: 1 von 1 erfolgreich
- `npm run test:e2e`: nach Korrektur eines bestehenden, auf den 24. Oktober
festgelegten „Heute“-Fixtures 18 von 18 Chromium-Browsertests erfolgreich
- Browserprüfung von Sendungsfreigabe und Gastansicht bei 390 × 844 Pixeln sowie
der Verwaltung bei 1280 × 900 Pixeln ohne horizontalen Überlauf; Screenshots
visuell kontrolliert
- `npm run build`: erfolgreich mit `@sveltejs/adapter-node`
- `npm run lint`: Prettier und ESLint erfolgreich
- Versionsgleichheit `0.6.0` in Paket, Lockdatei und Cloudron-Manifest geprüft
- `bash -n start.sh` und `git diff --check`: erfolgreich
+56 -11
View File
@@ -33,12 +33,12 @@ spätere API dieselben Regeln verwenden.
## Gesamtdatenmodell ## Gesamtdatenmodell
Die ersten fünf Etappen materialisieren Identität, Inhaltsverwaltung, Die ersten sechs Etappen materialisieren Identität, Inhaltsverwaltung,
Sendungsplanung und Live-Durchführung. Folgende Grenzen gelten für umgesetzte Sendungsplanung, Live-Durchführung, Historie und Lesefreigaben. Folgende Grenzen
und spätere Bereiche. gelten für umgesetzte und spätere Bereiche.
| Bereich | Geplante Entitäten und Beziehungen | | Bereich | Geplante Entitäten und Beziehungen |
| ----------- | ----------------------------------------------------------------------------------------------------------------------------------- | | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| Identität | `users`, Rollen, Sessions, Einladungen, Reset-Tokens und Sperren sind umgesetzt; technische Zugänge folgen später | | Identität | `users`, Rollen, Sessions, Einladungen, Reset-Tokens und Sperren sind umgesetzt; technische Zugänge folgen später |
| Taxonomie | getrennte `section_categories`, `formats`, `topics` samt optionalem Themenicon; Pieces und Vorschläge mehrfach verknüpft | | Taxonomie | getrennte `section_categories`, `formats`, `topics` samt optionalem Themenicon; Pieces und Vorschläge mehrfach verknüpft |
| Inhalt | `pieces` mit Status, Soft Delete, Favorit, Revision, Texten, Quellen, Werkzeugen und privaten Medien; Vorlagen speichern Snapshots | | Inhalt | `pieces` mit Status, Soft Delete, Favorit, Revision, Texten, Quellen, Werkzeugen und privaten Medien; Vorlagen speichern Snapshots |
@@ -238,13 +238,12 @@ Zurückgehen, Wiederaufnahme und bereits abgeschlossene Laufzeit getrennt von de
geplanten Dauer nachvollziehbar. geplanten Dauer nachvollziehbar.
`live_show_timer_runs` hält die unabhängige Gesamtzeit. Pause und Fortsetzen `live_show_timer_runs` hält die unabhängige Gesamtzeit. Pause und Fortsetzen
behandeln Gesamt-, Abschnitts- und Piece-Intervalle gemeinsam. Beenden setzt den behandeln Gesamt-, Abschnitts- und Piece-Intervalle gemeinsam. Beenden schließt
Sendungsstatus auf `completed`, schließt laufende Intervalle, markiert offene laufende Intervalle und setzt `ended_at_utc`, finalisiert die Sendung aber noch
Pieces aber nicht als erledigt. Eine ausdrückliche Wiederaufnahme bewahrt Zeiten nicht. Eine ausdrückliche Wiederaufnahme bewahrt Zeiten und Fortschritt; nur
und Fortschritt; nur „Neuer Durchlauf“ verwirft Laufintervalle und setzt die „Neuer Durchlauf“ verwirft Laufintervalle und setzt die Piece-Status zurück. Die
Piece-Status zurück. Die Restzeitschätzung verwendet je Abschnitt entweder die Restzeitschätzung verwendet je Abschnitt entweder die positive Abschnittsdauer
positive Abschnittsdauer oder die verbleibenden Piece-Dauern und zählt beide nie oder die verbleibenden Piece-Dauern und zählt beide nie zusammen.
zusammen.
Die interne Route `/live` benötigt weiterhin eine aktive Sitzung und darf Die interne Route `/live` benötigt weiterhin eine aktive Sitzung und darf
Recherche, Moderation und eigene Arbeitsdaten lesen. Die separate Ausgabe nutzt Recherche, Moderation und eigene Arbeitsdaten lesen. Die separate Ausgabe nutzt
@@ -295,6 +294,52 @@ sichtbar; die Oberfläche kennzeichnet den Abbruch und versucht die Verbindung
mit verlangsamtem Intervall erneut. Private Live-Daten werden nicht Teil dieses mit verlangsamtem Intervall erneut. Private Live-Daten werden nicht Teil dieses
Zustandsobjekts. Zustandsobjekts.
## Abschluss, Historie und Lesefreigaben
`src/lib/server/completion.ts` ist die Transaktionsgrenze des endgültigen
Sendungsabschlusses. Er verlangt einen beendeten Live-Lauf sowie passende Show-
und Live-Revisionen. Eine Befehls-ID in `show_finalizations` macht Wiederholungen
idempotent. In derselben Transaktion werden offene Intervalle geschlossen,
Abschnittskategorien eingefroren, ausschließlich erledigte Verwendungen außerhalb
der Reservebank in `piece_usage_history` materialisiert, bewusst gewählte
Fundus-Originale archiviert und die Sendung auf `completed` gesetzt. Jede normale
Planungs- und Live-Mutation sperrt diesen Zustand.
Die eigentlichen Sendungstexte, Quellen, Werkzeuge, Themen und Medien liegen
weiterhin in den beim Einplanen erzeugten Usage-Snapshots. Finalisierung friert
die zuvor noch katalogabhängige Abschnittskategorie ein. Abgeschlossene
Sendungen werden ausschließlich gelesen oder vollständig in einen neuen Entwurf
dupliziert. Das Duplikat erhält neue Show-, Abschnitts-, Usage- und Relations-IDs,
offene Usage-Status und weder Live-, Historien- noch Freigabedaten.
`src/lib/server/shares.ts` erzeugt für Barkeeper zufällige 256-Bit-Lesetokens;
SQLite speichert nur SHA-256-Hashes. Eine Freigabe verweist direkt auf eine
Sendung und darf deshalb bereits im Entwurf, im bereiten oder im laufenden
Zustand entstehen. Der Umfang ist eine Positivliste aus `rundown`, `moderation`
oder `research`. Optionale Passwörter liegen als Argon2id-Hash vor. Nach
erfolgreicher Prüfung entsteht eine höchstens zwölf Stunden gültige, ebenfalls
nur gehashte Gast-Sitzung; Fehlversuche werden pro Link und Clientadresse
begrenzt. Änderungen an Umfang, Passwort oder Ablauf invalidieren bestehende
Gast-Sitzungen. Ablauf und Löschung werden bei jedem HTML-, Seitendaten- und
Medienrequest erneut geprüft.
Die öffentliche Route `/freigabe/[token]` erhält keine interne Sendungs-ID und
keinen ungefilterten `Show`-Datensatz. Sie projiziert genau die am Token gebundene
Sendung und nur die Felder ihres Umfangs. Vor dem Abschluss werden aktuelle
Sendungs- und Usage-Snapshots bei jedem Aufruf neu gelesen; danach liefert
dieselbe Projektion die durch die Finalisierung stabilisierten Snapshots. Die
separate Medienroute akzeptiert ausschließlich das ausdrücklich gewählte
Präsentationsmedium einer eingeplanten Nicht-Reserve-Verwendung derselben
Sendung. Private `/media`-Dateien bleiben weiterhin an eine Redaktionssitzung
gebunden.
Die wiederverwendbare Barkeeper-Komponente `ShareManagement.svelte` liegt direkt
in der Sendungsbearbeitung und in der Verwaltung. Beide Routen verwenden
dieselben Servicefunktionen für Anlage, Bearbeitung und Löschung; die
Sendungsroute bindet Mutationen zusätzlich an ihre eigene Sendungs-ID. Die
Verwaltung gruppiert bestehende Freigaben nach Sendung und zeigt deren aktuellen
Status und Termin.
## Oberflächenstruktur ## Oberflächenstruktur
Die Hauptnavigation umfasst Eingang, Fundus, Sendungen, Archiv und Verwaltung. Die Hauptnavigation umfasst Eingang, Fundus, Sendungen, Archiv und Verwaltung.
+27 -1
View File
@@ -419,9 +419,35 @@ verbleibenden Piece-Dauern. Bereits abgeschlossene Abschnitte werden ausgelassen
ungeplante Abschnitte werden sichtbar gezählt und eine Überziehung reduziert ungeplante Abschnitte werden sichtbar gezählt und eine Überziehung reduziert
nicht die geplante Zeit kommender Inhalte. nicht die geplante Zeit kommender Inhalte.
`completed` beendet einen Durchlauf, nicht automatisch dessen offene Pieces. `ended_at_utc` beendet einen Durchlauf, nicht automatisch dessen offene Pieces.
Wiederaufnahme und neuer Durchlauf sind deshalb getrennte Befehle: Erstere Wiederaufnahme und neuer Durchlauf sind deshalb getrennte Befehle: Erstere
bewahrt Intervalle und Fortschritt, letzterer setzt beides ausdrücklich zurück. bewahrt Intervalle und Fortschritt, letzterer setzt beides ausdrücklich zurück.
Das private Markdownfeld „Interaktion“ folgt derselben Original-/Snapshotgrenze Das private Markdownfeld „Interaktion“ folgt derselben Original-/Snapshotgrenze
wie Moderation und Überleitung, ist aber nicht Teil der öffentlichen wie Moderation und Überleitung, ist aber nicht Teil der öffentlichen
Präsentations-Positivliste. Präsentations-Positivliste.
## D-027: Abschlusszustand und Freigaben als eigene Sicherheitsgrenzen
Ein beendeter Live-Lauf ist wiederaufnehmbar und deshalb nicht identisch mit
einer historischen Sendung. Erst eine eigene transaktionale Finalisierung setzt
`shows.status` auf `completed`. Eine dauerhaft gespeicherte Befehls-ID verhindert
Doppelverbuchung; Usage-Historie enthält nur `done`, nie `skipped` oder Reserve.
Bestehende Usage-Snapshots bleiben Inhaltsquelle, während Abschnittskategorien
beim Abschluss zusätzlich eingefroren werden. Historische Sendungen werden nicht
erneut bearbeitet, sondern mit neuen IDs und zurückgesetztem Laufzustand kopiert.
Lesefreigaben sind keine abgeschwächten Redaktionssitzungen. Sie verwenden eine
eigene Positivprojektion mit drei kumulativen Umfängen. Roh-Tokens und
Gast-Sitzungstokens stehen nie in SQLite, optionale Passwörter verwenden Argon2id.
Passwort, Ablauf und Widerruf werden zentral vor Seite, Seitendaten und Medium
geprüft. Präsentationsmedien sind an Freigabe, Sendung und Usage-Snapshot
gebunden; beliebige Fundus-Dateien bleiben ausgeschlossen.
Freigaben verweisen auf `shows` statt ausschließlich auf Finalisierungen. Damit
kann ein Barkeeper bereits einen Entwurf oder eine laufende Planung teilen. Die
Gastprojektion liest bei jedem Aufruf die aktuellen Sendungssnapshots; der
spätere Abschluss stabilisiert genau diese bestehende Datenquelle, ohne Token
oder URL zu wechseln. Bearbeitungen behalten den Link, invalidieren aber
vorhandene Passwortsitzungen. Vollständiges Löschen ist der unmittelbare
Widerruf. Die Sendungsbearbeitung und die zentrale Verwaltung verwenden dieselbe
Geschäftslogik und bleiben Barkeepern vorbehalten.
+53
View File
@@ -1,5 +1,58 @@
# Release Notes # Release Notes
## 0.6.0 - Abschluss, Archiv und Freigaben
Veröffentlicht am 25. Oktober 2026
Sendewerk 0.6.0 trennt das wiederaufnehmbare Ende eines Live-Laufs vom
unveränderlichen redaktionellen Abschluss. Abgeschlossene Sendungen erhalten
eine stabile Historie, liegen im Archiv und können als neuer Entwurf dupliziert
werden. Barkeeper geben genau eine geplante, laufende oder historische Sendung
über widerrufbare, optional passwortgeschützte Leselinks frei.
### Abschluss und Historie
- Ein Abschlussdialog zeigt gesendete, übersprungene und offene Pieces getrennt
und bietet ausschließlich gesendete Originale zur bewussten Archivierung an.
- Finalisierung ist transaktional und idempotent. Übersprungene Pieces und die
Reservebank werden nicht als gesendet verbucht.
- Usage-Texte, Relationen, Kategorien und tatsächliche Zeiten bleiben nach dem
Abschluss stabil, auch wenn Fundus-Originale oder Kataloge später wechseln.
- Historische Sendungen sind intern nur lesbar. Die Duplikation erzeugt einen
unabhängigen Entwurf mit offenen Pieces und ohne früheren Lauf- oder
Freigabezustand.
### Archiv und Freigaben
- Das Archiv zeigt abgeschlossene Sendungen neben archivierten und gelöschten
Pieces und bietet die Duplikation direkt an.
- Barkeeper erstellen Leselinks direkt in der Sendungsbearbeitung oder zentral
in der Verwaltung für Ablauf, Moderation oder vollständige Recherche, optional
mit Passwort und Ablaufzeit.
- Noch nicht abgeschlossene Sendungen zeigen Gästen beim nächsten Aufruf den
aktuellen Plan. Nach dem Abschluss bleibt derselbe Link auf dem stabilen
historischen Stand.
- Freigaben lassen sich in nach Sendung gruppierten Verwaltungskarten bearbeiten
und endgültig löschen; Passwortänderungen beenden vorhandene Gast-Sitzungen.
- Tokens und Gast-Sitzungen liegen nur gehasht vor; Passwörter verwenden
Argon2id und Fehlversuche sind begrenzt.
- Gastseiten projizieren genau eine Sendung. Private Originaldaten, Nutzer,
interne IDs und Felder außerhalb des gewählten Umfangs werden nicht geliefert.
- Direkte Medienaufrufe prüfen dieselbe Freigabe, Passwortsitzung, Ablaufzeit und
den Widerruf erneut und erlauben nur das freigegebene Präsentationsmedium.
### Technik und Prüfung
- Migration `0015_completion_archive_and_shares.sql` übernimmt bestehende
abgeschlossene Sendungen und ergänzt Finalisierung, Historie,
Duplikationsbefehle, Freigaben, Gast-Sitzungen und
Passwortversuchsbegrenzung.
- Migration `0016_shares_for_active_shows.sql` erhält vorhandene Freigaben und
erweitert ihre Referenz auf alle Sendungszustände.
- Paket, Lockdatei und Cloudron-Manifest tragen gemeinsam Version `0.6.0`.
- 52 Verhaltenstests und 18 Chromium-Abläufe decken den bisherigen Umfang sowie
Abschluss, Archiv und anonyme Lesefreigaben ab.
## 0.5.12 - Live-Regie und Sendungskalender ## 0.5.12 - Live-Regie und Sendungskalender
Veröffentlicht am 24. Oktober 2026 Veröffentlicht am 24. Oktober 2026
+38 -3
View File
@@ -2,10 +2,45 @@
## Aktueller Umfang ## Aktueller Umfang
Etappe 1 bis Etappe 5 sind abgeschlossen. Architektur, Gesamtdatenmodell, Etappe 1 bis Etappe 6 sind abgeschlossen. Architektur, Gesamtdatenmodell,
Anmeldung, Rollen, persistente Inhaltsbearbeitung, Sendungsplanung, Anmeldung, Rollen, persistente Inhaltsbearbeitung, Sendungsplanung,
Live-Steuerung, Präsentationsausgabe und lokaler Betriebsweg sind dokumentiert. Live-Steuerung, Präsentationsausgabe, unveränderliche Sendungshistorie,
Etappe 6 wurde nicht begonnen. Archiv und sichere Lesefreigaben sind dokumentiert.
## Abschluss, Archiv und Freigaben 0.6.0
- das Ende eines Live-Laufs bleibt wiederaufnehmbar; erst der getrennte,
idempotente Abschluss friert Sendung, Kategorien, tatsächliche Verwendungen
und Zeiten unveränderlich ein
- der Abschlussdialog zählt gesendete, übersprungene und offene Pieces getrennt
und archiviert Fundus-Originale ausschließlich nach bewusster Auswahl
- die Verwendungshistorie wird beim Abschluss materialisiert; übersprungene
Verwendungen und die Reservebank zählen nicht als gesendet
- abgeschlossene Sendungen liegen getrennt im Archiv, sind intern nur lesbar und
lassen sich mit neuen IDs, offenen Verwendungen und ohne Live- oder
Freigabezustand in einen neuen Entwurf duplizieren
- Barkeeper verwalten gehashte Leselinks direkt in der Sendungsbearbeitung und
gesammelt in der Verwaltung; Umfang, Passwort und Ablauf lassen sich ändern,
Freigaben vollständig löschen
- Entwürfe, bereite und laufende Sendungen lassen sich bereits freigeben;
Planänderungen werden beim nächsten Gastaufruf sichtbar, während der Abschluss
denselben Link auf den unveränderlichen historischen Stand festlegt
- passwortgeschützte Freigaben verwenden kurzlebige, gehashte Gast-Sitzungen und
begrenzen Fehlversuche pro Link und Clientadresse
- Gastseiten liefern genau eine positiv projizierte Sendung ohne interne IDs,
Originaldaten oder nicht freigegebene Felder; geschützte Präsentationsmedien
prüfen Umfang, Passwortsitzung, Ablauf und Widerruf bei jedem Direktaufruf
- bestehende abgeschlossene Sendungen werden durch Migration `0015` mit
eingefrorener Historie übernommen; Migration `0016` erweitert bestehende
Freigaben verlustfrei auf noch nicht abgeschlossene Sendungen
Die lokale Prüfmatrix umfasst 52 Verhaltenstests und 18 Chromium-Abläufe.
Abschluss, Archiv, historische Nur-Lese-Ansicht, Passwortfreigabe,
Scope-Begrenzung, direkter Medienzugriff, fremde IDs, Gast-Schreibversuch und
Widerruf wurden zusätzlich in einem anonymen Browserkontext geprüft. Der
erweiterte Ablauf prüft außerdem offene Sendungen, aktuelle Planänderungen,
Bearbeiten und Löschen in beiden Barkeeper-Oberflächen sowie Desktop- und
Smartphone-Layout.
## Live-Regie und Sendungskalender 0.5.12 ## Live-Regie und Sendungskalender 0.5.12
@@ -0,0 +1,115 @@
ALTER TABLE shows ADD COLUMN completed_at_utc TEXT;
ALTER TABLE show_sections ADD COLUMN category_name_snapshot TEXT;
ALTER TABLE show_sections ADD COLUMN category_color_snapshot TEXT;
CREATE TABLE show_finalizations (
show_id TEXT PRIMARY KEY REFERENCES shows(id) ON DELETE CASCADE,
command_id TEXT NOT NULL,
source_revision INTEGER NOT NULL CHECK (source_revision >= 1),
finalized_by_user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
finalized_at_utc TEXT NOT NULL,
UNIQUE (show_id, command_id)
);
CREATE TABLE show_finalization_archived_pieces (
show_id TEXT NOT NULL REFERENCES show_finalizations(show_id) ON DELETE CASCADE,
piece_id TEXT NOT NULL REFERENCES pieces(id) ON DELETE RESTRICT,
PRIMARY KEY (show_id, piece_id)
);
CREATE TABLE piece_usage_history (
usage_id TEXT PRIMARY KEY REFERENCES piece_usages(id) ON DELETE RESTRICT,
show_id TEXT NOT NULL REFERENCES shows(id) ON DELETE RESTRICT,
original_piece_id TEXT NOT NULL REFERENCES pieces(id) ON DELETE RESTRICT,
show_title_snapshot TEXT NOT NULL,
usage_title_snapshot TEXT NOT NULL,
used_at_utc TEXT NOT NULL,
actual_duration_seconds INTEGER NOT NULL CHECK (actual_duration_seconds >= 0)
);
CREATE INDEX idx_piece_usage_history_original
ON piece_usage_history(original_piece_id, used_at_utc DESC);
CREATE TABLE show_duplication_commands (
source_show_id TEXT NOT NULL REFERENCES show_finalizations(show_id) ON DELETE CASCADE,
command_id TEXT NOT NULL,
created_show_id TEXT NOT NULL REFERENCES shows(id) ON DELETE CASCADE,
created_at_utc TEXT NOT NULL,
PRIMARY KEY (source_show_id, command_id)
);
CREATE TABLE show_shares (
id TEXT PRIMARY KEY,
show_id TEXT NOT NULL REFERENCES show_finalizations(show_id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
scope TEXT NOT NULL CHECK (scope IN ('rundown', 'moderation', 'research')),
password_hash TEXT,
expires_at_utc TEXT,
revoked_at_utc TEXT,
created_by_user_id TEXT NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
created_at_utc TEXT NOT NULL
);
CREATE INDEX idx_show_shares_show ON show_shares(show_id, created_at_utc DESC);
CREATE TABLE show_share_sessions (
token_hash TEXT PRIMARY KEY,
share_id TEXT NOT NULL REFERENCES show_shares(id) ON DELETE CASCADE,
created_at_utc TEXT NOT NULL,
expires_at_utc TEXT NOT NULL
);
CREATE INDEX idx_show_share_sessions_share ON show_share_sessions(share_id, expires_at_utc);
CREATE TABLE share_password_attempts (
id TEXT PRIMARY KEY,
share_key TEXT NOT NULL,
address_key TEXT NOT NULL,
attempted_at_utc TEXT NOT NULL
);
CREATE INDEX idx_share_password_attempts_share
ON share_password_attempts(share_key, attempted_at_utc);
CREATE INDEX idx_share_password_attempts_address
ON share_password_attempts(address_key, attempted_at_utc);
UPDATE show_sections
SET category_name_snapshot = (
SELECT name FROM section_categories WHERE id = show_sections.category_id
),
category_color_snapshot = (
SELECT color FROM section_categories WHERE id = show_sections.category_id
)
WHERE show_id IN (SELECT id FROM shows WHERE status = 'completed');
UPDATE shows
SET completed_at_utc = COALESCE(
(SELECT ended_at_utc FROM show_live_states WHERE show_id = shows.id),
updated_at_utc
)
WHERE status = 'completed';
INSERT INTO show_finalizations (
show_id, command_id, source_revision, finalized_by_user_id, finalized_at_utc
)
SELECT id, 'migration-' || id, revision, NULL, completed_at_utc
FROM shows
WHERE status = 'completed';
INSERT INTO piece_usage_history (
usage_id, show_id, original_piece_id, show_title_snapshot, usage_title_snapshot,
used_at_utc, actual_duration_seconds
)
SELECT u.id, sh.id, u.original_piece_id, sh.title, u.title_snapshot,
COALESCE(sh.starts_at_utc, sh.completed_at_utc),
COALESCE((
SELECT sum(max(0, CAST((julianday(COALESCE(r.ended_at_utc, sh.completed_at_utc)) -
julianday(r.started_at_utc)) * 86400 AS INTEGER)))
FROM live_usage_runs r WHERE r.usage_id = u.id
), 0)
FROM piece_usages u
JOIN show_sections s ON s.id = u.show_section_id
JOIN shows sh ON sh.id = s.show_id
WHERE sh.status = 'completed' AND s.is_reserve_bank = 0
AND u.status = 'done' AND u.original_piece_id IS NOT NULL;
@@ -0,0 +1,43 @@
DROP INDEX idx_show_share_sessions_share;
DROP INDEX idx_show_shares_show;
ALTER TABLE show_share_sessions RENAME TO show_share_sessions_v15;
ALTER TABLE show_shares RENAME TO show_shares_v15;
CREATE TABLE show_shares (
id TEXT PRIMARY KEY,
show_id TEXT NOT NULL REFERENCES shows(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
scope TEXT NOT NULL CHECK (scope IN ('rundown', 'moderation', 'research')),
password_hash TEXT,
expires_at_utc TEXT,
revoked_at_utc TEXT,
created_by_user_id TEXT NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
created_at_utc TEXT NOT NULL
);
CREATE INDEX idx_show_shares_show ON show_shares(show_id, created_at_utc DESC);
CREATE TABLE show_share_sessions (
token_hash TEXT PRIMARY KEY,
share_id TEXT NOT NULL REFERENCES show_shares(id) ON DELETE CASCADE,
created_at_utc TEXT NOT NULL,
expires_at_utc TEXT NOT NULL
);
CREATE INDEX idx_show_share_sessions_share ON show_share_sessions(share_id, expires_at_utc);
INSERT INTO show_shares (
id, show_id, token_hash, scope, password_hash, expires_at_utc, revoked_at_utc,
created_by_user_id, created_at_utc
)
SELECT id, show_id, token_hash, scope, password_hash, expires_at_utc, revoked_at_utc,
created_by_user_id, created_at_utc
FROM show_shares_v15;
INSERT INTO show_share_sessions (token_hash, share_id, created_at_utc, expires_at_utc)
SELECT token_hash, share_id, created_at_utc, expires_at_utc
FROM show_share_sessions_v15;
DROP TABLE show_share_sessions_v15;
DROP TABLE show_shares_v15;
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "sendewerk", "name": "sendewerk",
"version": "0.5.12", "version": "0.6.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "sendewerk", "name": "sendewerk",
"version": "0.5.12", "version": "0.6.0",
"dependencies": { "dependencies": {
"@node-rs/argon2": "^2.2.2", "@node-rs/argon2": "^2.2.2",
"better-sqlite3": "^13.0.3", "better-sqlite3": "^13.0.3",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "sendewerk", "name": "sendewerk",
"private": true, "private": true,
"version": "0.5.12", "version": "0.6.0",
"type": "module", "type": "module",
"engines": { "engines": {
"node": ">=22.17 <25" "node": ">=22.17 <25"
+323
View File
@@ -47,6 +47,329 @@
--radius-small: 0.65rem; --radius-small: 0.65rem;
} }
.completion-dialog {
width: min(38rem, calc(100% - 2rem));
max-height: calc(100dvh - 2rem);
padding: 0;
border: 1px solid color-mix(in srgb, var(--gold) 55%, var(--line));
border-radius: var(--radius-large);
background: var(--panel-strong);
color: var(--text);
}
.completion-dialog::backdrop {
background: rgb(3 8 16 / 68%);
}
.completion-dialog form {
display: grid;
gap: 1rem;
padding: 1.25rem;
}
.completion-dialog h2,
.completion-dialog p {
margin: 0;
}
.completion-dialog fieldset {
display: grid;
gap: 0.5rem;
max-height: 15rem;
overflow-y: auto;
padding: 0.8rem;
border: 1px solid var(--line);
border-radius: var(--radius-medium);
}
.completion-dialog footer,
.completion-counts,
.check-row {
display: flex;
align-items: center;
gap: 0.75rem;
}
.completion-dialog footer {
justify-content: flex-end;
}
.completion-counts {
flex-wrap: wrap;
}
.completion-counts span {
padding: 0.45rem 0.65rem;
border-radius: 999px;
background: var(--panel-soft);
}
.check-row input {
width: auto;
}
.check-row span {
display: grid;
}
.share-management {
margin-bottom: 1.5rem;
padding: 1rem;
}
.share-create-form {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 11rem), 1fr));
gap: 0.8rem;
margin-top: 1rem;
}
.share-create-form label,
.share-edit-form label {
display: grid;
gap: 0.35rem;
color: var(--text-muted);
font-size: 0.72rem;
font-weight: 700;
}
.share-create-form button {
align-self: end;
}
.share-intro {
max-width: 58rem;
margin: 0;
color: var(--text-muted);
font-size: 0.76rem;
}
.share-show-list {
display: grid;
gap: 0.75rem;
margin-top: 1rem;
}
.share-show-card {
padding: 0.85rem;
border: 1px solid var(--line);
border-radius: var(--radius-medium);
background: var(--panel-soft);
}
.share-show-card > header,
.share-row summary {
display: grid;
align-items: center;
gap: 0.75rem;
}
.share-show-card > header {
grid-template-columns: minmax(0, 1fr) auto;
}
.share-show-card > header > div,
.share-row summary > span:nth-child(2) {
display: grid;
min-width: 0;
}
.share-show-card > header a {
color: var(--text);
font-weight: 760;
}
.share-show-card small,
.share-row small {
color: var(--text-muted);
font-size: 0.68rem;
}
.share-list {
display: grid;
gap: 0.4rem;
margin-top: 0.75rem;
}
.share-row {
border-top: 1px solid var(--line);
}
.share-row summary {
grid-template-columns: auto minmax(0, 1fr) auto auto;
min-height: 3.25rem;
cursor: pointer;
list-style: none;
}
.share-row summary::-webkit-details-marker {
display: none;
}
.share-row-icon {
display: grid;
width: 2rem;
height: 2rem;
place-items: center;
border-radius: 50%;
background: var(--accent-soft);
color: var(--accent-text);
}
.share-row summary > span:nth-child(3) {
padding: 0.2rem 0.45rem;
border-radius: 999px;
background: var(--accent-soft);
color: var(--accent-text);
font-size: 0.65rem;
font-weight: 700;
}
.share-row summary > span.locked {
background: color-mix(in srgb, var(--danger) 12%, var(--panel));
color: var(--danger);
}
.share-edit-area {
display: grid;
gap: 0.75rem;
padding: 0.75rem 0 0.25rem 2.75rem;
}
.share-edit-area > p {
margin: 0;
color: var(--text-muted);
font-size: 0.74rem;
}
.share-edit-form {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.65rem;
}
.share-edit-form input,
.share-edit-form select {
width: 100%;
}
.share-result {
display: grid;
gap: 0.25rem;
}
.share-result a {
overflow-wrap: anywhere;
}
.guest-shell {
min-height: 100dvh;
}
.share-page {
width: min(58rem, calc(100% - 2rem));
margin: 0 auto;
padding: clamp(2rem, 6vw, 5rem) 0;
}
.share-gate {
width: min(30rem, 100%);
margin: 8vh auto 0;
}
.share-heading {
padding-bottom: 1.5rem;
border-bottom: 1px solid var(--line);
}
.share-heading h1 {
margin: 0.25rem 0;
font-size: clamp(2rem, 6vw, 4rem);
}
.shared-rundown,
.historical-rundown {
display: grid;
gap: 1rem;
margin-top: 1.25rem;
}
.shared-section > header,
.historical-section > header,
.shared-usage-heading,
.historical-usage > header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.shared-section h2,
.shared-usage h3,
.historical-section h2,
.historical-usage h3 {
margin: 0;
}
.shared-usage,
.historical-usage {
margin-top: 0.75rem;
padding: 1rem;
border: 1px solid var(--line);
border-radius: var(--radius-medium);
background: var(--panel-soft);
}
.shared-usage img {
max-width: min(100%, 32rem);
max-height: 22rem;
margin-top: 0.8rem;
border-radius: var(--radius-medium);
object-fit: contain;
}
.shared-copy,
.historical-usage section,
.historical-usage details {
margin-top: 0.8rem;
}
.historical-show-page {
width: min(72rem, 100%);
}
.historical-usage p {
white-space: pre-wrap;
}
@media (max-width: 42rem) {
.share-create-form,
.share-edit-form {
grid-template-columns: 1fr;
}
.share-row summary {
grid-template-columns: auto minmax(0, 1fr) auto;
}
.share-row summary > .app-icon:last-child {
display: none;
}
.share-edit-area {
padding-left: 0;
}
.completion-dialog footer,
.shared-section > header,
.historical-section > header,
.shared-usage-heading,
.historical-usage > header {
align-items: stretch;
flex-direction: column;
}
}
:root[data-theme='dark'] { :root[data-theme='dark'] {
color-scheme: dark; color-scheme: dark;
--bg: #0b1220; --bg: #0b1220;
+29 -2
View File
@@ -1,3 +1,4 @@
import { randomBytes } from 'node:crypto';
import { redirect } from '@sveltejs/kit'; import { redirect } from '@sveltejs/kit';
import type { Handle } from '@sveltejs/kit/hooks'; import type { Handle } from '@sveltejs/kit/hooks';
import { deleteSession, getSessionUser, hasUsers, SESSION_COOKIE } from '#lib/server/auth.js'; import { deleteSession, getSessionUser, hasUsers, SESSION_COOKIE } from '#lib/server/auth.js';
@@ -14,16 +15,32 @@ const publicPaths = new Set([
export const handle: Handle = async ({ event, resolve }) => { export const handle: Handle = async ({ event, resolve }) => {
const presentationPath = event.url.pathname.startsWith('/heinzlive/'); const presentationPath = event.url.pathname.startsWith('/heinzlive/');
const sharePath = event.url.pathname.startsWith('/freigabe/');
const shareMediaPath = /^\/freigabe\/[^/]+\/media\/[^/]+$/.test(event.url.pathname);
const presentationTestPath = /^\/fundus\/[^/]+\/praesentation(?:\/|$)/.test(event.url.pathname); const presentationTestPath = /^\/fundus\/[^/]+\/praesentation(?:\/|$)/.test(event.url.pathname);
const token = event.cookies.get(SESSION_COOKIE); const token = event.cookies.get(SESSION_COOKIE);
event.locals.user = getSessionUser(token); event.locals.user = getSessionUser(token);
if (token && !event.locals.user) deleteSession(event.cookies); if (token && !event.locals.user) deleteSession(event.cookies);
if (!presentationPath && !publicPaths.has(event.url.pathname) && !event.locals.user) { if (
!presentationPath &&
!sharePath &&
!publicPaths.has(event.url.pathname) &&
!event.locals.user
) {
redirect(303, hasUsers() ? '/login' : '/setup'); redirect(303, hasUsers() ? '/login' : '/setup');
} }
const response = await resolve(event); const sharePageNonce = sharePath && !shareMediaPath ? randomBytes(16).toString('base64') : null;
const response = await resolve(
event,
sharePageNonce
? {
transformPageChunk: ({ html }) =>
html.replaceAll('<script', `<script nonce="${sharePageNonce}"`)
}
: undefined
);
response.headers.set('Referrer-Policy', 'same-origin'); response.headers.set('Referrer-Policy', 'same-origin');
response.headers.set('X-Content-Type-Options', 'nosniff'); response.headers.set('X-Content-Type-Options', 'nosniff');
response.headers.set('X-Frame-Options', 'DENY'); response.headers.set('X-Frame-Options', 'DENY');
@@ -35,6 +52,16 @@ export const handle: Handle = async ({ event, resolve }) => {
"frame-src https://www.youtube-nocookie.com https://www.youtube.com https://www.tiktok.com; frame-ancestors 'none'" "frame-src https://www.youtube-nocookie.com https://www.youtube.com https://www.tiktok.com; frame-ancestors 'none'"
); );
} }
if (sharePath) {
response.headers.set('Cache-Control', 'private, no-store');
response.headers.set('Referrer-Policy', 'same-origin');
if (!shareMediaPath) {
response.headers.set(
'Content-Security-Policy',
`default-src 'self'; script-src 'self' 'nonce-${sharePageNonce}'; style-src 'self' 'unsafe-inline'; img-src 'self'; frame-ancestors 'none'`
);
}
}
if (event.url.pathname === '/einladung' || event.url.pathname === '/passwort-zuruecksetzen') { if (event.url.pathname === '/einladung' || event.url.pathname === '/passwort-zuruecksetzen') {
response.headers.set('Cache-Control', 'no-store'); response.headers.set('Cache-Control', 'no-store');
} }
+249
View File
@@ -0,0 +1,249 @@
<script lang="ts">
import { resolve } from '$app/paths';
import AppIcon from './AppIcon.svelte';
type ShareShow = {
id: string;
title: string;
startsAtUtc: string | null;
status: string;
};
type ShareItem = {
id: string;
showId: string;
showTitle: string;
showStatus: string;
showStartsAtUtc: string | null;
scope: 'rundown' | 'moderation' | 'research';
isPasswordProtected: boolean;
expiresAtUtc: string | null;
revokedAtUtc: string | null;
createdAtUtc: string;
};
let {
shows,
shares,
fixedShowId = '',
form = null
}: {
shows: ShareShow[];
shares: ShareItem[];
fixedShowId?: string;
form?: { intent?: string; error?: string; success?: string; shareUrl?: string } | null;
} = $props();
let visibleShows = $derived(
fixedShowId
? shows.filter((show) => show.id === fixedShowId)
: shows.filter((show) => shares.some((share) => share.showId === show.id))
);
function germanDate(value: string | null, fallback = 'Termin offen') {
return value
? new Intl.DateTimeFormat('de-DE', {
timeZone: 'Europe/Berlin',
dateStyle: 'medium',
timeStyle: 'short'
}).format(new Date(value))
: fallback;
}
function berlinInput(value: string | null) {
if (!value) return '';
const parts = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Europe/Berlin',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23'
}).formatToParts(new Date(value));
const read = (type: Intl.DateTimeFormatPartTypes) =>
parts.find((part) => part.type === type)?.value ?? '';
return `${read('year')}-${read('month')}-${read('day')}T${read('hour')}:${read('minute')}`;
}
function statusLabel(status: string) {
if (status === 'draft') return 'Entwurf';
if (status === 'ready') return 'Bereit';
if (status === 'live') return 'Live';
return 'Abgeschlossen';
}
function scopeLabel(scope: ShareItem['scope']) {
if (scope === 'rundown') return 'Nur Ablauf';
if (scope === 'moderation') return 'Ablauf und Moderation';
return 'Vollständig mit Recherche';
}
function shareState(share: ShareItem) {
if (share.revokedAtUtc) return 'Widerrufen';
if (share.expiresAtUtc && new Date(share.expiresAtUtc) <= new Date()) return 'Abgelaufen';
return 'Aktiv';
}
</script>
<section
class="quiet-panel share-management"
aria-labelledby={`share-title-${fixedShowId || 'all'}`}
>
<div class="panel-heading">
<div>
<p class="eyebrow">Gastzugänge</p>
<h2 id={`share-title-${fixedShowId || 'all'}`}>Lesefreigaben</h2>
</div>
<span class="mail-mode">Aktualisiert sich bis zum Abschluss</span>
</div>
<p class="share-intro">
Der Gast sieht nur diese Sendung und den gewählten Informationsumfang. Der vollständige Link
wird aus Sicherheitsgründen nur direkt nach dem Anlegen angezeigt.
</p>
{#if form?.intent?.toLowerCase().includes('share') && form.error}<div
class="form-message error"
role="alert"
>
{form.error}
</div>{/if}
{#if form?.intent?.toLowerCase().includes('share') && form.success}<div
class="form-message success"
role="status"
>
{form.success}
</div>{/if}
{#if form?.shareUrl}<div class="form-message success share-result" role="status">
<strong>Neuer Leselink</strong>
<!-- The server returns only a same-origin share path created by this action. -->
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<a href={form.shareUrl}>{form.shareUrl}</a>
</div>{/if}
<form class="share-create-form" method="POST" action="?/createShare">
{#if fixedShowId}
<input type="hidden" name="showId" value={fixedShowId} />
{:else}
<label
>Sendung<select name="showId" required>
<option value="">Sendung wählen</option>
{#each shows as show (show.id)}<option value={show.id}
>{show.title} · {statusLabel(show.status)}</option
>{/each}
</select></label
>
{/if}
<label
>Informationsumfang<select name="scope">
<option value="rundown">Nur Ablauf</option>
<option value="moderation">Ablauf und Moderation</option>
<option value="research">Vollständig mit Recherche</option>
</select></label
>
<label
>Optionales Passwort<input
name="password"
type="password"
minlength="12"
maxlength="256"
autocomplete="new-password"
/></label
>
<label>Optionaler Ablauf<input name="expiresAt" type="datetime-local" /></label>
<button class="primary-button" type="submit" disabled={!shows.length}>Leselink anlegen</button>
</form>
{#if visibleShows.length}
<div class="share-show-list">
{#each visibleShows as show (show.id)}
<article class="share-show-card">
<header>
<div>
<a href={resolve('/sendungen/[id]', { id: show.id })}>{show.title}</a>
<small>{germanDate(show.startsAtUtc)} Uhr</small>
</div>
<span class={`show-state ${show.status}`}>{statusLabel(show.status)}</span>
</header>
<div class="share-list">
{#each shares.filter((share) => share.showId === show.id) as share (share.id)}
<details class="share-row">
<summary>
<span class="share-row-icon"><AppIcon name="link" /></span>
<span>
<strong>{scopeLabel(share.scope)}</strong>
<small
>{share.isPasswordProtected ? 'Mit Passwort' : 'Ohne Passwort'} · {germanDate(
share.expiresAtUtc,
'ohne Ablauf'
)}</small
>
</span>
<span class:locked={shareState(share) !== 'Aktiv'}>{shareState(share)}</span>
<AppIcon name="pen" />
</summary>
<div class="share-edit-area">
{#if share.revokedAtUtc}
<p>Diese Freigabe ist widerrufen und kann nur noch gelöscht werden.</p>
{:else}
<form class="share-edit-form" method="POST" action="?/updateShare">
<input type="hidden" name="shareId" value={share.id} />
<label
>Informationsumfang<select name="scope" value={share.scope}>
<option value="rundown">Nur Ablauf</option>
<option value="moderation">Ablauf und Moderation</option>
<option value="research">Vollständig mit Recherche</option>
</select></label
>
<label
>Passwort<select name="passwordMode">
<option value="keep">Unverändert</option>
<option value="remove">Kein Passwort</option>
<option value="replace">Neues Passwort setzen</option>
</select></label
>
<label
>Neues Passwort<input
name="password"
type="password"
minlength="12"
maxlength="256"
autocomplete="new-password"
/></label
>
<label
>Ablauf<input
name="expiresAt"
type="datetime-local"
value={berlinInput(share.expiresAtUtc)}
/><small>Leer lassen, um den Ablauf zu entfernen.</small></label
>
<button class="secondary-button" type="submit">Freigabe speichern</button>
</form>
{/if}
<form
method="POST"
action="?/deleteShare"
onsubmit={(event) => {
if (
!confirm('Freigabe endgültig löschen? Der Leselink wird sofort ungültig.')
)
event.preventDefault();
}}
>
<input type="hidden" name="shareId" value={share.id} />
<button class="text-button danger-text" type="submit">Freigabe löschen</button>
</form>
</div>
</details>
{/each}
{#if !shares.some((share) => share.showId === show.id)}
<p class="quiet-copy">Für diese Sendung gibt es noch keine Lesefreigabe.</p>
{/if}
</div>
</article>
{/each}
</div>
{:else}
<p class="quiet-copy">Noch keine Lesefreigaben angelegt.</p>
{/if}
</section>
+2 -2
View File
@@ -110,11 +110,11 @@ export async function verifyPassword(passwordHash: string, password: string): Pr
const dummyPasswordHash = hashPassword(randomBytes(32).toString('hex')); const dummyPasswordHash = hashPassword(randomBytes(32).toString('hex'));
function createToken(): string { export function createToken(): string {
return randomBytes(32).toString('base64url'); return randomBytes(32).toString('base64url');
} }
function hashToken(token: string): string { export function hashToken(token: string): string {
return createHash('sha256').update(token).digest('hex'); return createHash('sha256').update(token).digest('hex');
} }
+204
View File
@@ -0,0 +1,204 @@
import { execFileSync } from 'node:child_process';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import type { AuthUser } from './auth.js';
import { finalizeShow, getCompletionSummary } from './completion.js';
import { closeDatabase, getDatabase } from './database.js';
import {
addPieceUsage,
addShowSection,
createShow,
duplicateCompletedShow,
getPieceUsageContexts,
getShow
} from './shows.js';
let directory: string;
const actor: AuthUser = {
id: 'completion-actor',
email: 'completion@example.test',
displayName: 'Abschluss Test',
role: 'barkeeper',
lockedAtUtc: null
};
beforeEach(() => {
directory = mkdtempSync(path.join(tmpdir(), 'sendewerk-completion-test-'));
process.env.SENDEWERK_DB_PATH = path.join(directory, 'test.sqlite');
process.env.SENDEWERK_UPLOAD_DIR = path.join(directory, 'uploads');
execFileSync(process.execPath, ['scripts/migrate.mjs'], { cwd: process.cwd(), env: process.env });
execFileSync(process.execPath, ['scripts/seed.mjs'], { cwd: process.cwd(), env: process.env });
getDatabase()
.prepare(
`INSERT INTO users (id, email, email_normalized, display_name, password_hash, role,
password_changed_at_utc, created_at_utc, updated_at_utc)
VALUES (?, ?, ?, ?, 'unused', 'barkeeper', ?, ?, ?)`
)
.run(
actor.id,
actor.email,
actor.email,
actor.displayName,
...Array(3).fill(new Date().toISOString())
);
});
afterEach(() => {
closeDatabase();
delete process.env.SENDEWERK_DB_PATH;
delete process.env.SENDEWERK_UPLOAD_DIR;
rmSync(directory, { recursive: true, force: true });
});
function endedShow() {
const created = createShow({ title: 'Finale Sendung', startsAtUtc: null, templateId: null });
const section = addShowSection(created.id, created.revision, {
name: 'Gesendeter Block',
categoryId: 'cat-jc',
explicitDurationSeconds: null
});
const done = addPieceUsage(created.id, section.revision, {
pieceId: 'piece-console',
sectionId: section.id
});
const skipped = addPieceUsage(created.id, done.revision, {
pieceId: 'piece-console',
sectionId: section.id
});
const database = getDatabase();
database.prepare("UPDATE piece_usages SET status = 'done' WHERE id = ?").run(done.id);
database.prepare("UPDATE piece_usages SET status = 'skipped' WHERE id = ?").run(skipped.id);
database
.prepare(
`INSERT INTO show_live_states
(show_id, revision, started_at_utc, ended_at_utc, presentation_revision, updated_at_utc)
VALUES (?, 0, ?, ?, 0, ?)`
)
.run(
created.id,
'2026-10-25T18:00:00.000Z',
'2026-10-25T19:00:00.000Z',
'2026-10-25T19:00:00.000Z'
);
return { showId: created.id, doneUsageId: done.id, revision: skipped.revision };
}
describe('show completion', () => {
it('finalizes once, records only sent usages and archives only the explicit selection', () => {
const prepared = endedShow();
expect(getCompletionSummary(prepared.showId)).toMatchObject({
doneCount: 1,
skippedCount: 1,
candidates: [{ pieceId: 'piece-console', usageCount: 1 }]
});
const first = finalizeShow({
showId: prepared.showId,
commandId: 'complete-once',
expectedShowRevision: prepared.revision,
expectedLiveRevision: 0,
archivePieceIds: ['piece-console'],
actor,
now: new Date('2026-10-25T19:05:00.000Z')
});
expect(first.duplicate).toBe(false);
expect(
finalizeShow({
showId: prepared.showId,
commandId: 'complete-once',
expectedShowRevision: prepared.revision,
expectedLiveRevision: 0,
archivePieceIds: ['piece-console'],
actor
}).duplicate
).toBe(true);
expect(() =>
finalizeShow({
showId: prepared.showId,
commandId: 'different-command',
expectedShowRevision: prepared.revision,
expectedLiveRevision: 0,
archivePieceIds: [],
actor
})
).toThrow('SHOW_COMPLETED');
const database = getDatabase();
expect(
(
database
.prepare('SELECT count(*) AS count FROM piece_usage_history WHERE show_id = ?')
.get(prepared.showId) as { count: number }
).count
).toBe(1);
expect(database.prepare('SELECT status FROM pieces WHERE id = ?').get('piece-console')).toEqual(
{ status: 'archived' }
);
expect(getPieceUsageContexts(['piece-console'])['piece-console'].sentHistory).toHaveLength(1);
});
it('keeps historical snapshots stable and duplicates them into an editable clean run', () => {
const prepared = endedShow();
finalizeShow({
showId: prepared.showId,
commandId: 'freeze',
expectedShowRevision: prepared.revision,
expectedLiveRevision: 0,
archivePieceIds: [],
actor
});
const historical = getShow(prepared.showId)!;
getDatabase()
.prepare("UPDATE section_categories SET name = 'Später geändert' WHERE id = 'cat-jc'")
.run();
getDatabase()
.prepare("UPDATE pieces SET title = 'Später geändert' WHERE id = 'piece-console'")
.run();
const unchanged = getShow(prepared.showId)!;
expect(unchanged.sections[0].category).toBe(historical.sections[0].category);
expect(unchanged.sections[0].usages[0].title).toBe(historical.sections[0].usages[0].title);
const copy = duplicateCompletedShow(prepared.showId, 'copy-once');
expect(duplicateCompletedShow(prepared.showId, 'copy-once').id).toBe(copy.id);
expect(getShow(copy.id)).toMatchObject({
status: 'draft',
startsAtUtc: null,
sections: [
{
category: historical.sections[0].category,
usages: [{ status: 'open' }, { status: 'open' }]
}
]
});
expect(
getDatabase().prepare('SELECT 1 FROM show_finalizations WHERE show_id = ?').get(copy.id)
).toBeUndefined();
getDatabase()
.prepare(
`UPDATE piece_usages SET status = 'done'
WHERE show_section_id IN (SELECT id FROM show_sections WHERE show_id = ?)`
)
.run(copy.id);
getDatabase()
.prepare(
`INSERT INTO show_live_states
(show_id, revision, started_at_utc, ended_at_utc, presentation_revision, updated_at_utc)
VALUES (?, 0, ?, ?, 0, ?)`
)
.run(
copy.id,
'2026-10-26T18:00:00.000Z',
'2026-10-26T19:00:00.000Z',
'2026-10-26T19:00:00.000Z'
);
finalizeShow({
showId: copy.id,
commandId: 'finalize-copy',
expectedShowRevision: 1,
expectedLiveRevision: 0,
archivePieceIds: [],
actor
});
expect(getShow(copy.id)?.sections[0].category).toBe(historical.sections[0].category);
});
});
+210
View File
@@ -0,0 +1,210 @@
import type { AuthUser } from './auth.js';
import { getDatabase } from './database.js';
export type CompletionCandidate = {
pieceId: string;
title: string;
usageCount: number;
};
export type CompletionSummary = {
doneCount: number;
skippedCount: number;
openCount: number;
candidates: CompletionCandidate[];
};
export function isShowFinalized(showId: string): boolean {
return Boolean(
getDatabase().prepare('SELECT 1 FROM show_finalizations WHERE show_id = ?').get(showId)
);
}
export function getCompletionSummary(showId: string): CompletionSummary {
const database = getDatabase();
if (!database.prepare('SELECT 1 FROM shows WHERE id = ?').get(showId))
throw new Error('NOT_FOUND');
const counts = database
.prepare(
`SELECT
sum(CASE WHEN u.status = 'done' THEN 1 ELSE 0 END) AS done_count,
sum(CASE WHEN u.status = 'skipped' THEN 1 ELSE 0 END) AS skipped_count,
sum(CASE WHEN u.status IN ('open', 'current') THEN 1 ELSE 0 END) AS open_count
FROM piece_usages u JOIN show_sections s ON s.id = u.show_section_id
WHERE s.show_id = ? AND s.is_reserve_bank = 0`
)
.get(showId) as {
done_count: number | null;
skipped_count: number | null;
open_count: number | null;
};
const candidates = database
.prepare(
`SELECT u.original_piece_id AS piece_id, p.title, count(*) AS usage_count
FROM piece_usages u
JOIN show_sections s ON s.id = u.show_section_id
JOIN pieces p ON p.id = u.original_piece_id
WHERE s.show_id = ? AND s.is_reserve_bank = 0 AND u.status = 'done'
AND p.status != 'archived' AND p.deleted_at_utc IS NULL
GROUP BY u.original_piece_id, p.title ORDER BY p.title COLLATE NOCASE`
)
.all(showId) as { piece_id: string; title: string; usage_count: number }[];
return {
doneCount: counts.done_count ?? 0,
skippedCount: counts.skipped_count ?? 0,
openCount: counts.open_count ?? 0,
candidates: candidates.map((candidate) => ({
pieceId: candidate.piece_id,
title: candidate.title,
usageCount: candidate.usage_count
}))
};
}
export function getHistoricalActualDurations(showId: string): Record<string, number> {
return Object.fromEntries(
(
getDatabase()
.prepare(
`SELECT usage_id, actual_duration_seconds FROM piece_usage_history WHERE show_id = ?`
)
.all(showId) as { usage_id: string; actual_duration_seconds: number }[]
).map((row) => [row.usage_id, row.actual_duration_seconds])
);
}
export function finalizeShow(input: {
showId: string;
commandId: string;
expectedShowRevision: number;
expectedLiveRevision: number;
archivePieceIds: string[];
actor: AuthUser;
now?: Date;
}) {
if (!input.commandId || input.commandId.length > 200) throw new Error('INVALID_COMMAND');
if (!Number.isInteger(input.expectedShowRevision) || input.expectedShowRevision < 1) {
throw new Error('INVALID_REVISION');
}
if (!Number.isInteger(input.expectedLiveRevision) || input.expectedLiveRevision < 0) {
throw new Error('INVALID_REVISION');
}
const archivePieceIds = [...new Set(input.archivePieceIds.filter(Boolean))];
const database = getDatabase();
return database.transaction(() => {
const existing = database
.prepare('SELECT command_id FROM show_finalizations WHERE show_id = ?')
.get(input.showId) as { command_id: string } | undefined;
if (existing) {
if (existing.command_id !== input.commandId) throw new Error('SHOW_COMPLETED');
return { duplicate: true };
}
const state = database
.prepare(
`SELECT sh.revision AS show_revision, ls.revision AS live_revision, ls.ended_at_utc
FROM shows sh JOIN show_live_states ls ON ls.show_id = sh.id WHERE sh.id = ?`
)
.get(input.showId) as
{ show_revision: number; live_revision: number; ended_at_utc: string | null } | undefined;
if (!state) throw new Error('NOT_FOUND');
if (!state.ended_at_utc) throw new Error('NOT_ENDED');
if (
state.show_revision !== input.expectedShowRevision ||
state.live_revision !== input.expectedLiveRevision
) {
throw new Error('CONFLICT');
}
const candidates = getCompletionSummary(input.showId).candidates.map(
(candidate) => candidate.pieceId
);
if (archivePieceIds.some((pieceId) => !candidates.includes(pieceId))) {
throw new Error('INVALID_ARCHIVE_SELECTION');
}
const now = (input.now ?? new Date()).toISOString();
database
.prepare(
`UPDATE live_usage_runs SET ended_at_utc = ? WHERE show_id = ? AND ended_at_utc IS NULL`
)
.run(now, input.showId);
database
.prepare(
`UPDATE live_show_timer_runs SET ended_at_utc = ? WHERE show_id = ? AND ended_at_utc IS NULL`
)
.run(now, input.showId);
database
.prepare(
`UPDATE live_section_timer_runs SET ended_at_utc = ?
WHERE ended_at_utc IS NULL AND timer_id IN (
SELECT id FROM live_section_timers WHERE show_id = ?
)`
)
.run(now, input.showId);
database
.prepare(
`UPDATE live_section_timers SET ended_at_utc = ? WHERE show_id = ? AND ended_at_utc IS NULL`
)
.run(now, input.showId);
database
.prepare(
`UPDATE show_sections SET
category_name_snapshot = COALESCE(category_name_snapshot,
(SELECT name FROM section_categories WHERE id = show_sections.category_id)),
category_color_snapshot = COALESCE(category_color_snapshot,
(SELECT color FROM section_categories WHERE id = show_sections.category_id))
WHERE show_id = ?`
)
.run(input.showId);
database
.prepare(
`INSERT INTO piece_usage_history (
usage_id, show_id, original_piece_id, show_title_snapshot, usage_title_snapshot,
used_at_utc, actual_duration_seconds
)
SELECT u.id, sh.id, u.original_piece_id, sh.title, u.title_snapshot,
COALESCE(sh.starts_at_utc, ?),
COALESCE((
SELECT sum(max(0, CAST((julianday(COALESCE(r.ended_at_utc, ?)) -
julianday(r.started_at_utc)) * 86400 AS INTEGER)))
FROM live_usage_runs r WHERE r.usage_id = u.id
), 0)
FROM piece_usages u
JOIN show_sections s ON s.id = u.show_section_id
JOIN shows sh ON sh.id = s.show_id
WHERE sh.id = ? AND s.is_reserve_bank = 0 AND u.status = 'done'
AND u.original_piece_id IS NOT NULL`
)
.run(now, now, input.showId);
const archivePiece = database.prepare(
`UPDATE pieces SET archived_from_status = status, status = 'archived', revision = revision + 1,
updated_at_utc = ? WHERE id = ? AND status != 'archived' AND deleted_at_utc IS NULL`
);
for (const pieceId of archivePieceIds) {
archivePiece.run(now, pieceId);
}
const updated = database
.prepare(
`UPDATE shows SET status = 'completed', completed_at_utc = ?, revision = revision + 1,
updated_at_utc = ? WHERE id = ? AND revision = ? AND status != 'completed'`
)
.run(now, now, input.showId, input.expectedShowRevision);
if (updated.changes !== 1) throw new Error('CONFLICT');
database
.prepare(
`INSERT INTO show_finalizations
(show_id, command_id, source_revision, finalized_by_user_id, finalized_at_utc)
VALUES (?, ?, ?, ?, ?)`
)
.run(input.showId, input.commandId, input.expectedShowRevision, input.actor.id, now);
const recordArchivedPiece = database.prepare(
'INSERT INTO show_finalization_archived_pieces (show_id, piece_id) VALUES (?, ?)'
);
for (const pieceId of archivePieceIds) {
recordArchivedPiece.run(input.showId, pieceId);
}
return { duplicate: false };
})();
}
+1 -1
View File
@@ -926,7 +926,7 @@ export async function duplicatePiece(sourceId: string): Promise<string> {
presentation_metadata_fetched_at_utc, created_at_utc, updated_at_utc, revision) presentation_metadata_fetched_at_utc, created_at_utc, updated_at_utc, revision)
VALUES ( VALUES (
?, ?, ?, ?, ?, 'preparing', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'preparing', ?, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1 ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1
)` )`
) )
.run( .run(
+95 -2
View File
@@ -1,6 +1,6 @@
import Database from 'better-sqlite3'; import Database from 'better-sqlite3';
import { execFileSync } from 'node:child_process'; import { execFileSync } from 'node:child_process';
import { mkdtempSync, rmSync } from 'node:fs'; import { mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
@@ -62,7 +62,7 @@ describe('database setup', () => {
database.close(); database.close();
expect(counts).toEqual({ expect(counts).toEqual({
migrations: 12, migrations: 16,
pieces: 6, pieces: 6,
shows: 2, shows: 2,
usages: 5, usages: 5,
@@ -90,4 +90,97 @@ describe('database setup', () => {
expect(show.totalDurationSeconds).toBe(5100); expect(show.totalDurationSeconds).toBe(5100);
expect(show.sections[1].usages.find((usage) => usage.isReserve)?.durationSeconds).toBe(540); expect(show.sections[1].usages.find((usage) => usage.isReserve)?.durationSeconds).toBe(540);
}); });
it('upgrades existing completed shows into stable finalizations exactly once', () => {
const directory = mkdtempSync(path.join(tmpdir(), 'sendewerk-upgrade-test-'));
temporaryDirectories.push(directory);
const databasePath = path.join(directory, 'test.sqlite');
const environment = {
...process.env,
SENDEWERK_DB_PATH: databasePath,
SENDEWERK_UPLOAD_DIR: path.join(directory, 'uploads')
};
const database = new Database(databasePath);
database.pragma('foreign_keys = ON');
database.exec(
'CREATE TABLE schema_migrations (name TEXT PRIMARY KEY, applied_at_utc TEXT NOT NULL)'
);
const migrationDirectory = path.resolve('migrations');
const oldMigrations = readdirSync(migrationDirectory)
.filter(
(name) =>
/^\d+.+\.sql$/.test(name) && !name.startsWith('0015_') && !name.startsWith('0016_')
)
.sort();
for (const name of oldMigrations) {
database.exec(readFileSync(path.join(migrationDirectory, name), 'utf8'));
database
.prepare('INSERT INTO schema_migrations (name, applied_at_utc) VALUES (?, ?)')
.run(name, new Date().toISOString());
}
database.close();
execFileSync(process.execPath, ['scripts/seed.mjs'], { cwd: process.cwd(), env: environment });
const legacy = new Database(databasePath);
legacy.pragma('foreign_keys = ON');
const usage = legacy
.prepare(
`SELECT u.id FROM piece_usages u JOIN show_sections s ON s.id = u.show_section_id
WHERE s.show_id = 'show-october' AND s.is_reserve_bank = 0 LIMIT 1`
)
.get() as { id: string };
legacy.prepare("UPDATE piece_usages SET status = 'done' WHERE id = ?").run(usage.id);
legacy.prepare("UPDATE shows SET status = 'completed' WHERE id = 'show-october'").run();
const completionMigration = '0015_completion_archive_and_shares.sql';
legacy.exec(readFileSync(path.join(migrationDirectory, completionMigration), 'utf8'));
legacy
.prepare('INSERT INTO schema_migrations (name, applied_at_utc) VALUES (?, ?)')
.run(completionMigration, new Date().toISOString());
const now = new Date().toISOString();
legacy
.prepare(
`INSERT INTO users (id, email, email_normalized, display_name, password_hash, role,
password_changed_at_utc, created_at_utc, updated_at_utc)
VALUES ('upgrade-barkeeper', 'upgrade@example.test', 'upgrade@example.test',
'Upgrade', 'unused', 'barkeeper', ?, ?, ?)`
)
.run(now, now, now);
legacy
.prepare(
`INSERT INTO show_shares
(id, show_id, token_hash, scope, password_hash, expires_at_utc, revoked_at_utc,
created_by_user_id, created_at_utc)
VALUES ('upgrade-share', 'show-october', 'upgrade-token-hash', 'rundown', NULL,
NULL, NULL, 'upgrade-barkeeper', ?)`
)
.run(now);
legacy
.prepare(
`INSERT INTO show_share_sessions (token_hash, share_id, created_at_utc, expires_at_utc)
VALUES ('upgrade-session-hash', 'upgrade-share', ?, ?)`
)
.run(now, new Date(Date.now() + 60_000).toISOString());
legacy.close();
for (let run = 0; run < 2; run += 1) {
execFileSync(process.execPath, ['scripts/migrate.mjs'], {
cwd: process.cwd(),
env: environment
});
}
const upgraded = new Database(databasePath, { readonly: true });
expect(
upgraded
.prepare(
`SELECT
(SELECT count(*) FROM show_finalizations WHERE show_id = 'show-october') AS finalizations,
(SELECT count(*) FROM piece_usage_history WHERE show_id = 'show-october') AS history,
(SELECT count(*) FROM show_shares WHERE id = 'upgrade-share') AS shares,
(SELECT count(*) FROM show_share_sessions WHERE token_hash = 'upgrade-session-hash') AS sessions`
)
.get()
).toEqual({ finalizations: 1, history: 1, shares: 1, sessions: 1 });
expect(upgraded.pragma('foreign_key_check')).toEqual([]);
upgraded.close();
});
}); });
+18 -16
View File
@@ -197,10 +197,7 @@ function showTimerRunning(database: Database.Database, showId: string) {
); );
} }
function elapsedRuns( function elapsedRuns(runs: { started_at_utc: string; ended_at_utc: string | null }[], now: string) {
runs: { started_at_utc: string; ended_at_utc: string | null }[],
now: string
) {
return runs.reduce( return runs.reduce(
(total, run) => (total, run) =>
total + total +
@@ -236,9 +233,7 @@ function resumeSectionTimerRun(database: Database.Database, showId: string, now:
.get(showId) as { id: string } | undefined; .get(showId) as { id: string } | undefined;
if (!timer) return; if (!timer) return;
const running = database const running = database
.prepare( .prepare('SELECT 1 FROM live_section_timer_runs WHERE timer_id = ? AND ended_at_utc IS NULL')
'SELECT 1 FROM live_section_timer_runs WHERE timer_id = ? AND ended_at_utc IS NULL'
)
.get(timer.id); .get(timer.id);
if (!running) { if (!running) {
database database
@@ -335,6 +330,9 @@ function executeCommand(
const show = database.prepare('SELECT status FROM shows WHERE id = ?').get(showId) as { const show = database.prepare('SELECT status FROM shows WHERE id = ?').get(showId) as {
status: string; status: string;
}; };
if (database.prepare('SELECT 1 FROM show_finalizations WHERE show_id = ?').get(showId)) {
throw new Error('SHOW_COMPLETED');
}
if (show.status === 'completed' && !allowCompleted) throw new Error('SHOW_COMPLETED'); if (show.status === 'completed' && !allowCompleted) throw new Error('SHOW_COMPLETED');
if (state.revision !== expectedRevision) throw new Error('CONFLICT'); if (state.revision !== expectedRevision) throw new Error('CONFLICT');
operation(database, state, now); operation(database, state, now);
@@ -643,9 +641,7 @@ export function resetSectionTimer(
`SELECT section_id, planned_duration_seconds `SELECT section_id, planned_duration_seconds
FROM live_section_timers WHERE show_id = ? AND ended_at_utc IS NULL` FROM live_section_timers WHERE show_id = ? AND ended_at_utc IS NULL`
) )
.get(showId) as .get(showId) as { section_id: string; planned_duration_seconds: number } | undefined;
| { section_id: string; planned_duration_seconds: number }
| undefined;
if (!timer) throw new Error('NO_SECTION_TIMER'); if (!timer) throw new Error('NO_SECTION_TIMER');
const running = showTimerRunning(database, showId); const running = showTimerRunning(database, showId);
closeSectionTimer(database, showId, now); closeSectionTimer(database, showId, now);
@@ -741,12 +737,10 @@ export function endLive(
) )
.run(now, showId); .run(now, showId);
database database
.prepare( .prepare(`UPDATE show_live_states SET ended_at_utc = ? WHERE show_id = ?`)
`UPDATE show_live_states SET ended_at_utc = ? WHERE show_id = ?`
)
.run(now, showId); .run(now, showId);
database database
.prepare(`UPDATE shows SET status = 'completed', revision = revision + 1, updated_at_utc = ? WHERE id = ?`) .prepare(`UPDATE shows SET revision = revision + 1, updated_at_utc = ? WHERE id = ?`)
.run(now, showId); .run(now, showId);
}, },
nowValue nowValue
@@ -766,7 +760,11 @@ export function resumeEndedLive(
'resume-ended', 'resume-ended',
(database, state, now) => { (database, state, now) => {
if (!state.ended_at_utc) throw new Error('NOT_ENDED'); if (!state.ended_at_utc) throw new Error('NOT_ENDED');
database.prepare(`UPDATE shows SET status = 'live', revision = revision + 1, updated_at_utc = ? WHERE id = ?`).run(now, showId); database
.prepare(
`UPDATE shows SET status = 'live', revision = revision + 1, updated_at_utc = ? WHERE id = ?`
)
.run(now, showId);
database database
.prepare('UPDATE show_live_states SET ended_at_utc = NULL WHERE show_id = ?') .prepare('UPDATE show_live_states SET ended_at_utc = NULL WHERE show_id = ?')
.run(showId); .run(showId);
@@ -815,7 +813,11 @@ export function startNewLiveRun(
WHERE show_id = ?` WHERE show_id = ?`
) )
.run(now, showId); .run(now, showId);
database.prepare(`UPDATE shows SET status = 'live', revision = revision + 1, updated_at_utc = ? WHERE id = ?`).run(now, showId); database
.prepare(
`UPDATE shows SET status = 'live', revision = revision + 1, updated_at_utc = ? WHERE id = ?`
)
.run(now, showId);
openShowTimerRun(database, showId, now); openShowTimerRun(database, showId, now);
const target = orderedUsages(database, showId).find((usage) => usage.status === 'open'); const target = orderedUsages(database, showId).find((usage) => usage.status === 'open');
if (target) activateUsage(database, showId, target.id, now); if (target) activateUsage(database, showId, target.id, now);
+266
View File
@@ -0,0 +1,266 @@
import { execFileSync } from 'node:child_process';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import type { AuthUser } from './auth.js';
import { finalizeShow } from './completion.js';
import { closeDatabase, getDatabase } from './database.js';
import {
authenticateShare,
createShare,
deleteShare,
getSharedMedia,
getSharedShow,
revokeShare,
updateShare
} from './shares.js';
import { addPieceUsage, addShowSection, createShow } from './shows.js';
let directory: string;
const actor: AuthUser = {
id: 'share-actor',
email: 'share@example.test',
displayName: 'Freigabe Test',
role: 'barkeeper',
lockedAtUtc: null
};
beforeEach(() => {
directory = mkdtempSync(path.join(tmpdir(), 'sendewerk-shares-test-'));
process.env.SENDEWERK_DB_PATH = path.join(directory, 'test.sqlite');
process.env.SENDEWERK_UPLOAD_DIR = path.join(directory, 'uploads');
execFileSync(process.execPath, ['scripts/migrate.mjs'], { cwd: process.cwd(), env: process.env });
execFileSync(process.execPath, ['scripts/seed.mjs'], { cwd: process.cwd(), env: process.env });
const now = new Date().toISOString();
getDatabase()
.prepare(
`INSERT INTO users (id, email, email_normalized, display_name, password_hash, role,
password_changed_at_utc, created_at_utc, updated_at_utc)
VALUES (?, ?, ?, ?, 'unused', 'barkeeper', ?, ?, ?)`
)
.run(actor.id, actor.email, actor.email, actor.displayName, now, now, now);
});
afterEach(() => {
closeDatabase();
delete process.env.SENDEWERK_DB_PATH;
delete process.env.SENDEWERK_UPLOAD_DIR;
rmSync(directory, { recursive: true, force: true });
});
function finalizedShow() {
const created = createShow({
title: 'Freigegebene Sendung',
startsAtUtc: null,
templateId: null
});
const section = addShowSection(created.id, created.revision, {
name: 'Freigabeblock',
categoryId: null,
explicitDurationSeconds: null
});
const usage = addPieceUsage(created.id, section.revision, {
pieceId: 'piece-console',
sectionId: section.id
});
const reserveUsage = addPieceUsage(created.id, usage.revision, {
pieceId: 'piece-console',
sectionId: null
});
const database = getDatabase();
database.prepare("UPDATE piece_usages SET status = 'done' WHERE id = ?").run(usage.id);
database
.prepare(
`INSERT INTO show_live_states
(show_id, revision, started_at_utc, ended_at_utc, presentation_revision, updated_at_utc)
VALUES (?, 0, ?, ?, 0, ?)`
)
.run(
created.id,
'2026-10-25T18:00:00.000Z',
'2026-10-25T19:00:00.000Z',
'2026-10-25T19:00:00.000Z'
);
finalizeShow({
showId: created.id,
commandId: `finalize-${created.id}`,
expectedShowRevision: reserveUsage.revision,
expectedLiveRevision: 0,
archivePieceIds: [],
actor
});
return { showId: created.id, usageId: usage.id, reserveUsageId: reserveUsage.id };
}
describe('show shares', () => {
it('shares an unfinished show and keeps its current plan visible', async () => {
const created = createShow({
title: 'Offene Freigabe',
startsAtUtc: null,
templateId: null
});
const shared = await createShare(actor, {
showId: created.id,
scope: 'rundown',
password: '',
expiresAtUtc: null
});
const initiallyEmpty = getSharedShow(shared.token);
expect(initiallyEmpty.status).toBe('authorized');
if (initiallyEmpty.status !== 'authorized') throw new Error('share unavailable');
expect(initiallyEmpty.share.isCompleted).toBe(false);
expect(initiallyEmpty.share.sections).toEqual([]);
const section = addShowSection(created.id, created.revision, {
name: 'Nachträglich geplant',
categoryId: null,
explicitDurationSeconds: null
});
addPieceUsage(created.id, section.revision, {
pieceId: 'piece-console',
sectionId: section.id
});
const updatedPlan = getSharedShow(shared.token);
expect(updatedPlan.status).toBe('authorized');
if (updatedPlan.status !== 'authorized') throw new Error('share unavailable');
expect(updatedPlan.share.sections[0]).toMatchObject({
name: 'Nachträglich geplant',
usages: [{ title: 'Die kleine Konsole mit dem großen Dreh' }]
});
await updateShare(actor, shared.id, {
scope: 'moderation',
passwordMode: 'replace',
password: 'neues-freigabe-passwort',
expiresAtUtc: null
});
expect(getSharedShow(shared.token).status).toBe('password');
const session = await authenticateShare(shared.token, 'neues-freigabe-passwort', '192.0.2.20');
expect(getSharedShow(shared.token, session.token).status).toBe('authorized');
deleteShare(actor, shared.id, created.id);
expect(getSharedShow(shared.token, session.token).status).toBe('unavailable');
});
it('projects exactly the selected information scope', async () => {
const prepared = finalizedShow();
const rundown = await createShare(actor, {
showId: prepared.showId,
scope: 'rundown',
password: '',
expiresAtUtc: null
});
const basic = getSharedShow(rundown.token);
expect(basic.status).toBe('authorized');
if (basic.status !== 'authorized') throw new Error('share unavailable');
const basicUsage = basic.share.sections[0].usages[0];
expect(basicUsage).not.toHaveProperty('moderationHtml');
expect(basicUsage).not.toHaveProperty('researchHtml');
expect(JSON.stringify(basic)).not.toContain('originalPieceId');
expect(JSON.stringify(basic)).not.toContain(prepared.showId);
getDatabase()
.prepare(
`INSERT INTO usage_tool_snapshots
(id, usage_id, original_tool_id, name, start_url, work_url, note, position)
VALUES ('private-share-tool', ?, NULL, 'Schnittwerkzeug',
'https://private.example/start', 'https://private.example/work?secret=1',
'Freigegebene Werkzeugnotiz', 0)`
)
.run(prepared.usageId);
const research = await createShare(actor, {
showId: prepared.showId,
scope: 'research',
password: '',
expiresAtUtc: null
});
const full = getSharedShow(research.token);
expect(full.status).toBe('authorized');
if (full.status !== 'authorized') throw new Error('share unavailable');
expect(full.share.sections[0].usages[0]).toHaveProperty('moderationHtml');
expect(full.share.sections[0].usages[0]).toHaveProperty('researchHtml');
expect(full.share.sections[0].usages[0]).not.toHaveProperty('ownNotesHtml');
expect(JSON.stringify(full)).not.toContain('private.example');
expect(JSON.stringify(full)).toContain('Freigegebene Werkzeugnotiz');
});
it('enforces password, expiry and revocation for page and direct media access', async () => {
const prepared = finalizedShow();
const database = getDatabase();
mkdirSync(process.env.SENDEWERK_UPLOAD_DIR!, { recursive: true });
writeFileSync(path.join(process.env.SENDEWERK_UPLOAD_DIR!, 'shared.png'), Buffer.from('image'));
database
.prepare(
`INSERT INTO piece_media
(id, piece_id, storage_name, original_name, mime_type, byte_size, alt_text, position, created_at_utc)
VALUES ('share-media', 'piece-console', 'shared.png', 'shared.png', 'image/png', 5, 'Freigegeben', 99, ?)`
)
.run(new Date().toISOString());
database
.prepare(
`INSERT INTO usage_media_snapshots
(id, usage_id, original_media_id, alt_text, position, is_presentation)
VALUES ('share-snapshot', ?, 'share-media', 'Freigegeben', 99, 1)`
)
.run(prepared.usageId);
database
.prepare(
`INSERT INTO usage_media_snapshots
(id, usage_id, original_media_id, alt_text, position, is_presentation)
VALUES ('reserve-share-snapshot', ?, 'share-media', 'Private Reserve', 99, 1)`
)
.run(prepared.reserveUsageId);
const created = await createShare(actor, {
showId: prepared.showId,
scope: 'research',
password: 'ein-sicheres-passwort',
expiresAtUtc: new Date(Date.now() + 60_000).toISOString()
});
expect(getSharedShow(created.token).status).toBe('password');
expect(getSharedShow(created.token)).not.toHaveProperty('share');
expect(await getSharedMedia(created.token, undefined, 'share-snapshot')).toBeNull();
await expect(authenticateShare(created.token, 'falsch', '127.0.0.1')).rejects.toThrow(
'INVALID_PASSWORD'
);
const session = await authenticateShare(created.token, 'ein-sicheres-passwort', '127.0.0.1');
expect(getSharedShow(created.token, session.token).status).toBe('authorized');
expect((await getSharedMedia(created.token, session.token, 'share-snapshot'))?.byteSize).toBe(
5
);
expect(await getSharedMedia(created.token, session.token, 'foreign-media')).toBeNull();
expect(await getSharedMedia(created.token, session.token, 'reserve-share-snapshot')).toBeNull();
revokeShare(actor, created.id);
expect(getSharedShow(created.token, session.token).status).toBe('unavailable');
expect(await getSharedMedia(created.token, session.token, 'share-snapshot')).toBeNull();
const expired = await createShare(actor, {
showId: prepared.showId,
scope: 'rundown',
password: '',
expiresAtUtc: new Date(Date.now() + 60_000).toISOString()
});
database
.prepare('UPDATE show_shares SET expires_at_utc = ? WHERE id = ?')
.run('2000-01-01T00:00:00.000Z', expired.id);
expect(getSharedShow(expired.token).status).toBe('unavailable');
expect(await getSharedMedia(expired.token, undefined, 'share-snapshot')).toBeNull();
});
it('limits repeated password attempts per share', async () => {
const prepared = finalizedShow();
const created = await createShare(actor, {
showId: prepared.showId,
scope: 'rundown',
password: 'noch-ein-sicheres-passwort',
expiresAtUtc: null
});
for (let attempt = 0; attempt < 5; attempt += 1) {
await expect(authenticateShare(created.token, 'falsch', '192.0.2.10')).rejects.toThrow(
'INVALID_PASSWORD'
);
}
await expect(
authenticateShare(created.token, 'noch-ein-sicheres-passwort', '192.0.2.10')
).rejects.toThrow('RATE_LIMITED');
});
});
+430
View File
@@ -0,0 +1,430 @@
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { randomUUID } from 'node:crypto';
import {
createToken,
hashPassword,
hashToken,
validPassword,
verifyPassword,
type AuthUser
} from './auth.js';
import { getServerPaths } from './config.js';
import { getDatabase } from './database.js';
import { renderMarkdown } from './markdown.js';
import type { ShowStatus } from './shows.js';
export const SHARE_SESSION_COOKIE = 'sendewerk_share_session';
export const SHARE_SCOPES = ['rundown', 'moderation', 'research'] as const;
export type ShareScope = (typeof SHARE_SCOPES)[number];
const PASSWORD_ATTEMPT_WINDOW_MS = 15 * 60 * 1000;
const SHARE_PASSWORD_ATTEMPT_LIMIT = 5;
const ADDRESS_PASSWORD_ATTEMPT_LIMIT = 20;
type ShareRow = {
id: string;
show_id: string;
token_hash: string;
scope: ShareScope;
password_hash: string | null;
expires_at_utc: string | null;
revoked_at_utc: string | null;
created_at_utc: string;
};
export type ShareListItem = {
id: string;
showId: string;
showTitle: string;
showStatus: ShowStatus;
showStartsAtUtc: string | null;
scope: ShareScope;
isPasswordProtected: boolean;
expiresAtUtc: string | null;
revokedAtUtc: string | null;
createdAtUtc: string;
};
function requireBarkeeperActor(actor: AuthUser): void {
if (actor.role !== 'barkeeper') throw new Error('FORBIDDEN');
}
function validExpiry(value: string | null): string | null {
if (!value) return null;
const expiry = new Date(value);
if (Number.isNaN(expiry.valueOf()) || expiry.valueOf() <= Date.now()) {
throw new Error('INVALID_EXPIRY');
}
return expiry.toISOString();
}
function activeShare(rawToken: string): ShareRow | null {
if (!rawToken) return null;
const now = new Date().toISOString();
return (
(getDatabase()
.prepare(
`SELECT id, show_id, token_hash, scope, password_hash, expires_at_utc,
revoked_at_utc, created_at_utc FROM show_shares
WHERE token_hash = ? AND revoked_at_utc IS NULL
AND (expires_at_utc IS NULL OR expires_at_utc > ?)`
)
.get(hashToken(rawToken), now) as ShareRow | undefined) ?? null
);
}
export function listShares(actor: AuthUser, showId?: string): ShareListItem[] {
requireBarkeeperActor(actor);
const where = showId ? 'WHERE ss.show_id = ?' : '';
return (
getDatabase()
.prepare(
`SELECT ss.id, ss.show_id, sh.title AS show_title, sh.status AS show_status,
sh.starts_at_utc AS show_starts_at_utc, ss.scope, ss.password_hash,
ss.expires_at_utc, ss.revoked_at_utc, ss.created_at_utc
FROM show_shares ss JOIN shows sh ON sh.id = ss.show_id
${where}
ORDER BY ss.created_at_utc DESC`
)
.all(...(showId ? [showId] : [])) as {
id: string;
show_id: string;
show_title: string;
show_status: ShowStatus;
show_starts_at_utc: string | null;
scope: ShareScope;
password_hash: string | null;
expires_at_utc: string | null;
revoked_at_utc: string | null;
created_at_utc: string;
}[]
).map((share) => ({
id: share.id,
showId: share.show_id,
showTitle: share.show_title,
showStatus: share.show_status,
showStartsAtUtc: share.show_starts_at_utc,
scope: share.scope,
isPasswordProtected: Boolean(share.password_hash),
expiresAtUtc: share.expires_at_utc,
revokedAtUtc: share.revoked_at_utc,
createdAtUtc: share.created_at_utc
}));
}
export async function createShare(
actor: AuthUser,
input: { showId: string; scope: string; password: string; expiresAtUtc: string | null }
) {
requireBarkeeperActor(actor);
if (!SHARE_SCOPES.includes(input.scope as ShareScope)) throw new Error('INVALID_SCOPE');
if (input.password && !validPassword(input.password)) throw new Error('INVALID_PASSWORD');
const expiresAtUtc = validExpiry(input.expiresAtUtc);
const database = getDatabase();
if (!database.prepare('SELECT 1 FROM shows WHERE id = ?').get(input.showId)) {
throw new Error('NOT_FOUND');
}
const token = createToken();
const id = randomUUID();
const now = new Date().toISOString();
const passwordHash = input.password ? await hashPassword(input.password) : null;
database
.prepare(
`INSERT INTO show_shares
(id, show_id, token_hash, scope, password_hash, expires_at_utc,
created_by_user_id, created_at_utc) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
)
.run(
id,
input.showId,
hashToken(token),
input.scope,
passwordHash,
expiresAtUtc,
actor.id,
now
);
return { id, token };
}
export async function updateShare(
actor: AuthUser,
shareId: string,
input: {
scope: string;
passwordMode: string;
password: string;
expiresAtUtc: string | null;
},
expectedShowId?: string
): Promise<void> {
requireBarkeeperActor(actor);
if (!SHARE_SCOPES.includes(input.scope as ShareScope)) throw new Error('INVALID_SCOPE');
if (!['keep', 'remove', 'replace'].includes(input.passwordMode)) {
throw new Error('INVALID_PASSWORD_MODE');
}
if (input.passwordMode === 'replace' && !validPassword(input.password)) {
throw new Error('INVALID_PASSWORD');
}
const expiresAtUtc = validExpiry(input.expiresAtUtc);
const existing = getDatabase()
.prepare(
'SELECT show_id, password_hash FROM show_shares WHERE id = ? AND revoked_at_utc IS NULL'
)
.get(shareId) as { show_id: string; password_hash: string | null } | undefined;
if (!existing || (expectedShowId && existing.show_id !== expectedShowId))
throw new Error('NOT_FOUND');
const passwordHash =
input.passwordMode === 'keep'
? existing.password_hash
: input.passwordMode === 'replace'
? await hashPassword(input.password)
: null;
const database = getDatabase();
database.transaction(() => {
const result = database
.prepare(
`UPDATE show_shares SET scope = ?, password_hash = ?, expires_at_utc = ?
WHERE id = ? AND revoked_at_utc IS NULL`
)
.run(input.scope, passwordHash, expiresAtUtc, shareId);
if (result.changes !== 1) throw new Error('NOT_FOUND');
database.prepare('DELETE FROM show_share_sessions WHERE share_id = ?').run(shareId);
})();
}
export function deleteShare(actor: AuthUser, shareId: string, expectedShowId?: string): void {
requireBarkeeperActor(actor);
const result = getDatabase()
.prepare(`DELETE FROM show_shares WHERE id = ?${expectedShowId ? ' AND show_id = ?' : ''}`)
.run(...(expectedShowId ? [shareId, expectedShowId] : [shareId]));
if (result.changes !== 1) throw new Error('NOT_FOUND');
}
export function revokeShare(actor: AuthUser, shareId: string): void {
requireBarkeeperActor(actor);
const database = getDatabase();
database.transaction(() => {
const result = database
.prepare('UPDATE show_shares SET revoked_at_utc = ? WHERE id = ? AND revoked_at_utc IS NULL')
.run(new Date().toISOString(), shareId);
if (result.changes !== 1) throw new Error('NOT_FOUND');
database.prepare('DELETE FROM show_share_sessions WHERE share_id = ?').run(shareId);
})();
}
export function shareAccessState(rawToken: string, sessionToken?: string) {
const share = activeShare(rawToken);
if (!share) return { status: 'unavailable' as const };
if (!share.password_hash) return { status: 'authorized' as const, share };
if (!sessionToken) return { status: 'password' as const };
const session = getDatabase()
.prepare(
`SELECT 1 FROM show_share_sessions WHERE token_hash = ? AND share_id = ?
AND expires_at_utc > ?`
)
.get(hashToken(sessionToken), share.id, new Date().toISOString());
return session ? { status: 'authorized' as const, share } : { status: 'password' as const };
}
export async function authenticateShare(rawToken: string, password: string, address: string) {
const share = activeShare(rawToken);
if (!share?.password_hash) throw new Error('SHARE_UNAVAILABLE');
const database = getDatabase();
const now = new Date();
const since = new Date(now.getTime() - PASSWORD_ATTEMPT_WINDOW_MS).toISOString();
const shareKey = hashToken(rawToken);
const addressKey = hashToken(address || 'unknown');
database.prepare('DELETE FROM share_password_attempts WHERE attempted_at_utc < ?').run(since);
const shareAttempts = database
.prepare(
`SELECT count(*) AS count FROM share_password_attempts
WHERE share_key = ? AND attempted_at_utc >= ?`
)
.get(shareKey, since) as { count: number };
const addressAttempts = database
.prepare(
`SELECT count(*) AS count FROM share_password_attempts
WHERE address_key = ? AND attempted_at_utc >= ?`
)
.get(addressKey, since) as { count: number };
if (
shareAttempts.count >= SHARE_PASSWORD_ATTEMPT_LIMIT ||
addressAttempts.count >= ADDRESS_PASSWORD_ATTEMPT_LIMIT
) {
throw new Error('RATE_LIMITED');
}
database
.prepare(
`INSERT INTO share_password_attempts
(id, share_key, address_key, attempted_at_utc) VALUES (?, ?, ?, ?)`
)
.run(randomUUID(), shareKey, addressKey, now.toISOString());
if (!(await verifyPassword(share.password_hash, password))) throw new Error('INVALID_PASSWORD');
const token = createToken();
const defaultExpiry = now.getTime() + 12 * 60 * 60 * 1000;
const shareExpiry = share.expires_at_utc
? new Date(share.expires_at_utc).getTime()
: defaultExpiry;
const expires = new Date(Math.min(defaultExpiry, shareExpiry));
database.prepare('DELETE FROM share_password_attempts WHERE share_key = ?').run(shareKey);
database
.prepare('DELETE FROM show_share_sessions WHERE expires_at_utc <= ?')
.run(now.toISOString());
database
.prepare(
`INSERT INTO show_share_sessions (token_hash, share_id, created_at_utc, expires_at_utc)
VALUES (?, ?, ?, ?)`
)
.run(hashToken(token), share.id, now.toISOString(), expires.toISOString());
return { token, expires };
}
export function getSharedShow(rawToken: string, sessionToken?: string) {
const access = shareAccessState(rawToken, sessionToken);
if (access.status !== 'authorized' || !access.share) return access;
const share = access.share;
const database = getDatabase();
const show = database
.prepare('SELECT title, starts_at_utc, status, completed_at_utc FROM shows WHERE id = ?')
.get(share.show_id) as {
title: string;
starts_at_utc: string | null;
status: ShowStatus;
completed_at_utc: string | null;
};
const sections = database
.prepare(
`SELECT s.id, s.name, s.category_name_snapshot, s.category_color_snapshot,
c.name AS category_name, c.color AS category_color,
s.explicit_duration_seconds, s.position FROM show_sections s
LEFT JOIN section_categories c ON c.id = s.category_id
WHERE s.show_id = ? AND s.is_reserve_bank = 0 ORDER BY s.position`
)
.all(share.show_id) as {
id: string;
name: string;
category_name_snapshot: string | null;
category_color_snapshot: string | null;
category_name: string | null;
category_color: string | null;
explicit_duration_seconds: number | null;
position: number;
}[];
const usageStatement = database.prepare(
`SELECT u.id, u.title_snapshot, u.output_title_snapshot, u.status, u.duration_seconds,
u.intro_snapshot, u.research_snapshot, u.moderation_snapshot, u.interaction_snapshot,
u.transition_snapshot, u.own_notes,
COALESCE((SELECT actual_duration_seconds FROM piece_usage_history WHERE usage_id = u.id), 0)
AS actual_duration_seconds
FROM piece_usages u WHERE u.show_section_id = ? ORDER BY u.position`
);
const sourceStatement = database.prepare(
'SELECT label, url, timecode_seconds FROM usage_source_snapshots WHERE usage_id = ? ORDER BY position'
);
const toolStatement = database.prepare(
`SELECT name, start_url, work_url, note FROM usage_tool_snapshots
WHERE usage_id = ? ORDER BY position`
);
const mediaStatement = database.prepare(
`SELECT id, alt_text FROM usage_media_snapshots
WHERE usage_id = ? AND is_presentation = 1`
);
const includesModeration = share.scope === 'moderation' || share.scope === 'research';
const includesResearch = share.scope === 'research';
return {
status: 'authorized' as const,
share: {
scope: share.scope,
title: show.title,
startsAtUtc: show.starts_at_utc,
completedAtUtc: show.completed_at_utc,
isCompleted: show.status === 'completed',
sections: sections.map((section) => {
const usages = usageStatement.all(section.id) as {
id: string;
title_snapshot: string;
output_title_snapshot: string;
status: string;
duration_seconds: number;
actual_duration_seconds: number;
intro_snapshot: string;
research_snapshot: string;
moderation_snapshot: string;
interaction_snapshot: string;
transition_snapshot: string;
own_notes: string;
}[];
return {
name: section.name,
category:
show.status === 'completed' ? section.category_name_snapshot : section.category_name,
categoryColor:
show.status === 'completed' ? section.category_color_snapshot : section.category_color,
explicitDurationSeconds: section.explicit_duration_seconds,
usages: usages.map((usage) => ({
title: usage.title_snapshot,
outputTitle: usage.output_title_snapshot,
status: usage.status,
durationSeconds: usage.duration_seconds,
actualDurationSeconds: usage.actual_duration_seconds,
media: mediaStatement.get(usage.id) as { id: string; alt_text: string } | undefined,
...(includesModeration
? {
introHtml: renderMarkdown(usage.intro_snapshot),
moderationHtml: renderMarkdown(usage.moderation_snapshot),
interactionHtml: renderMarkdown(usage.interaction_snapshot),
transitionHtml: renderMarkdown(usage.transition_snapshot)
}
: {}),
...(includesResearch
? {
researchHtml: renderMarkdown(usage.research_snapshot),
sources: sourceStatement.all(usage.id) as {
label: string;
url: string;
timecode_seconds: number | null;
}[],
tools: (
toolStatement.all(usage.id) as {
name: string;
start_url: string;
work_url: string;
note: string;
}[]
).map((tool) => ({ name: tool.name, note: tool.note }))
}
: {})
}))
};
})
}
};
}
export async function getSharedMedia(
rawToken: string,
sessionToken: string | undefined,
snapshotMediaId: string
) {
const access = shareAccessState(rawToken, sessionToken);
if (access.status !== 'authorized' || !access.share) return null;
const row = getDatabase()
.prepare(
`SELECT pm.storage_name, pm.mime_type, pm.byte_size
FROM usage_media_snapshots ums
JOIN piece_usages u ON u.id = ums.usage_id
JOIN show_sections s ON s.id = u.show_section_id
JOIN piece_media pm ON pm.id = ums.original_media_id
WHERE ums.id = ? AND ums.is_presentation = 1 AND s.show_id = ?
AND s.is_reserve_bank = 0`
)
.get(snapshotMediaId, access.share.show_id) as
{ storage_name: string; mime_type: string; byte_size: number } | undefined;
if (!row) return null;
return {
data: await readFile(path.join(getServerPaths().uploads, row.storage_name)),
mimeType: row.mime_type,
byteSize: row.byte_size
};
}
+23 -1
View File
@@ -253,7 +253,29 @@ describe('show builder data layer', () => {
getDatabase() getDatabase()
.prepare("UPDATE piece_usages SET status = 'skipped' WHERE id = ?") .prepare("UPDATE piece_usages SET status = 'skipped' WHERE id = ?")
.run(skipped.id); .run(skipped.id);
getDatabase().prepare("UPDATE shows SET status = 'completed' WHERE id = ?").run(completed.id); getDatabase()
.prepare(
"UPDATE shows SET status = 'completed', completed_at_utc = starts_at_utc WHERE id = ?"
)
.run(completed.id);
getDatabase()
.prepare(
`INSERT INTO show_finalizations
(show_id, command_id, source_revision, finalized_at_utc)
SELECT id, 'test-finalization', revision, starts_at_utc FROM shows WHERE id = ?`
)
.run(completed.id);
getDatabase()
.prepare(
`INSERT INTO piece_usage_history
(usage_id, show_id, original_piece_id, show_title_snapshot, usage_title_snapshot,
used_at_utc, actual_duration_seconds)
SELECT u.id, sh.id, u.original_piece_id, sh.title, u.title_snapshot,
sh.starts_at_utc, 0 FROM piece_usages u
JOIN show_sections s ON s.id = u.show_section_id
JOIN shows sh ON sh.id = s.show_id WHERE u.id = ?`
)
.run(done.id);
const active = createShow({ title: 'Aktuelle Planung', startsAtUtc: null, templateId: null }); const active = createShow({ title: 'Aktuelle Planung', startsAtUtc: null, templateId: null });
const activeSection = addShowSection(active.id, active.revision, { const activeSection = addShowSection(active.id, active.revision, {
+159 -14
View File
@@ -120,6 +120,7 @@ export type Show = {
revision: number; revision: number;
createdAtUtc: string; createdAtUtc: string;
updatedAtUtc: string; updatedAtUtc: string;
completedAtUtc: string | null;
sections: ShowSection[]; sections: ShowSection[];
reserve: PieceUsage[]; reserve: PieceUsage[];
totalDurationSeconds: number; totalDurationSeconds: number;
@@ -193,6 +194,7 @@ type ShowRow = {
revision: number; revision: number;
created_at_utc: string; created_at_utc: string;
updated_at_utc: string; updated_at_utc: string;
completed_at_utc: string | null;
}; };
type SectionRow = { type SectionRow = {
@@ -201,6 +203,8 @@ type SectionRow = {
category_id: string | null; category_id: string | null;
category: string | null; category: string | null;
category_color: string | null; category_color: string | null;
category_name_snapshot: string | null;
category_color_snapshot: string | null;
position: number; position: number;
explicit_duration_seconds: number | null; explicit_duration_seconds: number | null;
is_reserve_bank: number; is_reserve_bank: number;
@@ -713,7 +717,11 @@ function copyUsageRelations(database: Database.Database, sourceUsageId: string,
.run(usageId, sourceUsageId); .run(usageId, sourceUsageId);
} }
export function listShows(): ShowListItem[] { export function listShows(statuses?: ShowStatus[]): ShowListItem[] {
const requestedStatuses = statuses?.filter((status) => SHOW_STATUSES.includes(status));
const where = requestedStatuses?.length
? `WHERE sh.status IN (${requestedStatuses.map(() => '?').join(', ')})`
: '';
return ( return (
getDatabase() getDatabase()
.prepare( .prepare(
@@ -724,10 +732,11 @@ export function listShows(): ShowListItem[] {
(SELECT sum(u.duration_seconds) FROM piece_usages u WHERE u.show_section_id = s.id)) (SELECT sum(u.duration_seconds) FROM piece_usages u WHERE u.show_section_id = s.id))
ELSE 0 END), 0) AS total_duration_seconds ELSE 0 END), 0) AS total_duration_seconds
FROM shows sh LEFT JOIN show_sections s ON s.show_id = sh.id FROM shows sh LEFT JOIN show_sections s ON s.show_id = sh.id
${where}
GROUP BY sh.id GROUP BY sh.id
ORDER BY sh.starts_at_utc IS NULL, sh.starts_at_utc DESC, sh.created_at_utc DESC` ORDER BY sh.starts_at_utc IS NULL, sh.starts_at_utc DESC, sh.created_at_utc DESC`
) )
.all() as { .all(...(requestedStatuses ?? [])) as {
id: string; id: string;
title: string; title: string;
starts_at_utc: string | null; starts_at_utc: string | null;
@@ -781,13 +790,14 @@ export function getShow(id: string): Show | null {
const show = database const show = database
.prepare( .prepare(
`SELECT id, title, starts_at_utc, twitch_title, announcement_text, status, revision, `SELECT id, title, starts_at_utc, twitch_title, announcement_text, status, revision,
created_at_utc, updated_at_utc FROM shows WHERE id = ?` created_at_utc, updated_at_utc, completed_at_utc FROM shows WHERE id = ?`
) )
.get(id) as ShowRow | undefined; .get(id) as ShowRow | undefined;
if (!show) return null; if (!show) return null;
const sectionRows = database const sectionRows = database
.prepare( .prepare(
`SELECT s.id, s.name, s.category_id, c.name AS category, c.color AS category_color, `SELECT s.id, s.name, s.category_id, c.name AS category, c.color AS category_color,
s.category_name_snapshot, s.category_color_snapshot,
s.position, s.explicit_duration_seconds, s.is_reserve_bank s.position, s.explicit_duration_seconds, s.is_reserve_bank
FROM show_sections s LEFT JOIN section_categories c ON c.id = s.category_id FROM show_sections s LEFT JOIN section_categories c ON c.id = s.category_id
WHERE s.show_id = ? ORDER BY s.position` WHERE s.show_id = ? ORDER BY s.position`
@@ -821,8 +831,14 @@ export function getShow(id: string): Show | null {
id: row.id, id: row.id,
name: row.name, name: row.name,
categoryId: row.category_id, categoryId: row.category_id,
category: row.category, category:
categoryColor: row.category_color, show.status === 'completed'
? row.category_name_snapshot
: (row.category_name_snapshot ?? row.category),
categoryColor:
show.status === 'completed'
? row.category_color_snapshot
: (row.category_color_snapshot ?? row.category_color),
position: row.position, position: row.position,
explicitDurationSeconds: row.explicit_duration_seconds, explicitDurationSeconds: row.explicit_duration_seconds,
durationSeconds: durationSeconds:
@@ -842,6 +858,7 @@ export function getShow(id: string): Show | null {
revision: show.revision, revision: show.revision,
createdAtUtc: show.created_at_utc, createdAtUtc: show.created_at_utc,
updatedAtUtc: show.updated_at_utc, updatedAtUtc: show.updated_at_utc,
completedAtUtc: show.completed_at_utc,
sections, sections,
reserve: reserveRow ? (usagesBySection.get(reserveRow.id) ?? []) : [], reserve: reserveRow ? (usagesBySection.get(reserveRow.id) ?? []) : [],
totalDurationSeconds: sections.reduce((total, section) => total + section.durationSeconds, 0) totalDurationSeconds: sections.reduce((total, section) => total + section.durationSeconds, 0)
@@ -868,14 +885,9 @@ export function getPieceUsageContexts(
const database = getDatabase(); const database = getDatabase();
const history = database const history = database
.prepare( .prepare(
`SELECT u.id AS usage_id, u.original_piece_id, sh.id AS show_id, `SELECT usage_id, original_piece_id, show_id,
sh.title AS show_title, COALESCE(sh.starts_at_utc, sh.updated_at_utc) AS used_at_utc show_title_snapshot AS show_title, used_at_utc
FROM piece_usages u FROM piece_usage_history ORDER BY used_at_utc DESC, usage_id DESC`
JOIN show_sections s ON s.id = u.show_section_id
JOIN shows sh ON sh.id = s.show_id
WHERE u.original_piece_id IS NOT NULL AND u.status = 'done'
AND sh.status = 'completed' AND s.is_reserve_bank = 0
ORDER BY used_at_utc DESC, u.id DESC`
) )
.all() as { .all() as {
usage_id: string; usage_id: string;
@@ -976,6 +988,138 @@ export function createShow(input: CreateShowInput): CreatedMutationResult {
return { id, revision: 1, updatedAtUtc: now }; return { id, revision: 1, updatedAtUtc: now };
} }
export function duplicateCompletedShow(
sourceShowId: string,
commandId: string
): CreatedMutationResult {
if (!commandId || commandId.length > 200) throw new Error('INVALID_COMMAND');
const database = getDatabase();
return database.transaction(() => {
const duplicate = database
.prepare(
`SELECT created_show_id FROM show_duplication_commands
WHERE source_show_id = ? AND command_id = ?`
)
.get(sourceShowId, commandId) as { created_show_id: string } | undefined;
if (duplicate) {
const show = database
.prepare('SELECT revision, updated_at_utc FROM shows WHERE id = ?')
.get(duplicate.created_show_id) as { revision: number; updated_at_utc: string };
return {
id: duplicate.created_show_id,
revision: show.revision,
updatedAtUtc: show.updated_at_utc
};
}
const source = database
.prepare(
`SELECT sh.title, sh.twitch_title, sh.announcement_text
FROM shows sh JOIN show_finalizations f ON f.show_id = sh.id WHERE sh.id = ?`
)
.get(sourceShowId) as
{ title: string; twitch_title: string; announcement_text: string } | undefined;
if (!source) throw new Error('NOT_FOUND');
const id = randomUUID();
const now = new Date().toISOString();
const title = `${source.title} (Kopie)`.slice(0, 200);
database
.prepare(
`INSERT INTO shows (id, title, starts_at_utc, twitch_title, announcement_text,
status, revision, created_at_utc, updated_at_utc)
VALUES (?, ?, NULL, ?, ?, 'draft', 1, ?, ?)`
)
.run(id, title, source.twitch_title, source.announcement_text, now, now);
const sections = database
.prepare(
`SELECT id, name, category_id, category_name_snapshot, category_color_snapshot,
position, explicit_duration_seconds, is_reserve_bank
FROM show_sections WHERE show_id = ? ORDER BY position`
)
.all(sourceShowId) as {
id: string;
name: string;
category_id: string | null;
category_name_snapshot: string | null;
category_color_snapshot: string | null;
position: number;
explicit_duration_seconds: number | null;
is_reserve_bank: number;
}[];
const insertSection = database.prepare(
`INSERT INTO show_sections (id, show_id, name, category_id, category_name_snapshot,
category_color_snapshot, position, explicit_duration_seconds, is_reserve_bank)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
);
const usages = database.prepare(
'SELECT * FROM piece_usages WHERE show_section_id = ? ORDER BY position'
);
const insertUsage = database.prepare(
`INSERT INTO piece_usages (id, show_section_id, original_piece_id, position, status,
duration_seconds, title_snapshot, output_title_snapshot, intro_snapshot, research_snapshot,
moderation_snapshot, interaction_snapshot, transition_snapshot, presentation_snapshot,
presentation_mode_snapshot, presentation_source_label_snapshot,
presentation_source_url_snapshot, presentation_source_timecode_snapshot,
presentation_title_meta_snapshot, presentation_description_snapshot,
presentation_image_url_snapshot, presentation_site_name_snapshot,
presentation_provider_snapshot, presentation_provider_id_snapshot, own_notes, is_reserve)
VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
);
for (const section of sections) {
const sectionId = randomUUID();
insertSection.run(
sectionId,
id,
section.name,
section.category_id,
section.category_name_snapshot,
section.category_color_snapshot,
section.position,
section.explicit_duration_seconds,
section.is_reserve_bank
);
for (const usage of usages.all(section.id) as (UsageRow & { is_reserve: number })[]) {
const usageId = randomUUID();
insertUsage.run(
usageId,
sectionId,
usage.original_piece_id,
usage.position,
usage.duration_seconds,
usage.title_snapshot,
usage.output_title_snapshot,
usage.intro_snapshot,
usage.research_snapshot,
usage.moderation_snapshot,
usage.interaction_snapshot,
usage.transition_snapshot,
usage.presentation_snapshot,
usage.presentation_mode_snapshot,
usage.presentation_source_label_snapshot,
usage.presentation_source_url_snapshot,
usage.presentation_source_timecode_snapshot,
usage.presentation_title_meta_snapshot,
usage.presentation_description_snapshot,
usage.presentation_image_url_snapshot,
usage.presentation_site_name_snapshot,
usage.presentation_provider_snapshot,
usage.presentation_provider_id_snapshot,
usage.own_notes,
usage.is_reserve
);
copyUsageRelations(database, usage.id, usageId);
}
}
database
.prepare(
`INSERT INTO show_duplication_commands
(source_show_id, command_id, created_show_id, created_at_utc) VALUES (?, ?, ?, ?)`
)
.run(sourceShowId, commandId, id, now);
return { id, revision: 1, updatedAtUtc: now };
})();
}
export function saveShowMetadata( export function saveShowMetadata(
showId: string, showId: string,
expectedRevision: number, expectedRevision: number,
@@ -1055,7 +1199,8 @@ export function updateShowSection(
sectionBelongsToShow(database, showId, sectionId); sectionBelongsToShow(database, showId, sectionId);
database database
.prepare( .prepare(
`UPDATE show_sections SET name = ?, category_id = ?, explicit_duration_seconds = ? `UPDATE show_sections SET name = ?, category_id = ?, explicit_duration_seconds = ?,
category_name_snapshot = NULL, category_color_snapshot = NULL
WHERE id = ?` WHERE id = ?`
) )
.run(name, input.categoryId, duration, sectionId); .run(name, input.categoryId, duration, sectionId);
+12
View File
@@ -0,0 +1,12 @@
import { describe, expect, it } from 'vitest';
import { berlinLocalToUtc } from './time.js';
describe('Berlin local time conversion', () => {
it('converts valid civil time and rejects normalized or nonexistent values', () => {
expect(berlinLocalToUtc('2026-10-25T20:00')).toBe('2026-10-25T19:00:00.000Z');
expect(berlinLocalToUtc('')).toBeNull();
expect(() => berlinLocalToUtc('2026-02-31T12:00')).toThrow('INVALID_TIMESTAMP');
expect(() => berlinLocalToUtc('2026-03-29T02:30', 'INVALID_EXPIRY')).toThrow('INVALID_EXPIRY');
expect(() => berlinLocalToUtc('2026-10-25T02:30', 'INVALID_EXPIRY')).toThrow('INVALID_EXPIRY');
});
});
+44
View File
@@ -0,0 +1,44 @@
export function berlinLocalToUtc(value: string, errorCode = 'INVALID_TIMESTAMP'): string | null {
if (!value) return null;
const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/.exec(value);
if (!match) throw new Error(errorCode);
const parts = match.slice(1).map(Number);
const localMilliseconds = Date.UTC(parts[0], parts[1] - 1, parts[2], parts[3], parts[4]);
let candidate = localMilliseconds;
const formatter = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Europe/Berlin',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23'
});
const localParts = (date: Date) => {
const formatted = formatter.formatToParts(date);
const read = (type: Intl.DateTimeFormatPartTypes) =>
formatted.find((part) => part.type === type)?.value ?? '';
return {
key: `${read('year')}-${read('month')}-${read('day')}T${read('hour')}:${read('minute')}`,
milliseconds: Date.UTC(
Number(read('year')),
Number(read('month')) - 1,
Number(read('day')),
Number(read('hour')),
Number(read('minute'))
)
};
};
for (let attempt = 0; attempt < 2; attempt += 1) {
candidate += localMilliseconds - localParts(new Date(candidate)).milliseconds;
}
const result = new Date(candidate);
if (localParts(result).key !== value) throw new Error(errorCode);
if (
localParts(new Date(candidate - 60 * 60 * 1000)).key === value ||
localParts(new Date(candidate + 60 * 60 * 1000)).key === value
) {
throw new Error(errorCode);
}
return result.toISOString();
}
+5 -1
View File
@@ -1,7 +1,11 @@
import type { LayoutServerLoad } from './$types'; import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = ({ locals, url }) => ({ export const load: LayoutServerLoad = ({ locals, url }) => ({
user: url.pathname.startsWith('/heinzlive/') ? null : locals.user, user:
url.pathname.startsWith('/heinzlive/') || url.pathname.startsWith('/freigabe/')
? null
: locals.user,
presentationMode: url.pathname.startsWith('/heinzlive/'), presentationMode: url.pathname.startsWith('/heinzlive/'),
guestMode: url.pathname.startsWith('/freigabe/'),
liveControllerMode: url.pathname === '/live' liveControllerMode: url.pathname === '/live'
}); });
+11
View File
@@ -34,6 +34,17 @@
{#if data.presentationMode} {#if data.presentationMode}
{@render children()} {@render children()}
{:else if data.guestMode}
<div class="guest-shell">
<header class="auth-header">
<span class="brand"
><span class="brand-mark" aria-hidden="true"><i></i><i></i><i></i></span><span
>Sendewerk</span
></span
><ThemeToggle />
</header>
{@render children()}
</div>
{:else if data.user && data.liveControllerMode} {:else if data.user && data.liveControllerMode}
<main id="main-content" class="live-controller-shell">{@render children()}</main> <main id="main-content" class="live-controller-shell">{@render children()}</main>
{:else if data.user} {:else if data.user}
+19 -1
View File
@@ -1,11 +1,15 @@
import { fail } from '@sveltejs/kit'; import { fail, redirect } from '@sveltejs/kit';
import { randomUUID } from 'node:crypto';
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
import { requireUser } from '#lib/server/auth.js'; import { requireUser } from '#lib/server/auth.js';
import { listPieces, setPieceArchived, setPieceDeleted } from '#lib/server/content.js'; import { listPieces, setPieceArchived, setPieceDeleted } from '#lib/server/content.js';
import { duplicateCompletedShow, listShows } from '#lib/server/shows.js';
export const load: PageServerLoad = ({ locals }) => { export const load: PageServerLoad = ({ locals }) => {
requireUser(locals.user); requireUser(locals.user);
return { return {
completedShows: listShows(['completed']),
duplicateCommandId: randomUUID(),
pieces: listPieces({ statuses: ['archived'] }), pieces: listPieces({ statuses: ['archived'] }),
deletedPieces: listPieces({ deletedPieces: listPieces({
statuses: ['inbox', 'preparing', 'ready', 'archived'], statuses: ['inbox', 'preparing', 'ready', 'archived'],
@@ -34,5 +38,19 @@ export const actions = {
} catch { } catch {
return fail(409, { error: 'Das Piece wurde inzwischen geändert. Bitte Seite neu laden.' }); return fail(409, { error: 'Das Piece wurde inzwischen geändert. Bitte Seite neu laden.' });
} }
},
duplicateShow: async ({ locals, request }) => {
requireUser(locals.user);
const data = await request.formData();
try {
const duplicate = duplicateCompletedShow(
String(data.get('showId') || ''),
String(data.get('commandId') || '')
);
redirect(303, `/sendungen/${duplicate.id}`);
} catch (cause) {
if (cause && typeof cause === 'object' && 'status' in cause) throw cause;
return fail(400, { error: 'Die Sendung konnte nicht dupliziert werden.' });
}
} }
} satisfies Actions; } satisfies Actions;
+22 -1
View File
@@ -1,5 +1,7 @@
<script lang="ts"> <script lang="ts">
import AppIcon from '#lib/components/AppIcon.svelte'; import AppIcon from '#lib/components/AppIcon.svelte';
import { resolve } from '$app/paths';
import { formatDuration } from '#lib/format.js';
import type { PageProps } from './$types'; import type { PageProps } from './$types';
let { data, form }: PageProps = $props(); let { data, form }: PageProps = $props();
</script> </script>
@@ -9,10 +11,29 @@
<div class="page-wrap narrow-page"> <div class="page-wrap narrow-page">
<header class="page-heading"> <header class="page-heading">
<h1>Archiv</h1> <h1>Archiv</h1>
<p class="page-intro">Abgelegte Pieces; abgeschlossene Sendungen folgen in Etappe 6.</p> <p class="page-intro">Stabile Sendungshistorie und bewusst abgelegte Fundus-Pieces.</p>
</header> </header>
{#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if} {#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if}
{#if form?.success}<div class="form-message success" role="status">{form.success}</div>{/if} {#if form?.success}<div class="form-message success" role="status">{form.success}</div>{/if}
<section aria-labelledby="completed-shows-title">
<h2 id="completed-shows-title" class="section-heading">Abgeschlossene Sendungen</h2>
{#if data.completedShows.length}<div class="archive-list">
{#each data.completedShows as show (show.id)}<article>
<div>
<h2><a href={resolve('/sendungen/[id]', { id: show.id })}>{show.title}</a></h2>
<p>
{show.sectionCount} Abschnitte · {formatDuration(show.totalDurationSeconds)} Planzeit
</p>
</div>
<form method="POST" action="?/duplicateShow">
<input type="hidden" name="showId" value={show.id} />
<input type="hidden" name="commandId" value={data.duplicateCommandId} />
<button class="secondary-button" type="submit">Als neue Sendung duplizieren</button>
</form>
</article>{/each}
</div>{:else}<p class="context-empty">Noch keine Sendung historisch abgeschlossen.</p>{/if}
</section>
<h2 class="section-heading">Archivierte Pieces</h2>
{#if data.pieces.length}<div class="archive-list"> {#if data.pieces.length}<div class="archive-list">
{#each data.pieces as piece (piece.id)}<article> {#each data.pieces as piece (piece.id)}<article>
<div> <div>
@@ -0,0 +1,31 @@
import { fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { getAuthConfig } from '#lib/server/config.js';
import { authenticateShare, getSharedShow, SHARE_SESSION_COOKIE } from '#lib/server/shares.js';
export const load: PageServerLoad = ({ params, cookies }) => {
return getSharedShow(params.token, cookies.get(SHARE_SESSION_COOKIE));
};
export const actions = {
password: async ({ params, request, cookies, getClientAddress }) => {
const data = await request.formData();
try {
const session = await authenticateShare(
params.token,
String(data.get('password') || ''),
getClientAddress()
);
cookies.set(SHARE_SESSION_COOKIE, session.token, {
path: `/freigabe/${params.token}`,
httpOnly: true,
sameSite: 'strict',
secure: getAuthConfig().secureCookies,
expires: session.expires
});
return { success: true };
} catch {
return fail(401, { error: 'Passwort falsch oder Freigabe nicht mehr verfügbar.' });
}
}
} satisfies Actions;
+141
View File
@@ -0,0 +1,141 @@
<script lang="ts">
import { page } from '$app/state';
import { formatDuration } from '#lib/format.js';
import type { PageProps } from './$types';
let { data, form }: PageProps = $props();
function berlinDate(value: string | null) {
if (!value) return 'Termin nicht angegeben';
return new Intl.DateTimeFormat('de-DE', {
timeZone: 'Europe/Berlin',
dateStyle: 'full',
timeStyle: 'short'
}).format(new Date(value));
}
</script>
<svelte:head><title>Sendungsfreigabe | Sendewerk</title></svelte:head>
<main class="share-page">
{#if data.status === 'unavailable'}
<section class="quiet-panel share-gate">
<p class="eyebrow">Lesefreigabe</p>
<h1>Nicht verfügbar</h1>
<p>Dieser Link ist ungültig, abgelaufen oder wurde widerrufen.</p>
</section>
{:else if data.status === 'password'}
<section class="quiet-panel share-gate">
<p class="eyebrow">Geschützte Lesefreigabe</p>
<h1>Passwort erforderlich</h1>
{#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if}
<form method="POST" action="?/password" class="compact-form">
<label
>Passwort<input
name="password"
type="password"
required
autocomplete="current-password"
/></label
>
<button class="primary-button" type="submit">Sendung öffnen</button>
</form>
</section>
{:else}
<header class="share-heading">
<p class="eyebrow">
{data.share.isCompleted ? 'Abgeschlossene Sendung' : 'Aktuelle Sendungsplanung'} · Nur lesen
</p>
<h1>{data.share.title}</h1>
<p>{berlinDate(data.share.startsAtUtc)} Uhr</p>
</header>
<div class="shared-rundown">
{#each data.share.sections as section (section)}
<section class="quiet-panel shared-section">
<header>
<div>
{#if section.category}<small
style={`--tax-color: ${section.categoryColor ?? '#67dde8'}`}
>{section.category}</small
>{/if}
<h2>{section.name}</h2>
</div>
{#if section.explicitDurationSeconds !== null}<span
>{formatDuration(section.explicitDurationSeconds)}</span
>{/if}
</header>
{#each section.usages as usage (usage)}
<article class="shared-usage">
<div class="shared-usage-heading">
<div>
<small
>{usage.status === 'done'
? 'Gesendet'
: usage.status === 'skipped'
? 'Übersprungen'
: 'Offen'}</small
>
<h3>{usage.title}</h3>
</div>
<span>{formatDuration(usage.actualDurationSeconds || usage.durationSeconds)}</span>
</div>
{#if usage.media}<img
src={`${page.url.pathname}/media/${usage.media.id}`}
alt={usage.media.alt_text}
/>{/if}
{#if 'introHtml' in usage && usage.introHtml}<div class="shared-copy">
<strong>Einleitung</strong>
<!-- HTML is produced exclusively by the server-side allowlist sanitizer. -->
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
<div>{@html usage.introHtml}</div>
</div>{/if}
{#if 'moderationHtml' in usage && usage.moderationHtml}<div class="shared-copy">
<strong>Moderation</strong>
<!-- HTML is produced exclusively by the server-side allowlist sanitizer. -->
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
<div>{@html usage.moderationHtml}</div>
</div>{/if}
{#if 'interactionHtml' in usage && usage.interactionHtml}<div class="shared-copy">
<strong>Interaktion</strong>
<!-- HTML is produced exclusively by the server-side allowlist sanitizer. -->
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
<div>{@html usage.interactionHtml}</div>
</div>{/if}
{#if 'transitionHtml' in usage && usage.transitionHtml}<div class="shared-copy">
<strong>Überleitung</strong>
<!-- HTML is produced exclusively by the server-side allowlist sanitizer. -->
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
<div>{@html usage.transitionHtml}</div>
</div>{/if}
{#if 'researchHtml' in usage && usage.researchHtml}<details>
<summary>Recherche</summary>
<!-- HTML is produced exclusively by the server-side allowlist sanitizer. -->
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
<div class="shared-copy">{@html usage.researchHtml}</div>
</details>{/if}
{#if 'sources' in usage && usage.sources?.length}<details>
<summary>Quellen</summary>
<ul>
{#each usage.sources ?? [] as source (source)}<li>
<!-- External source URLs are validated and stored server-side. -->
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<a href={source.url} rel="noreferrer">{source.label || source.url}</a>
</li>{/each}
</ul>
</details>{/if}
{#if 'tools' in usage && usage.tools?.length}<details>
<summary>Werkzeuge</summary>
<ul>
{#each usage.tools ?? [] as tool (tool)}<li>
<strong>{tool.name}</strong>{#if tool.note}
· {tool.note}{/if}
</li>{/each}
</ul>
</details>{/if}
</article>
{/each}
</section>
{/each}
</div>
{/if}
</main>
@@ -0,0 +1,18 @@
import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getSharedMedia, SHARE_SESSION_COOKIE } from '#lib/server/shares.js';
export const GET: RequestHandler = async ({ params, cookies }) => {
const media = await getSharedMedia(params.token, cookies.get(SHARE_SESSION_COOKIE), params.id);
if (!media) error(404, 'Bild nicht gefunden');
return new Response(media.data, {
headers: {
'Content-Type': media.mimeType,
'Content-Length': String(media.byteSize),
'Cache-Control': 'private, no-store',
'Content-Security-Policy': "default-src 'none'; sandbox",
'Cross-Origin-Resource-Policy': 'same-origin',
'X-Content-Type-Options': 'nosniff'
}
});
};
+22 -1
View File
@@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto';
import { error, fail } from '@sveltejs/kit'; import { error, fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
import { requireUser } from '#lib/server/auth.js'; import { requireUser } from '#lib/server/auth.js';
import { finalizeShow, getCompletionSummary } from '#lib/server/completion.js';
import { import {
completeCurrent, completeCurrent,
clearPresentationChannel, clearPresentationChannel,
@@ -74,7 +75,13 @@ export const load: PageServerLoad = ({ locals, url }) => {
const showId = url.searchParams.get('show') || listShows()[0]?.id; const showId = url.searchParams.get('show') || listShows()[0]?.id;
if (!showId) error(404, 'Keine Sendung vorhanden'); if (!showId) error(404, 'Keine Sendung vorhanden');
try { try {
return { live: getLiveView(showId), commandId: randomUUID() }; const live = getLiveView(showId);
return {
live,
commandId: randomUUID(),
completion:
live.endedAtUtc && live.show.status !== 'completed' ? getCompletionSummary(showId) : null
};
} catch (cause) { } catch (cause) {
if (cause instanceof Error && cause.message === 'NOT_FOUND') if (cause instanceof Error && cause.message === 'NOT_FOUND')
error(404, 'Sendung nicht gefunden'); error(404, 'Sendung nicht gefunden');
@@ -142,6 +149,20 @@ export const actions = {
endLive(text(data, 'showId'), text(data, 'commandId'), Number(data.get('revision'))) endLive(text(data, 'showId'), text(data, 'commandId'), Number(data.get('revision')))
); );
}, },
finalize: async ({ locals, request }) => {
const actor = requireUser(locals.user);
const data = await request.formData();
return attempt('finalize', () =>
finalizeShow({
showId: text(data, 'showId'),
commandId: text(data, 'commandId'),
expectedShowRevision: Number(data.get('showRevision')),
expectedLiveRevision: Number(data.get('revision')),
archivePieceIds: data.getAll('archivePieceIds').map(String),
actor
})
);
},
resumeEnded: async ({ locals, request }) => { resumeEnded: async ({ locals, request }) => {
requireUser(locals.user); requireUser(locals.user);
const data = await request.formData(); const data = await request.formData();
+62
View File
@@ -30,6 +30,7 @@
let railCloseButton = $state<HTMLButtonElement>(); let railCloseButton = $state<HTMLButtonElement>();
let liveMenuTrigger = $state<HTMLButtonElement>(); let liveMenuTrigger = $state<HTMLButtonElement>();
let liveMenuPopover = $state<HTMLElement>(); let liveMenuPopover = $state<HTMLElement>();
let completionDialog = $state<HTMLDialogElement>();
let scrollPositions: Record<string, number> = {}; let scrollPositions: Record<string, number> = {};
let outputWindow: Window | null = null; let outputWindow: Window | null = null;
let allUsages = $derived(data.live.sections.flatMap((section) => section.usages)); let allUsages = $derived(data.live.sections.flatMap((section) => section.usages));
@@ -144,6 +145,10 @@
}); });
}); });
$effect(() => {
if (!data.completion && completionDialog?.open) completionDialog.close();
});
onMount(() => { onMount(() => {
const key = `sendewerk-live-${data.live.show.id}`; const key = `sendewerk-live-${data.live.show.id}`;
try { try {
@@ -611,6 +616,12 @@
> >
</form> </form>
{:else if data.live.endedAtUtc} {:else if data.live.endedAtUtc}
{#if data.live.show.status === 'completed'}
<p class="quiet-copy">Diese Sendung ist historisch abgeschlossen und unveränderlich.</p>
<a class="secondary-button" href={resolve('/sendungen/[id]', { id: data.live.show.id })}
>Historische Sendung öffnen</a
>
{:else}
<form method="POST" action={liveAction('resumeEnded')} use:enhance={enhanceCommand}> <form method="POST" action={liveAction('resumeEnded')} use:enhance={enhanceCommand}>
<input type="hidden" name="showId" value={data.live.show.id} /> <input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="revision" value={data.live.liveRevision} /> <input type="hidden" name="revision" value={data.live.liveRevision} />
@@ -639,6 +650,14 @@
><AppIcon name="arrow-rotate-left" /> Neuer Durchlauf</button ><AppIcon name="arrow-rotate-left" /> Neuer Durchlauf</button
> >
</form> </form>
<button
class="primary-button"
type="button"
onclick={() => completionDialog?.showModal()}
>
<AppIcon name="box-archive" /> Sendung abschließen
</button>
{/if}
{:else} {:else}
<form <form
method="POST" method="POST"
@@ -723,6 +742,49 @@
{#if popupMessage}<div class="live-message form-message" role="status">{popupMessage}</div>{/if} {#if popupMessage}<div class="live-message form-message" role="status">{popupMessage}</div>{/if}
</div> </div>
<dialog bind:this={completionDialog} class="completion-dialog">
<form method="POST" action={liveAction('finalize')} use:enhance={enhanceCommand}>
<header>
<p class="eyebrow">Unveränderliche Historie</p>
<h2>Sendung abschließen</h2>
</header>
<p>
Der aktuelle Ablauf wird dauerhaft eingefroren. Übersprungene Pieces zählen nicht als
gesendet.
</p>
{#if data.completion}
<div class="completion-counts">
<span><strong>{data.completion.doneCount}</strong> gesendet</span><span
><strong>{data.completion.skippedCount}</strong> übersprungen</span
><span><strong>{data.completion.openCount}</strong> offen</span>
</div>
<fieldset>
<legend>Originale bewusst in den Fundus-Archivbereich legen</legend>
{#each data.completion.candidates as candidate (candidate.pieceId)}<label
class="check-row"
><input type="checkbox" name="archivePieceIds" value={candidate.pieceId} /><span
><strong>{candidate.title}</strong>{#if candidate.usageCount > 1}<small
>{candidate.usageCount} gesendete Verwendungen</small
>{/if}</span
></label
>{/each}
{#if !data.completion.candidates.length}<p class="quiet-copy">
Keine archivierbaren Originale vorhanden.
</p>{/if}
</fieldset>
{/if}
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="revision" value={data.live.liveRevision} />
<input type="hidden" name="showRevision" value={data.live.show.revision} />
<input type="hidden" name="commandId" value={data.commandId} />
<footer>
<button class="text-button" type="button" onclick={() => completionDialog?.close()}
>Abbrechen</button
><button class="primary-button" type="submit">Historischen Stand abschließen</button>
</footer>
</form>
</dialog>
<div class="live-workspace"> <div class="live-workspace">
{#if viewedUsage}<article {#if viewedUsage}<article
class="live-current" class="live-current"
+2 -32
View File
@@ -3,37 +3,7 @@ import type { Actions, PageServerLoad } from './$types';
import { berlinDateKey, berlinMonthKey, normalizeMonthKey, shiftMonth } from '#lib/calendar.js'; import { berlinDateKey, berlinMonthKey, normalizeMonthKey, shiftMonth } from '#lib/calendar.js';
import { getShowOverviewView, requireUser, setShowOverviewView } from '#lib/server/auth.js'; import { getShowOverviewView, requireUser, setShowOverviewView } from '#lib/server/auth.js';
import { createShow, listShows, listShowTemplates } from '#lib/server/shows.js'; import { createShow, listShows, listShowTemplates } from '#lib/server/shows.js';
import { berlinLocalToUtc } from '#lib/server/time.js';
function berlinLocalToUtc(value: string): string | null {
if (!value) return null;
const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/.exec(value);
if (!match) throw new Error('INVALID_TIMESTAMP');
const parts = match.slice(1).map(Number);
const localMilliseconds = Date.UTC(parts[0], parts[1] - 1, parts[2], parts[3], parts[4]);
let candidate = localMilliseconds;
for (let attempt = 0; attempt < 2; attempt += 1) {
const formatted = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Europe/Berlin',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23'
}).formatToParts(new Date(candidate));
const read = (type: Intl.DateTimeFormatPartTypes) =>
Number(formatted.find((part) => part.type === type)?.value);
const represented = Date.UTC(
read('year'),
read('month') - 1,
read('day'),
read('hour'),
read('minute')
);
candidate += localMilliseconds - represented;
}
return new Date(candidate).toISOString();
}
export const load: PageServerLoad = ({ locals, url }) => { export const load: PageServerLoad = ({ locals, url }) => {
const user = requireUser(locals.user); const user = requireUser(locals.user);
@@ -41,7 +11,7 @@ export const load: PageServerLoad = ({ locals, url }) => {
const todayMonth = berlinMonthKey(today); const todayMonth = berlinMonthKey(today);
const selectedMonth = normalizeMonthKey(url.searchParams.get('month'), todayMonth); const selectedMonth = normalizeMonthKey(url.searchParams.get('month'), todayMonth);
return { return {
shows: listShows(), shows: listShows(['draft', 'ready', 'live']),
templates: listShowTemplates(), templates: listShowTemplates(),
view: getShowOverviewView(user.id), view: getShowOverviewView(user.id),
selectedMonth, selectedMonth,
+95 -29
View File
@@ -1,13 +1,17 @@
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { error, fail } from '@sveltejs/kit'; import { error, fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
import { requireUser } from '#lib/server/auth.js'; import { requireBarkeeper, requireUser } from '#lib/server/auth.js';
import { getHistoricalActualDurations } from '#lib/server/completion.js';
import { getTaxonomy, listPieces, listPieceTemplates } from '#lib/server/content.js'; import { getTaxonomy, listPieces, listPieceTemplates } from '#lib/server/content.js';
import { createShare, deleteShare, listShares, updateShare } from '#lib/server/shares.js';
import { berlinLocalToUtc } from '#lib/server/time.js';
import { import {
addPieceUsage, addPieceUsage,
addShowSection, addShowSection,
createAndAddPieceUsage, createAndAddPieceUsage,
duplicatePieceUsage, duplicatePieceUsage,
duplicateCompletedShow,
getShow, getShow,
getPieceUsageContexts, getPieceUsageContexts,
movePieceUsage, movePieceUsage,
@@ -35,32 +39,6 @@ function pieceDurationInSeconds(data: FormData) {
return Math.round(Number(data.get('durationMinutes')) * 60); return Math.round(Number(data.get('durationMinutes')) * 60);
} }
function berlinLocalToUtc(value: string): string | null {
if (!value) return null;
const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/.exec(value);
if (!match) throw new Error('INVALID_TIMESTAMP');
const parts = match.slice(1).map(Number);
const localMilliseconds = Date.UTC(parts[0], parts[1] - 1, parts[2], parts[3], parts[4]);
let candidate = localMilliseconds;
for (let attempt = 0; attempt < 2; attempt += 1) {
const formatted = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Europe/Berlin',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23'
}).formatToParts(new Date(candidate));
const read = (type: Intl.DateTimeFormatPartTypes) =>
Number(formatted.find((part) => part.type === type)?.value);
candidate +=
localMilliseconds -
Date.UTC(read('year'), read('month') - 1, read('day'), read('hour'), read('minute'));
}
return new Date(candidate).toISOString();
}
function berlinInput(value: string | null) { function berlinInput(value: string | null) {
if (!value) return ''; if (!value) return '';
const parts = new Intl.DateTimeFormat('en-CA', { const parts = new Intl.DateTimeFormat('en-CA', {
@@ -104,6 +82,12 @@ function actionError(cause: unknown) {
}; };
if (code === 'INVALID_DURATION') if (code === 'INVALID_DURATION')
return { status: 400, error: 'Piece-Dauern müssen zwischen 0 und 1.440 Minuten liegen.' }; return { status: 400, error: 'Piece-Dauern müssen zwischen 0 und 1.440 Minuten liegen.' };
if (code === 'INVALID_PASSWORD')
return { status: 400, error: 'Ein Freigabepasswort benötigt mindestens 12 Zeichen.' };
if (code === 'INVALID_EXPIRY')
return { status: 400, error: 'Der Ablaufzeitpunkt muss in der Zukunft liegen.' };
if (code === 'INVALID_SCOPE' || code === 'INVALID_PASSWORD_MODE')
return { status: 400, error: 'Die Einstellungen der Freigabe sind ungültig.' };
if (code.startsWith('INVALID_')) if (code.startsWith('INVALID_'))
return { status: 400, error: 'Mindestens ein Feld ist ungültig oder zu lang.' }; return { status: 400, error: 'Mindestens ein Feld ist ungültig oder zu lang.' };
return { status: 500, error: 'Die Änderung konnte nicht gespeichert werden.' }; return { status: 500, error: 'Die Änderung konnte nicht gespeichert werden.' };
@@ -118,9 +102,26 @@ function attempt(intent: string, operation: () => unknown) {
} }
export const load: PageServerLoad = ({ locals, params, url }) => { export const load: PageServerLoad = ({ locals, params, url }) => {
requireUser(locals.user); const actor = requireUser(locals.user);
const show = getShow(params.id); const show = getShow(params.id);
if (!show) error(404, 'Sendung nicht gefunden'); if (!show) error(404, 'Sendung nicht gefunden');
const shares = actor.role === 'barkeeper' ? listShares(actor, show.id) : [];
if (show.status === 'completed') {
return {
show,
shares,
canManageShares: actor.role === 'barkeeper',
historical: true,
actualDurations: getHistoricalActualDurations(show.id),
duplicateCommandId: randomUUID(),
startsAtLocal: berlinInput(show.startsAtUtc),
pieces: [],
taxonomy: getTaxonomy(),
pieceTemplates: [],
createCommandId: randomUUID(),
filters: { query: '', categoryId: '', includeUsed: false, includePlanned: false }
};
}
const pieces = listPieces({ const pieces = listPieces({
statuses: ['ready'], statuses: ['ready'],
query: url.searchParams.get('q') ?? '', query: url.searchParams.get('q') ?? '',
@@ -132,6 +133,11 @@ export const load: PageServerLoad = ({ locals, params, url }) => {
); );
return { return {
show, show,
shares,
canManageShares: actor.role === 'barkeeper',
historical: false,
actualDurations: {} as Record<string, number>,
duplicateCommandId: randomUUID(),
startsAtLocal: berlinInput(show.startsAtUtc), startsAtLocal: berlinInput(show.startsAtUtc),
pieces: pieces.map((piece) => ({ ...piece, ...usageContexts[piece.id] })), pieces: pieces.map((piece) => ({ ...piece, ...usageContexts[piece.id] })),
taxonomy: getTaxonomy(), taxonomy: getTaxonomy(),
@@ -147,6 +153,66 @@ export const load: PageServerLoad = ({ locals, params, url }) => {
}; };
export const actions = { export const actions = {
createShare: async ({ locals, params, request }) => {
const actor = requireBarkeeper(locals.user);
const data = await request.formData();
try {
const share = await createShare(actor, {
showId: params.id,
scope: text(data, 'scope'),
password: text(data, 'password'),
expiresAtUtc: berlinLocalToUtc(text(data, 'expiresAt'), 'INVALID_EXPIRY')
});
return {
intent: 'createShare',
success: 'Leselink angelegt. Der Link wird nur jetzt vollständig angezeigt.',
shareUrl: `/freigabe/${share.token}`
};
} catch (cause) {
return fail(actionError(cause).status, { intent: 'createShare', ...actionError(cause) });
}
},
updateShare: async ({ locals, params, request }) => {
const actor = requireBarkeeper(locals.user);
const data = await request.formData();
try {
await updateShare(
actor,
text(data, 'shareId'),
{
scope: text(data, 'scope'),
passwordMode: text(data, 'passwordMode'),
password: text(data, 'password'),
expiresAtUtc: berlinLocalToUtc(text(data, 'expiresAt'), 'INVALID_EXPIRY')
},
params.id
);
return { intent: 'updateShare', success: 'Freigabe aktualisiert.' };
} catch (cause) {
return fail(actionError(cause).status, { intent: 'updateShare', ...actionError(cause) });
}
},
deleteShare: async ({ locals, params, request }) => {
const actor = requireBarkeeper(locals.user);
const data = await request.formData();
try {
deleteShare(actor, text(data, 'shareId'), params.id);
return { intent: 'deleteShare', success: 'Freigabe gelöscht.' };
} catch (cause) {
return fail(actionError(cause).status, { intent: 'deleteShare', ...actionError(cause) });
}
},
duplicateShow: async ({ locals, params, request }) => {
requireUser(locals.user);
const data = await request.formData();
try {
const duplicate = duplicateCompletedShow(params.id, text(data, 'commandId'));
redirect(303, `/sendungen/${duplicate.id}`);
} catch (cause) {
if (cause && typeof cause === 'object' && 'status' in cause) throw cause;
return fail(actionError(cause).status, { intent: 'duplicateShow', ...actionError(cause) });
}
},
saveMetadata: async ({ locals, params, request }) => { saveMetadata: async ({ locals, params, request }) => {
requireUser(locals.user); requireUser(locals.user);
const data = await request.formData(); const data = await request.formData();
+164 -27
View File
@@ -7,6 +7,7 @@
import { onMount, untrack } from 'svelte'; import { onMount, untrack } from 'svelte';
import PieceUsageHistory from '#lib/components/PieceUsageHistory.svelte'; import PieceUsageHistory from '#lib/components/PieceUsageHistory.svelte';
import AppIcon from '#lib/components/AppIcon.svelte'; import AppIcon from '#lib/components/AppIcon.svelte';
import ShareManagement from '#lib/components/ShareManagement.svelte';
import UsageEditorDialog from '#lib/components/UsageEditorDialog.svelte'; import UsageEditorDialog from '#lib/components/UsageEditorDialog.svelte';
import { formatClock, formatDuration } from '#lib/format.js'; import { formatClock, formatDuration } from '#lib/format.js';
import type { PageProps } from './$types'; import type { PageProps } from './$types';
@@ -20,10 +21,10 @@
| null | null
>(null); >(null);
let dropTarget = $state<string | null>(null); let dropTarget = $state<string | null>(null);
let moveSectionForm: HTMLFormElement; let moveSectionForm = $state<HTMLFormElement>();
let moveUsageForm: HTMLFormElement; let moveUsageForm = $state<HTMLFormElement>();
let addUsageForm: HTMLFormElement; let addUsageForm = $state<HTMLFormElement>();
let removeUsageForm: HTMLFormElement; let removeUsageForm = $state<HTMLFormElement>();
let draggedSectionId = $state(''); let draggedSectionId = $state('');
let draggedUsageId = $state(''); let draggedUsageId = $state('');
let draggedPieceId = $state(''); let draggedPieceId = $state('');
@@ -150,7 +151,7 @@
draggedSectionId = dragged.id; draggedSectionId = dragged.id;
dropPosition = position; dropPosition = position;
finishDrag(); finishDrag();
setTimeout(() => moveSectionForm.requestSubmit()); setTimeout(() => moveSectionForm?.requestSubmit());
} }
function dropUsage(sectionId: string | null, position: number) { function dropUsage(sectionId: string | null, position: number) {
@@ -160,12 +161,12 @@
if (dragged.kind === 'piece') { if (dragged.kind === 'piece') {
draggedPieceId = dragged.id; draggedPieceId = dragged.id;
finishDrag(); finishDrag();
setTimeout(() => addUsageForm.requestSubmit()); setTimeout(() => addUsageForm?.requestSubmit());
return; return;
} }
draggedUsageId = dragged.id; draggedUsageId = dragged.id;
finishDrag(); finishDrag();
setTimeout(() => moveUsageForm.requestSubmit()); setTimeout(() => moveUsageForm?.requestSubmit());
} }
function removeDraggedUsage() { function removeDraggedUsage() {
@@ -176,7 +177,7 @@
} }
removedUsageId = dragged.id; removedUsageId = dragged.id;
finishDrag(); finishDrag();
setTimeout(() => removeUsageForm.requestSubmit()); setTimeout(() => removeUsageForm?.requestSubmit());
} }
const enhanceCommand: SubmitFunction = ({ formElement, submitter }) => { const enhanceCommand: SubmitFunction = ({ formElement, submitter }) => {
@@ -205,10 +206,106 @@
<svelte:head><title>{data.show.title} | Sendungen | Sendewerk</title></svelte:head> <svelte:head><title>{data.show.title} | Sendungen | Sendewerk</title></svelte:head>
{#if data.historical}
<div class="page-wrap historical-show-page">
<header class="page-heading show-heading">
<div class="show-heading-copy">
<a class="back-link" href={resolve('/archiv')}><AppIcon name="arrow-left" /> Archiv</a>
<p class="eyebrow">Unveränderliche Sendungshistorie</p>
<h1>{data.show.title}</h1>
<p class="show-date">
{data.show.startsAtUtc ? `${berlinDate(data.show.startsAtUtc)} Uhr` : 'Termin offen'}
</p>
</div>
<div class="show-summary">
<div><strong>{plannedCount}</strong><span>Pieces</span></div>
<div>
<strong>{formatDuration(data.show.totalDurationSeconds)}</strong><span>Planzeit</span>
</div>
<form method="POST" action="?/duplicateShow">
<input type="hidden" name="commandId" value={data.duplicateCommandId} />
<button class="secondary-button" type="submit">Als neue Sendung duplizieren</button>
</form>
</div>
</header>
{#if form?.error && !form.intent?.toLowerCase().includes('share')}<div
class="form-message error"
role="alert"
>
{form.error}
</div>{/if}
{#if data.canManageShares}<ShareManagement
shows={[data.show]}
shares={data.shares}
fixedShowId={data.show.id}
{form}
/>{/if}
<div class="historical-rundown">
{#each data.show.sections as section (section.id)}<section
class="quiet-panel historical-section"
>
<header>
<div>
{#if section.category}<small
style={`--tax-color: ${section.categoryColor ?? '#67dde8'}`}
>{section.category}</small
>{/if}
<h2>{section.name}</h2>
</div>
<strong>{formatDuration(section.durationSeconds)}</strong>
</header>
{#each section.usages as usage (usage.id)}<article class="historical-usage">
<header>
<div>
<small
>{usage.status === 'done'
? 'Gesendet'
: usage.status === 'skipped'
? 'Übersprungen'
: 'Offen beim Abschluss'}</small
>
<h3>{usage.title}</h3>
</div>
<span
>{formatDuration(data.actualDurations[usage.id] ?? 0)} Ist · {formatDuration(
usage.durationSeconds
)} Plan</span
>
</header>
{#if usage.intro}<section>
<strong>Einleitung</strong>
<p>{usage.intro}</p>
</section>{/if}
{#if usage.moderation}<section>
<strong>Moderation</strong>
<p>{usage.moderation}</p>
</section>{/if}
{#if usage.interaction}<section>
<strong>Interaktion</strong>
<p>{usage.interaction}</p>
</section>{/if}
{#if usage.transition}<section>
<strong>Überleitung</strong>
<p>{usage.transition}</p>
</section>{/if}
{#if usage.research}<details>
<summary>Recherche</summary>
<p>{usage.research}</p>
</details>{/if}
{#if usage.ownNotes}<details>
<summary>Eigene Notizen</summary>
<p>{usage.ownNotes}</p>
</details>{/if}
</article>{/each}
</section>{/each}
</div>
</div>
{:else}
<div class="page-wrap builder-page"> <div class="page-wrap builder-page">
<header class="page-heading show-heading"> <header class="page-heading show-heading">
<div class="show-heading-copy"> <div class="show-heading-copy">
<a class="back-link" href={resolve('/sendungen')}><AppIcon name="arrow-left" /> Sendungen</a> <a class="back-link" href={resolve('/sendungen')}><AppIcon name="arrow-left" /> Sendungen</a
>
<h1>{data.show.title}</h1> <h1>{data.show.title}</h1>
<p class="show-date"> <p class="show-date">
{data.show.startsAtUtc ? `${berlinDate(data.show.startsAtUtc)} Uhr` : 'Termin offen'} {data.show.startsAtUtc ? `${berlinDate(data.show.startsAtUtc)} Uhr` : 'Termin offen'}
@@ -225,8 +322,18 @@
</div> </div>
</header> </header>
{#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if} {#if form?.error && !form.intent?.toLowerCase().includes('share')}<div
{#if form?.success}<div class="form-message success" role="status">{form.success}</div>{/if} class="form-message error"
role="alert"
>
{form.error}
</div>{/if}
{#if form?.success && !form.intent?.toLowerCase().includes('share')}<div
class="form-message success"
role="status"
>
{form.success}
</div>{/if}
<div class="sr-only" role="status" aria-live="polite">{copyMessage}</div> <div class="sr-only" role="status" aria-live="polite">{copyMessage}</div>
<details class="show-settings"> <details class="show-settings">
@@ -270,8 +377,9 @@
>Ankündigung<span class="copy-field" >Ankündigung<span class="copy-field"
><textarea name="announcementText" maxlength="20000" rows="4" ><textarea name="announcementText" maxlength="20000" rows="4"
>{data.show.announcementText}</textarea >{data.show.announcementText}</textarea
><button type="button" onclick={() => copyText('Ankündigung', data.show.announcementText)} ><button
>Kopieren</button type="button"
onclick={() => copyText('Ankündigung', data.show.announcementText)}>Kopieren</button
></span ></span
></label ></label
> >
@@ -282,6 +390,12 @@
</div> </div>
</form> </form>
</details> </details>
{#if data.canManageShares}<ShareManagement
shows={[data.show]}
shares={data.shares}
fixedShowId={data.show.id}
{form}
/>{/if}
<div class="mobile-pane-switch" aria-label="Arbeitsbereich auswählen"> <div class="mobile-pane-switch" aria-label="Arbeitsbereich auswählen">
<button <button
@@ -418,7 +532,8 @@
>{/if}{piece.title} >{/if}{piece.title}
</h3> </h3>
<p> <p>
{piece.format} · {piece.topics.map((item) => item.name).join(', ') || 'Ohne Thema'} {piece.format} · {piece.topics.map((item) => item.name).join(', ') ||
'Ohne Thema'}
</p> </p>
{#if piece.isPlannedInCurrentShow}<span class="planned-label">Eingeplant</span>{/if} {#if piece.isPlannedInCurrentShow}<span class="planned-label">Eingeplant</span>{/if}
<PieceUsageHistory history={piece.sentHistory} /> <PieceUsageHistory history={piece.sentHistory} />
@@ -538,7 +653,8 @@
/><input type="hidden" name="position" value={index + 1} /><button /><input type="hidden" name="position" value={index + 1} /><button
type="submit" type="submit"
disabled={index === data.show.sections.length - 1} disabled={index === data.show.sections.length - 1}
aria-label={`${section.name} nach unten`}><AppIcon name="arrow-down" /></button aria-label={`${section.name} nach unten`}
><AppIcon name="arrow-down" /></button
> >
</form> </form>
</div> </div>
@@ -593,11 +709,15 @@
</div> </div>
<div class="row-actions"> <div class="row-actions">
<form use:enhance={enhanceCommand} method="POST" action="?/moveUsage"> <form use:enhance={enhanceCommand} method="POST" action="?/moveUsage">
<input type="hidden" name="revision" value={data.show.revision} /><input <input
type="hidden" type="hidden"
name="usageId" name="revision"
value={usage.id} value={data.show.revision}
/><input type="hidden" name="sectionId" value={section.id} /><input /><input type="hidden" name="usageId" value={usage.id} /><input
type="hidden"
name="sectionId"
value={section.id}
/><input
type="hidden" type="hidden"
name="position" name="position"
value={usageIndex - 1} value={usageIndex - 1}
@@ -609,11 +729,15 @@
> >
</form> </form>
<form use:enhance={enhanceCommand} method="POST" action="?/moveUsage"> <form use:enhance={enhanceCommand} method="POST" action="?/moveUsage">
<input type="hidden" name="revision" value={data.show.revision} /><input <input
type="hidden" type="hidden"
name="usageId" name="revision"
value={usage.id} value={data.show.revision}
/><input type="hidden" name="sectionId" value={section.id} /><input /><input type="hidden" name="usageId" value={usage.id} /><input
type="hidden"
name="sectionId"
value={section.id}
/><input
type="hidden" type="hidden"
name="position" name="position"
value={usageIndex + 1} value={usageIndex + 1}
@@ -637,14 +761,20 @@
</div> </div>
{/each} {/each}
{#if showsUsagePreview(section.id, section.usages.length)} {#if showsUsagePreview(section.id, section.usages.length)}
<div class="drag-insert-preview usage-insert-preview" data-drop-preview="usage"> <div
class="drag-insert-preview usage-insert-preview"
data-drop-preview="usage"
>
<strong>{draggedTitle()}</strong><small>Hier einfügen</small> <strong>{draggedTitle()}</strong><small>Hier einfügen</small>
</div> </div>
{/if} {/if}
</div>{:else} </div>{:else}
{#if showsUsagePreview(section.id, 0)} {#if showsUsagePreview(section.id, 0)}
<div class="usage-list"> <div class="usage-list">
<div class="drag-insert-preview usage-insert-preview" data-drop-preview="usage"> <div
class="drag-insert-preview usage-insert-preview"
data-drop-preview="usage"
>
<strong>{draggedTitle()}</strong><small>Hier einfügen</small> <strong>{draggedTitle()}</strong><small>Hier einfügen</small>
</div> </div>
</div> </div>
@@ -675,7 +805,12 @@
name="sectionId" name="sectionId"
value={section.id} value={section.id}
/><label /><label
>Name<input name="name" required maxlength="200" value={section.name} /></label >Name<input
name="name"
required
maxlength="200"
value={section.name}
/></label
><label ><label
>Kategorie<select name="categoryId" >Kategorie<select name="categoryId"
><option value="">Ohne Kategorie</option ><option value="">Ohne Kategorie</option
@@ -841,7 +976,8 @@
/><button /><button
class="text-button danger-text" class="text-button danger-text"
type="submit" type="submit"
aria-label={`${usage.title} aus Sendung entfernen`}>Aus Sendung entfernen</button aria-label={`${usage.title} aus Sendung entfernen`}
>Aus Sendung entfernen</button
> >
</form> </form>
<UsageEditorDialog <UsageEditorDialog
@@ -921,3 +1057,4 @@
<input type="hidden" name="usageId" value={removedUsageId} /> <input type="hidden" name="usageId" value={removedUsageId} />
</form> </form>
</div> </div>
{/if}
+53
View File
@@ -8,6 +8,9 @@ import {
type TaxonomyKind type TaxonomyKind
} from '#lib/server/content.js'; } from '#lib/server/content.js';
import { getMailConfig } from '#lib/server/config.js'; import { getMailConfig } from '#lib/server/config.js';
import { createShare, deleteShare, listShares, updateShare } from '#lib/server/shares.js';
import { listShows } from '#lib/server/shows.js';
import { berlinLocalToUtc } from '#lib/server/time.js';
import { createTool, listTools, updateTool, type ToolInput } from '#lib/server/tools.js'; import { createTool, listTools, updateTool, type ToolInput } from '#lib/server/tools.js';
import { import {
inviteUser, inviteUser,
@@ -28,6 +31,8 @@ export const load: PageServerLoad = ({ locals }) => {
users: listUsers(), users: listUsers(),
invitations: listOpenInvitations(), invitations: listOpenInvitations(),
tools: listTools(true), tools: listTools(true),
shows: listShows(),
shares: listShares(locals.user!),
mailTransport: getMailConfig().transport mailTransport: getMailConfig().transport
}; };
}; };
@@ -50,6 +55,10 @@ function actionError(cause: unknown): string {
if (code === 'INVALID_TOOL_FORMAT') if (code === 'INVALID_TOOL_FORMAT')
return 'Mindestens ein gewähltes Format existiert nicht mehr.'; return 'Mindestens ein gewähltes Format existiert nicht mehr.';
if (code === 'INVALID_TOOL') return 'Bitte die Werkzeugdaten prüfen.'; if (code === 'INVALID_TOOL') return 'Bitte die Werkzeugdaten prüfen.';
if (code === 'INVALID_PASSWORD') return 'Ein Freigabepasswort benötigt mindestens 12 Zeichen.';
if (code === 'INVALID_EXPIRY') return 'Der Ablaufzeitpunkt muss in der Zukunft liegen.';
if (code === 'INVALID_SCOPE') return 'Der Informationsumfang ist ungültig.';
if (code === 'INVALID_PASSWORD_MODE') return 'Die Passwortoption ist ungültig.';
return 'Die Aktion konnte nicht ausgeführt werden.'; return 'Die Aktion konnte nicht ausgeführt werden.';
} }
@@ -65,6 +74,50 @@ function toolInput(data: FormData): ToolInput {
} }
export const actions = { export const actions = {
createShare: async ({ locals, request }) => {
const actor = requireBarkeeper(locals.user);
const data = await request.formData();
try {
const share = await createShare(actor, {
showId: String(data.get('showId') || ''),
scope: String(data.get('scope') || ''),
password: String(data.get('password') || ''),
expiresAtUtc: berlinLocalToUtc(String(data.get('expiresAt') || ''), 'INVALID_EXPIRY')
});
return {
intent: 'createShare',
success: 'Leselink angelegt. Der Link wird nur jetzt vollständig angezeigt.',
shareUrl: `/freigabe/${share.token}`
};
} catch (cause) {
return fail(400, { intent: 'createShare', error: actionError(cause) });
}
},
updateShare: async ({ locals, request }) => {
const actor = requireBarkeeper(locals.user);
const data = await request.formData();
try {
await updateShare(actor, String(data.get('shareId') || ''), {
scope: String(data.get('scope') || ''),
passwordMode: String(data.get('passwordMode') || ''),
password: String(data.get('password') || ''),
expiresAtUtc: berlinLocalToUtc(String(data.get('expiresAt') || ''), 'INVALID_EXPIRY')
});
return { intent: 'updateShare', success: 'Freigabe aktualisiert.' };
} catch (cause) {
return fail(400, { intent: 'updateShare', error: actionError(cause) });
}
},
deleteShare: async ({ locals, request }) => {
const actor = requireBarkeeper(locals.user);
const data = await request.formData();
try {
deleteShare(actor, String(data.get('shareId') || ''));
return { intent: 'deleteShare', success: 'Freigabe gelöscht.' };
} catch (cause) {
return fail(400, { intent: 'deleteShare', error: actionError(cause) });
}
},
invite: async ({ locals, request }) => { invite: async ({ locals, request }) => {
const actor = requireBarkeeper(locals.user); const actor = requireBarkeeper(locals.user);
const data = await request.formData(); const data = await request.formData();
+14 -2
View File
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import AppIcon from '#lib/components/AppIcon.svelte'; import AppIcon from '#lib/components/AppIcon.svelte';
import IconPicker from '#lib/components/IconPicker.svelte'; import IconPicker from '#lib/components/IconPicker.svelte';
import ShareManagement from '#lib/components/ShareManagement.svelte';
import ToolEditorDialog from '#lib/components/ToolEditorDialog.svelte'; import ToolEditorDialog from '#lib/components/ToolEditorDialog.svelte';
import type { PageProps } from './$types'; import type { PageProps } from './$types';
let { data, form }: PageProps = $props(); let { data, form }: PageProps = $props();
@@ -18,8 +19,19 @@
<h1>Verwaltung</h1> <h1>Verwaltung</h1>
<p class="page-intro">Konten, Werkzeuge und bestehende Taxonomie verwalten.</p> <p class="page-intro">Konten, Werkzeuge und bestehende Taxonomie verwalten.</p>
</header> </header>
{#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if} {#if form?.error && !form.intent?.toLowerCase().includes('share')}<div
{#if form?.success}<div class="form-message success" role="status">{form.success}</div>{/if} class="form-message error"
role="alert"
>
{form.error}
</div>{/if}
{#if form?.success && !form.intent?.toLowerCase().includes('share')}<div
class="form-message success"
role="status"
>
{form.success}
</div>{/if}
<ShareManagement shows={data.shows} shares={data.shares} {form} />
<section class="quiet-panel account-panel"> <section class="quiet-panel account-panel">
<div class="panel-heading"> <div class="panel-heading">
+288 -7
View File
@@ -1,6 +1,6 @@
import { expect, test, type Locator, type Page } from '@playwright/test'; import { expect, test, type Locator, type Page } from '@playwright/test';
import Database from 'better-sqlite3'; import Database from 'better-sqlite3';
import { readFileSync, readdirSync } from 'node:fs'; import { mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
import path from 'node:path'; import path from 'node:path';
const adminEmail = 'barkeeper@example.test'; const adminEmail = 'barkeeper@example.test';
@@ -85,7 +85,8 @@ function addSentHistoryFixture(pieceId: string) {
database.pragma('foreign_keys = ON'); database.pragma('foreign_keys = ON');
const insertShow = database.prepare( const insertShow = database.prepare(
`INSERT INTO shows (id, title, starts_at_utc, twitch_title, announcement_text, status, `INSERT INTO shows (id, title, starts_at_utc, twitch_title, announcement_text, status,
revision, created_at_utc, updated_at_utc) VALUES (?, ?, ?, '', '', 'completed', 1, ?, ?)` revision, created_at_utc, updated_at_utc, completed_at_utc)
VALUES (?, ?, ?, '', '', 'completed', 1, ?, ?, ?)`
); );
const insertSection = database.prepare( const insertSection = database.prepare(
`INSERT INTO show_sections (id, show_id, name, category_id, position, `INSERT INTO show_sections (id, show_id, name, category_id, position,
@@ -104,9 +105,24 @@ function addSentHistoryFixture(pieceId: string) {
] as const) { ] as const) {
const showId = `e2e-history-${suffix}`; const showId = `e2e-history-${suffix}`;
const sectionId = `e2e-history-section-${suffix}`; const sectionId = `e2e-history-section-${suffix}`;
insertShow.run(showId, title, date, date, date); insertShow.run(showId, title, date, date, date, date);
insertSection.run(sectionId, showId); insertSection.run(sectionId, showId);
insertUsage.run(`e2e-history-usage-${suffix}`, sectionId, pieceId); const usageId = `e2e-history-usage-${suffix}`;
insertUsage.run(usageId, sectionId, pieceId);
database
.prepare(
`INSERT INTO show_finalizations
(show_id, command_id, source_revision, finalized_at_utc) VALUES (?, ?, 1, ?)`
)
.run(showId, `fixture-${suffix}`, date);
database
.prepare(
`INSERT INTO piece_usage_history
(usage_id, show_id, original_piece_id, show_title_snapshot,
usage_title_snapshot, used_at_utc, actual_duration_seconds)
VALUES (?, ?, ?, ?, 'Historische Verwendung', ?, 300)`
)
.run(usageId, showId, pieceId, title, date);
} }
})(); })();
database.close(); database.close();
@@ -117,10 +133,120 @@ function removeSentHistoryFixture() {
if (!dataDirectory) return; if (!dataDirectory) return;
const database = new Database(path.join(dataDirectory, 'e2e.sqlite')); const database = new Database(path.join(dataDirectory, 'e2e.sqlite'));
database.pragma('foreign_keys = ON'); database.pragma('foreign_keys = ON');
database.prepare("DELETE FROM piece_usage_history WHERE show_id LIKE 'e2e-history-%'").run();
database.prepare("DELETE FROM shows WHERE id LIKE 'e2e-history-%'").run(); database.prepare("DELETE FROM shows WHERE id LIKE 'e2e-history-%'").run();
database.close(); database.close();
} }
function addCompletionFixture() {
const dataDirectory = process.env.SENDEWERK_E2E_DATA_DIR;
if (!dataDirectory) throw new Error('E2E-Datenverzeichnis ist nicht konfiguriert.');
const database = new Database(path.join(dataDirectory, 'e2e.sqlite'));
database.pragma('foreign_keys = ON');
const now = '2026-10-25T19:00:00.000Z';
const png = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
'base64'
);
mkdirSync(path.join(dataDirectory, 'uploads'), { recursive: true });
writeFileSync(path.join(dataDirectory, 'uploads', 'e2e-share.png'), png);
database.transaction(() => {
database
.prepare(
`INSERT INTO shows (id, title, starts_at_utc, twitch_title, announcement_text, status,
revision, created_at_utc, updated_at_utc)
VALUES ('e2e-completion', 'E2E Abschluss', ?, '', '', 'live', 1, ?, ?)`
)
.run(now, now, now);
database
.prepare(
`INSERT INTO show_sections (id, show_id, name, category_id, position,
explicit_duration_seconds, is_reserve_bank) VALUES
('e2e-completion-section', 'e2e-completion', 'Finaler Ablauf', NULL, 0, NULL, 0),
('e2e-completion-reserve', 'e2e-completion', 'Reservebank', NULL, 1, NULL, 1)`
)
.run();
const insertUsage = database.prepare(
`INSERT INTO piece_usages (id, show_section_id, original_piece_id, position, status,
duration_seconds, title_snapshot, intro_snapshot, research_snapshot, moderation_snapshot,
interaction_snapshot, transition_snapshot, presentation_snapshot, own_notes, is_reserve)
VALUES (?, 'e2e-completion-section', ?, ?, ?, 300, ?, ?, ?, ?, ?, ?, '', ?, 0)`
);
insertUsage.run(
'e2e-completion-done',
'piece-console',
0,
'done',
'Gesendeter Abschlussbeitrag',
'FREIGABE_EINLEITUNG',
'PRIVATE_FREIGABE_RECHERCHE',
'FREIGABE_MODERATION',
'FREIGABE_INTERAKTION',
'FREIGABE_UEBERLEITUNG',
'PRIVATE_FREIGABE_NOTIZ'
);
insertUsage.run(
'e2e-completion-skipped',
'piece-effects',
1,
'skipped',
'Übersprungener Abschlussbeitrag',
'',
'PRIVATE_SKIPPED_RESEARCH',
'',
'',
'',
''
);
database
.prepare(
`INSERT INTO show_live_states
(show_id, revision, started_at_utc, ended_at_utc, presentation_revision, updated_at_utc)
VALUES ('e2e-completion', 0, ?, ?, 0, ?)`
)
.run('2026-10-25T18:00:00.000Z', now, now);
database
.prepare(
`INSERT INTO piece_media
(id, piece_id, storage_name, original_name, mime_type, byte_size, alt_text, position, created_at_utc)
VALUES ('e2e-share-media', 'piece-console', 'e2e-share.png', 'intern.png',
'image/png', ?, 'Freigegebenes Abschlussbild', 77, ?)`
)
.run(png.byteLength, now);
database
.prepare(
`INSERT INTO usage_media_snapshots
(id, usage_id, original_media_id, alt_text, position, is_presentation)
VALUES ('e2e-share-snapshot', 'e2e-completion-done', 'e2e-share-media',
'Freigegebenes Abschlussbild', 0, 1)`
)
.run();
})();
database.close();
}
function removeCompletionFixture() {
const dataDirectory = process.env.SENDEWERK_E2E_DATA_DIR;
if (!dataDirectory) return;
const database = new Database(path.join(dataDirectory, 'e2e.sqlite'));
database.pragma('foreign_keys = ON');
database.transaction(() => {
database
.prepare(
`DELETE FROM show_share_sessions WHERE share_id IN (
SELECT id FROM show_shares WHERE show_id = 'e2e-completion'
)`
)
.run();
database.prepare("DELETE FROM show_shares WHERE show_id = 'e2e-completion'").run();
database.prepare("DELETE FROM piece_usage_history WHERE show_id = 'e2e-completion'").run();
database.prepare("DELETE FROM shows WHERE id = 'e2e-completion'").run();
database.prepare("DELETE FROM piece_media WHERE id = 'e2e-share-media'").run();
})();
database.close();
rmSync(path.join(dataDirectory, 'uploads', 'e2e-share.png'), { force: true });
}
function addPresentationFixture() { function addPresentationFixture() {
const dataDirectory = process.env.SENDEWERK_E2E_DATA_DIR; const dataDirectory = process.env.SENDEWERK_E2E_DATA_DIR;
if (!dataDirectory) throw new Error('E2E-Datenverzeichnis ist nicht konfiguriert.'); if (!dataDirectory) throw new Error('E2E-Datenverzeichnis ist nicht konfiguriert.');
@@ -204,10 +330,10 @@ function addCalendarFixture() {
`INSERT INTO shows (id, title, starts_at_utc, twitch_title, announcement_text, status, `INSERT INTO shows (id, title, starts_at_utc, twitch_title, announcement_text, status,
revision, created_at_utc, updated_at_utc) VALUES (?, ?, ?, '', '', 'draft', 1, ?, ?)` revision, created_at_utc, updated_at_utc) VALUES (?, ?, ?, '', '', 'draft', 1, ?, ?)`
); );
const now = '2026-10-24T10:00:00.000Z'; const now = '2026-10-25T10:00:00.000Z';
database.transaction(() => { database.transaction(() => {
insert.run('e2e-calendar-late', 'Kalender Spättermin', '2026-10-24T18:00:00.000Z', now, now); insert.run('e2e-calendar-late', 'Kalender Spättermin', '2026-10-25T18:00:00.000Z', now, now);
insert.run('e2e-calendar-early', 'Kalender Frühtermin', '2026-10-24T08:00:00.000Z', now, now); insert.run('e2e-calendar-early', 'Kalender Frühtermin', '2026-10-25T09:00:00.000Z', now, now);
insert.run( insert.run(
'e2e-calendar-next', 'e2e-calendar-next',
'Kalender Novembertermin', 'Kalender Novembertermin',
@@ -1532,6 +1658,161 @@ test('live control persists state and exposes only explicit presentation data',
await expectNoHorizontalOverflow(page); await expectNoHorizontalOverflow(page);
}); });
test('completion, archive and protected guest sharing work end to end', async ({
page,
browser
}, testInfo) => {
addCompletionFixture();
try {
await page.goto('/sendungen/e2e-completion');
const planningShares = page.locator('.share-management');
await expect(planningShares.getByText('Aktualisiert sich bis zum Abschluss')).toBeVisible();
await planningShares.getByLabel('Informationsumfang').selectOption('moderation');
await planningShares.getByRole('button', { name: 'Leselink anlegen' }).click();
const planningSharePath = await planningShares.locator('.share-result a').getAttribute('href');
expect(planningSharePath).toMatch(/^\/freigabe\//);
await page.screenshot({
path: testInfo.outputPath('share-editor-desktop.png'),
fullPage: true
});
await page.setViewportSize({ width: 390, height: 844 });
await expectNoHorizontalOverflow(page);
await page.screenshot({ path: testInfo.outputPath('share-editor-mobile.png'), fullPage: true });
await page.setViewportSize({ width: 1280, height: 900 });
const planningGuest = await browser.newContext();
const planningGuestPage = await planningGuest.newPage();
await planningGuestPage.goto(planningSharePath!);
await expect(planningGuestPage.getByText('Aktuelle Sendungsplanung · Nur lesen')).toBeVisible();
await expect(planningGuestPage.getByText('Gesendeter Abschlussbeitrag')).toBeVisible();
await expect(planningGuestPage.getByText('FREIGABE_MODERATION')).toBeVisible();
const guestThemePreference = await planningGuestPage
.locator('html')
.getAttribute('data-theme-preference');
await planningGuestPage.getByRole('button', { name: /Darstellung:/ }).click();
await expect(planningGuestPage.locator('html')).not.toHaveAttribute(
'data-theme-preference',
guestThemePreference!
);
const planningShareRow = planningShares.locator('.share-row').first();
await planningShareRow.locator('summary').getByText('Ablauf und Moderation').click();
await planningShareRow.getByLabel('Informationsumfang').selectOption('research');
await planningShareRow.locator('select[name="passwordMode"]').selectOption('replace');
await planningShareRow.locator('input[name="password"]').fill('Planung-Freigabe-Passwort');
await planningShareRow.getByRole('button', { name: 'Freigabe speichern' }).click();
await planningGuestPage.reload();
await expect(
planningGuestPage.getByRole('heading', { name: 'Passwort erforderlich' })
).toBeVisible();
page.once('dialog', (dialog) => dialog.accept());
await planningShares
.locator('.share-row')
.first()
.locator('summary')
.getByText('Vollständig mit Recherche')
.click();
await planningShares.getByRole('button', { name: 'Freigabe löschen' }).click();
await planningGuestPage.reload();
await expect(planningGuestPage.getByRole('heading', { name: 'Nicht verfügbar' })).toBeVisible();
await planningGuest.close();
await page.goto('/live?show=e2e-completion');
await page.getByRole('button', { name: 'Menü', exact: true }).click();
await page.getByRole('button', { name: 'Sendung abschließen' }).click();
const completion = page.locator('.completion-dialog');
await expect(completion).toBeVisible();
await expect(completion).toContainText('1 gesendet');
await expect(completion).toContainText('1 übersprungen');
await expect(completion.getByText('Die kleine Konsole mit dem großen Dreh')).toBeVisible();
await expect(completion.getByText('Warum praktische Effekte wieder auffallen')).toHaveCount(0);
await completion.getByRole('button', { name: 'Historischen Stand abschließen' }).click();
await expect(completion).toBeHidden();
await page.goto('/archiv');
await expect(page.getByRole('link', { name: 'E2E Abschluss' })).toBeVisible();
await page.getByRole('link', { name: 'E2E Abschluss' }).click();
await expect(page.getByText('Unveränderliche Sendungshistorie')).toBeVisible();
await expect(page.getByText('Gesendeter Abschlussbeitrag')).toBeVisible();
await expect(page.getByText('Übersprungener Abschlussbeitrag')).toBeVisible();
await expect(page.getByRole('button', { name: 'Sendungsdaten speichern' })).toHaveCount(0);
await page.goto('/verwaltung');
const shareManagement = page.locator('.share-management');
await shareManagement.getByLabel('Sendung').selectOption('e2e-completion');
await shareManagement.getByLabel('Informationsumfang').selectOption('moderation');
await shareManagement.getByLabel('Optionales Passwort').fill('E2E-Freigabe-Passwort');
await shareManagement.getByRole('button', { name: 'Leselink anlegen' }).click();
const shareLink = page.locator('.share-result a');
await expect(shareLink).toBeVisible();
const sharePath = await shareLink.getAttribute('href');
expect(sharePath).toMatch(/^\/freigabe\//);
await page.screenshot({ path: testInfo.outputPath('share-admin-desktop.png'), fullPage: true });
const guest = await browser.newContext();
const guestPage = await guest.newPage();
await guestPage.goto(sharePath!);
await expect(guestPage.getByRole('heading', { name: 'Passwort erforderlich' })).toBeVisible();
const passwordHtml = await guestPage.content();
expect(passwordHtml).not.toContain('password_hash');
expect(passwordHtml).not.toContain('e2e-completion');
await guestPage.getByLabel('Passwort').fill('falsch');
await guestPage.getByRole('button', { name: 'Sendung öffnen' }).click();
await expect(guestPage.getByRole('alert')).toContainText('Passwort falsch');
await guestPage.getByLabel('Passwort').fill('E2E-Freigabe-Passwort');
await guestPage.getByRole('button', { name: 'Sendung öffnen' }).click();
await expect(guestPage.getByRole('heading', { name: 'E2E Abschluss' })).toBeVisible();
await expect(guestPage.getByText('FREIGABE_MODERATION')).toBeVisible();
await expect(guestPage.getByText('PRIVATE_FREIGABE_RECHERCHE')).toHaveCount(0);
await expect(guestPage.getByText('PRIVATE_FREIGABE_NOTIZ')).toHaveCount(0);
await expect(guestPage.getByAltText('Freigegebenes Abschlussbild')).toBeVisible();
await guestPage.setViewportSize({ width: 390, height: 844 });
await expectNoHorizontalOverflow(guestPage);
await guestPage.screenshot({ path: testInfo.outputPath('share-mobile.png'), fullPage: true });
const imagePath = await guestPage
.getByAltText('Freigegebenes Abschlussbild')
.getAttribute('src');
const sharedImage = await guest.request.get(imagePath!);
expect(sharedImage.status()).toBe(200);
expect(sharedImage.headers()['content-security-policy']).toBe("default-src 'none'; sandbox");
expect((await guest.request.get(`${sharePath}/media/foreign-id`)).status()).toBe(404);
const guestWrite = await guest.request.post('/sendungen/e2e-completion?/saveMetadata', {
maxRedirects: 0,
form: { title: 'Verboten', revision: '2', status: 'draft' }
});
expect(guestWrite.status()).toBe(403);
await page.goto('/verwaltung');
const showCard = page.locator('.share-show-card').filter({ hasText: 'E2E Abschluss' });
await expect(showCard.getByText('Abgeschlossen')).toBeVisible();
const shareRow = showCard.locator('.share-row').first();
await shareRow.locator('summary').getByText('Ablauf und Moderation').click();
await shareRow.getByLabel('Informationsumfang').selectOption('rundown');
await shareRow.locator('select[name="passwordMode"]').selectOption('remove');
await shareRow.getByRole('button', { name: 'Freigabe speichern' }).click();
await guestPage.reload();
await expect(guestPage.getByRole('heading', { name: 'E2E Abschluss' })).toBeVisible();
await expect(guestPage.getByText('FREIGABE_MODERATION')).toHaveCount(0);
page.once('dialog', (dialog) => dialog.accept());
const updatedCard = page.locator('.share-show-card').filter({ hasText: 'E2E Abschluss' });
await updatedCard
.locator('.share-row')
.first()
.locator('summary')
.getByText('Nur Ablauf')
.click();
await updatedCard.getByRole('button', { name: 'Freigabe löschen' }).click();
await guestPage.reload();
await expect(guestPage.getByRole('heading', { name: 'Nicht verfügbar' })).toBeVisible();
expect((await guest.request.get(imagePath!)).status()).toBe(404);
await guest.close();
} finally {
removeCompletionFixture();
}
});
test('pieces autosave safely, detect conflicts and protect uploaded images', async ({ test('pieces autosave safely, detect conflicts and protect uploaded images', async ({
page, page,
browser browser