Phase 6 abgeschlossen: Aktenchat, Kanäle, Echtzeit, Benachrichtigungen

Ein Ereignisstrom mit Themen (/api/stream), in dem der Dateistrom aus Phase 5
aufgegangen ist. Zwei Prüfungen: beim Verbinden, welche Themen der Nutzer
abonnieren darf, und je Ereignis wird der Inhalt mit seinen Rechten geladen.
Das zweite ist nicht nur Vorsicht — eine Umklassifizierung wirkt dadurch in
einer offenen Verbindung sofort. In der pg_notify-Nutzlast steht nie ein
Inhalt, nur Thema, Art und Kennung.

Der Aktenchat sendet optimistisch mit clientseitiger UUIDv7, die zugleich
Idempotenzschlüssel ist. Antworten, vier feste Reaktionen, Bearbeiten mit
sichtbarem editedAt, Zurückziehen mit Platzhalter — Löschen gibt es nicht, und
eine Regel in der Datenbank hält das fest. Eine zurückgezogene Nachricht kommt
ohne Text an, nicht ausgeblendet.

Aktenzeichen im Text werden zu Vorschaukarten, serverseitig aufgelöst. Eine
Akte, die der Leser nicht sehen darf, sieht darin genauso aus wie eine, die es
gar nicht gibt.

"Als Vermerk übernehmen" steht sichtbar an jeder Nachricht. Der Vermerk gehört
dem Verfasser, nicht dem Übernehmenden; ein Systemeintrag nennt den Ursprung,
damit der Wortlaut später nicht für die Akte geschrieben wirkt.

Bei den Discord-Benachrichtigungen entscheidet die Web-App, wer was erfährt,
und der Worker liefert nur aus. Die Alternative hätte geheissen, die
Sichtbarkeitsprüfung im Worker ein zweites Mal zu bauen. In meta steht ein
Titel nur, wenn er hinaus darf — ab Stufe 3 keiner. Postausgang als Tabelle,
angestossen über LISTEN, mit Takt als Sicherheitsnetz.

Nebenbei riss das Budget der Fallakte erneut (122 kB). Drei Schritte zurück
auf 119,6: Chattexte in eine eigene i18n-Datei, ein gemeinsames AddPanel statt
viermal derselbe Aufklapp-Code, und eine eigene UUIDv7 statt des Pakets — die
Sortierbarkeit, auf der die Nachlieferung beruht, steht damit im eigenen Code
und hat Tests.

Unterwegs habe ich NotifyMode und User.notifyMode ein zweites Mal angelegt,
obwohl es beides seit dem Grundgerüst gibt. Die Migration schlug fehl und die
Datenbank musste von Hand aufgeräumt werden; die Lehre steht in docs/13.

155 Tests im Web-Paket, 15 in der Datenbank, 7 im Worker, 32 e2e.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Harlekin
2026-09-02 21:06:03 +02:00
co-authored by Claude Opus 5
parent 5f58d9b96f
commit 34a8b96dd3
61 changed files with 3883 additions and 518 deletions
+2
View File
@@ -37,6 +37,8 @@ DISCORD_GUILD_ID=
# Kanal, in den der Worker Start, Verbindungsabbruch und fehlgeschlagenen # Kanal, in den der Worker Start, Verbindungsabbruch und fehlgeschlagenen
# Voll-Abgleich meldet. Das ist die eigentliche Alarmierung. # Voll-Abgleich meldet. Das ist die eigentliche Alarmierung.
DISCORD_ADMIN_CHANNEL_ID= DISCORD_ADMIN_CHANNEL_ID=
# Takt des Postausgangs als Sicherheitsnetz; angestossen wird er ueber LISTEN.
WORKER_OUTBOX_MS=30000
# --- Notfallzugang ------------------------------------------------------------ # --- Notfallzugang ------------------------------------------------------------
# Eine oder zwei Discord-IDs, kommagetrennt. Diese Nutzer haben immer Stufe 5 # Eine oder zwei Discord-IDs, kommagetrennt. Diese Nutzer haben immer Stufe 5
+9 -8
View File
@@ -104,14 +104,15 @@ Das ist der schwierigste Teil. Wenn er steht, ist der Rest Formularbau.
## Phase 6 — Chat und Echtzeit ## Phase 6 — Chat und Echtzeit
- [ ] SSE-Endpunkt mit `LISTEN/NOTIFY`, Heartbeat, Reconnect mit `Last-Event-ID` - [x] SSE-Endpunkt mit `LISTEN/NOTIFY`, Heartbeat, Reconnect mit `Last-Event-ID`
- [ ] Aktenchat mit Antworten, Reaktionen, Bearbeiten, Zurückziehen - [x] Aktenchat mit Antworten, Reaktionen, Bearbeiten, Zurückziehen
- [ ] Optimistisches Senden mit Dedupe über Client-ID - [x] Optimistisches Senden mit Dedupe über Client-ID
- [ ] Aktenzeichen-Erkennung mit serverseitig aufgelöster Vorschaukarte - [x] Aktenzeichen-Erkennung mit serverseitig aufgelöster Vorschaukarte
- [ ] Ungelesen-Markierung und Trennlinie - [x] Ungelesen-Markierung und Trennlinie
- [ ] Freie Kanäle mit eigener Mindeststufe - [x] Freie Kanäle mit eigener Mindeststufe
- [ ] „Als Vermerk übernehmen" - [x] „Als Vermerk übernehmen"
- [ ] Discord-Benachrichtigungen über den Worker, ohne Inhalte - [x] Discord-Benachrichtigungen über den Worker, ohne Inhalte
- [x] Benachrichtigungseinstellung je Nutzer: alles / meine Fälle / Erwähnungen / aus
## Phase 7 — Suche ## Phase 7 — Suche
+98
View File
@@ -550,3 +550,101 @@ importieren den Bereich, den sie brauchen.
Das brachte die grösste Route von 121 auf 118 kB und wächst nicht mehr mit jedem neuen Das brachte die grösste Route von 121 auf 118 kB und wächst nicht mehr mit jedem neuen
Bereich mit. Der Zuschnitt der sechs Gruppen folgt der gemeinsamen Verwendung, nicht der Bereich mit. Der Zuschnitt der sechs Gruppen folgt der gemeinsamen Verwendung, nicht der
Grösse: was zusammen gebraucht wird, liegt zusammen. Grösse: was zusammen gebraucht wird, liegt zusammen.
## Chat und Echtzeit
### Ein Strom, Themen darin
`docs/06` nennt `GET /api/stream?topics=case:<id>,notifications`. Gebaut wie beschrieben, mit
den Themen `case:<id>`, `channel:<id>` und `user:<id>`. Der Ereignisstrom aus Phase 5
(`/api/events`, nur Dateiprüfungen) ist darin aufgegangen.
**Zwei Prüfungen, beide nötig.** Beim Verbinden wird gefiltert, welche der angefragten Themen
der Nutzer überhaupt abonnieren darf — wer ein fremdes nennt, bekommt es nicht und erfährt
auch nicht, ob es existiert. Und je Ereignis wird der Inhalt **mit den Rechten des Empfängers**
geladen. Das zweite ist nicht nur Vorsicht: eine Umklassifizierung mitten in einer offenen
Verbindung wirkt dadurch sofort, ohne dass jemand neu laden muss.
In der `pg_notify`-Nutzlast steht deshalb nie ein Inhalt, nur Thema, Art und Kennung. Ein Text
darin wäre an jedem Filter vorbei.
### Die Vorschaukarte ist der heikelste Ort im Chat
Ein Aktenzeichen im Fliesstext wird zur Karte. Sie wird **serverseitig** aufgelöst, und eine
Akte, die der Leser nicht sehen darf, sieht darin genauso aus wie eine, die es gar nicht gibt.
Nie den Titel mitschicken und im Client ausblenden — das ist der Fehler, den ein Aktensystem
genau einmal macht.
Dasselbe gilt für nachkommende Nachrichten: der Strom-Endpunkt löst die Karten je Empfänger
auf, nicht der Absender.
### Nachrichten werden zurückgezogen, nicht gelöscht
Der Chat ist ausdrücklich kein Aktenbestandteil, deshalb darf hier bearbeitet werden, ohne dass
eine Version entsteht. Eine Nachricht, auf die jemand geantwortet hat, darf trotzdem nicht
spurlos verschwinden: `withdrawnAt` setzt einen Platzhalter, und eine Regel in der Datenbank
verbietet `DELETE`. Autor, Zeitpunkt und Ort sind per Trigger unveränderlich.
Eine zurückgezogene Nachricht kommt **ohne Text** in der Oberfläche an. Nicht ausgeblendet —
gar nicht erst geschickt.
### Die Web-App entscheidet, der Worker liefert
Bei den Discord-Benachrichtigungen war die naheliegende Bauart, dem Worker die Kennungen zu
geben und ihn nachschlagen zu lassen. Das hiesse, die Sichtbarkeitsprüfung ein zweites Mal zu
bauen — und sie zweimal richtig zu haben, dauerhaft.
Stattdessen entscheidet die Web-App beim Einstellen in den Postausgang, wer die Meldung bekommt
(eine Zeile je Empfänger) und was darin stehen darf. In `meta` liegt ein Titel nur, wenn er
auch nach Discord darf; ab Stufe 3 liegt dort keiner. Der Worker setzt aus festen Bruchstücken
einen Satz zusammen und hat keine Entscheidung mehr zu treffen.
Der Postausgang ist eine Tabelle, kein Aufruf: der Worker ist ein eigener Prozess und darf
gerade neu starten. Eine Zeile, die nicht rausging, bleibt liegen. Angestossen wird er über
`notification_queued`, der 30-Sekunden-Takt ist das Sicherheitsnetz — kein Einmal-Container,
siehe harte Regel 8.
Die Empfängerprüfung ruft `visibleCaseWhere` je Kandidat auf, also eine Abfrage pro Person.
Bei zehn bis fünfzig Leuten ist das billiger als der Fehler, die Regel ein zweites Mal zu
schreiben und eine der beiden Fassungen später zu vergessen.
### Eigene UUIDv7 statt Paket
Das Paket `uuidv7` wog 2,6 kB im Bündel der Fallakte. Ersetzt durch `web/lib/uuidv7.ts`, rund
sechzig Zeilen, mit dem Zähler in `rand_a` für die Reihenfolge innerhalb einer Millisekunde.
Der Grund ist nicht nur die Grösse: auf der **lexikografischen Sortierbarkeit** dieser
Kennungen beruhen zwei Dinge — die Nachlieferung verpasster Nachrichten (`id > seit`) und die
Reihenfolge im Chat ohne Sortierspalte. Diese Zusicherung steht jetzt ausdrücklich im eigenen
Code und hat Tests, statt in einer Abhängigkeit vorausgesetzt zu werden.
## Das Bündelbudget der Fallakte
Die Fallakte ist die dichteste Seite der Anwendung: Abschnitte, Verlauf, Beteiligte,
Verknüpfungen, Dokumente, Anhänge, Chat. Mit Phase 6 riss sie das Budget aus `docs/09`
(122 kB gegen 120 kB erlaubte) und wurde in drei Schritten darunter gebracht:
| Schritt | Ersparnis |
|---|---|
| Chattexte aus `lib/i18n/cases.ts` in eine eigene Datei | ~0,5 kB |
| Gemeinsames `AddPanel` statt viermal derselbe Aufklapp-Code | ~1 kB |
| Eigene UUIDv7 statt Paket | ~2,6 kB |
Sie liegt jetzt bei **119,6 kB**. Das ist knapp, und die Richtung stimmt nicht: jede weitere
Insel auf dieser Seite kostet wieder. Der nächste Schritt, wenn es eng wird, ist kein weiteres
Kürzen, sondern die Seite zu teilen — die Beteiligten, Verknüpfungen und Anhänge gehören
fachlich zusammen und könnten hinter einen Reiter.
Der Chat wird bereits erst geladen, wenn er in Sichtweite kommt. Das zählt Next zwar weiter
zur ersten Ladung, spart aber wirklich etwas: wer nur die Akte liest, baut keine offene
Verbindung zum Ereignisstrom auf.
## Beim Erweitern des Schemas zuerst nachsehen
In dieser Phase wurden `NotifyMode` und `User.notifyMode` ein zweites Mal angelegt — es gab
beides schon seit dem Grundgerüst. Die Migration scheiterte, und die Datenbank stand halb
geändert da; das Aufräumen war von Hand nötig.
Die Lehre steht hier, weil sie sich wiederholen wird: **vor jedem neuen Modell, Enum oder Feld
im Schema nachsehen, ob es das schon gibt.** Das Schema ist über achthundert Zeilen lang, und
der Teil, den man gerade nicht bearbeitet, ist der, den man vergisst.
@@ -0,0 +1,81 @@
-- Phase 6: Chat und Echtzeit.
-- ===========================================================================
-- 1. Erwähnungen an der Nachricht
--
-- Beim Senden aufgelöst und gespeichert, nicht beim Lesen aus dem Text
-- geparst: der Worker braucht sie für die Benachrichtigungen, und er soll
-- den Text gar nicht erst sehen.
-- ===========================================================================
ALTER TABLE "ChatMessage"
ADD COLUMN "mentions" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[];
CREATE INDEX "ChatMessage_mentions_idx" ON "ChatMessage" USING GIN ("mentions");
-- ===========================================================================
-- 2. Chatnachrichten sind zurückziehbar, nicht löschbar
--
-- Dieselbe Zusicherung wie beim Verlaufseintrag, eine Stufe schwächer: der
-- Chat ist ausdrücklich kein Aktenbestandteil, aber eine Nachricht, auf die
-- jemand geantwortet hat, darf nicht spurlos verschwinden.
-- ===========================================================================
CREATE RULE chat_no_delete AS ON DELETE TO "ChatMessage" DO INSTEAD NOTHING;
-- Autor, Zeitpunkt und Ort einer Nachricht sind unveränderlich. Wer etwas
-- anderes sagen will, zieht zurück und schreibt neu.
CREATE OR REPLACE FUNCTION chat_immutable_guard() RETURNS trigger AS $$
BEGIN
IF NEW."authorId" IS DISTINCT FROM OLD."authorId"
OR NEW."createdAt" IS DISTINCT FROM OLD."createdAt"
OR NEW."caseId" IS DISTINCT FROM OLD."caseId"
OR NEW."channelId" IS DISTINCT FROM OLD."channelId" THEN
RAISE EXCEPTION 'authorId, createdAt und der Ort einer ChatMessage sind unveränderlich';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER chat_immutable_guard_trg
BEFORE UPDATE ON "ChatMessage"
FOR EACH ROW EXECUTE FUNCTION chat_immutable_guard();
-- ===========================================================================
-- 3. Benachrichtigungen
-- ===========================================================================
-- NotifyMode und User.notifyMode gibt es seit dem Grundgeruest, sie werden hier
-- nur benutzt. Siehe docs/06-chat-und-realtime.md.
CREATE TABLE "NotificationOutbox" (
"id" BIGSERIAL NOT NULL,
"kind" TEXT NOT NULL,
"caseId" TEXT,
"channelId" TEXT,
"recipientId" TEXT,
"actorId" TEXT NOT NULL,
"meta" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"sentAt" TIMESTAMP(3),
"attempts" INTEGER NOT NULL DEFAULT 0,
"lastError" TEXT,
CONSTRAINT "NotificationOutbox_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "NotificationOutbox_sentAt_createdAt_idx"
ON "NotificationOutbox"("sentAt", "createdAt");
CREATE INDEX "NotificationOutbox_caseId_idx" ON "NotificationOutbox"("caseId");
-- Der Worker wartet nicht auf seinen Takt, wenn etwas anliegt.
CREATE OR REPLACE FUNCTION notify_outbox() RETURNS trigger AS $$
BEGIN
PERFORM pg_notify('notification_queued', NEW."id"::text);
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER notification_outbox_notify
AFTER INSERT ON "NotificationOutbox"
FOR EACH ROW EXECUTE FUNCTION notify_outbox();
+41
View File
@@ -41,10 +41,13 @@ model User {
firstSeenAt DateTime @default(now()) firstSeenAt DateTime @default(now())
lastSeenAt DateTime @default(now()) lastSeenAt DateTime @default(now())
/// Wie viel dieser Nutzer per Discord erfahren will.
/// Siehe docs/06-chat-und-realtime.md.
notifyMode NotifyMode @default(OWN_CASES) notifyMode NotifyMode @default(OWN_CASES)
roles UserRole[] roles UserRole[]
sessions Session[] sessions Session[]
personnelFiles PersonnelFile[] personnelFiles PersonnelFile[]
participations CaseParticipant[] participations CaseParticipant[]
assignments PersonnelAssignment[] assignments PersonnelAssignment[]
@@ -55,9 +58,13 @@ model User {
@@index([isActive, displayName]) @@index([isActive, displayName])
} }
/// Was ein Nutzer per Discord erfahren will.
enum NotifyMode { enum NotifyMode {
/// jede Aenderung in jeder Akte, die er sehen darf
ALL ALL
/// nur Akten, an denen er beteiligt ist oder die er beobachtet
OWN_CASES OWN_CASES
/// nur direkte Erwaehnungen
MENTIONS MENTIONS
OFF OFF
} }
@@ -720,6 +727,10 @@ model ChatMessage {
withdrawnAt DateTime? withdrawnAt DateTime?
/// Gesetzt, sobald die Nachricht als Vermerk uebernommen wurde. /// Gesetzt, sobald die Nachricht als Vermerk uebernommen wurde.
adoptedEntryId String? adoptedEntryId String?
/// Erwaehnte Nutzer, beim Senden aufgeloest. Steht hier und wird nicht beim
/// Lesen aus dem Text geparst, weil der Worker sie fuer die
/// Benachrichtigungen braucht, ohne den Text zu kennen.
mentions String[] @default([])
createdAt DateTime @default(now()) createdAt DateTime @default(now())
case Case? @relation(fields: [caseId], references: [id], onDelete: Cascade) case Case? @relation(fields: [caseId], references: [id], onDelete: Cascade)
@@ -732,6 +743,7 @@ model ChatMessage {
@@index([channelId, createdAt(sort: Desc)]) @@index([channelId, createdAt(sort: Desc)])
@@index([authorId]) @@index([authorId])
@@index([replyToId]) @@index([replyToId])
@@index([mentions], type: Gin)
} }
/// Kleiner fester Satz an Reaktionen, keine freie Emoji-Auswahl. /// Kleiner fester Satz an Reaktionen, keine freie Emoji-Auswahl.
@@ -766,6 +778,35 @@ model Watch {
} }
/// Was die Web-App dem Worker zum Verschicken uebergibt.
///
/// Eine Tabelle statt eines direkten Aufrufs, weil der Worker ein eigener
/// Prozess ist und laufen oder auch gerade neu starten kann. Ein Eintrag, der
/// nicht rausging, bleibt liegen und wird beim naechsten Durchlauf abgeholt.
/// Kein Einmal-Container, siehe CLAUDE.md, harte Regel 8.
///
/// **Hier steht nie ein Inhalt.** Nur Kennungen und die Art des Ereignisses;
/// den Text baut der Worker daraus, und Discord kennt unsere Klassifizierung
/// nicht. Siehe docs/06-chat-und-realtime.md.
model NotificationOutbox {
id BigInt @id @default(autoincrement())
kind String // "entry.new", "chat.new", "chat.mention", "case.status"
caseId String?
channelId String?
/// Leer heisst: in den allgemeinen Kanal. Sonst als Direktnachricht.
recipientId String?
actorId String
/// Nur Kennungen und Zahlen, nie Text aus einer Akte.
meta Json?
createdAt DateTime @default(now())
sentAt DateTime?
attempts Int @default(0)
lastError String?
@@index([sentAt, createdAt])
@@index([caseId])
}
/// Lebenszeichen der Dienste, die nicht im Web-Container laufen. Der Worker /// Lebenszeichen der Dienste, die nicht im Web-Container laufen. Der Worker
/// schreibt hier seinen Gateway-Zustand hinein, damit /api/health ihn pruefen /// schreibt hier seinen Gateway-Zustand hinein, damit /api/health ihn pruefen
/// kann, ohne Discord zu befragen. Siehe docs/11-betrieb.md. /// kann, ohne Discord zu befragen. Siehe docs/11-betrieb.md.
-9
View File
@@ -106,9 +106,6 @@ importers:
sharp: sharp:
specifier: 0.34.4 specifier: 0.34.4
version: 0.34.4 version: 0.34.4
uuidv7:
specifier: 1.0.2
version: 1.0.2
zod: zod:
specifier: ^4.0.0 specifier: ^4.0.0
version: 4.5.4 version: 4.5.4
@@ -3477,10 +3474,6 @@ packages:
uri-js@4.4.1: uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
uuidv7@1.0.2:
resolution: {integrity: sha512-8JQkH4ooXnm1JCIhqTMbtmdnYEn6oKukBxHn1Ic9878jMkL7daTI7anTExfY18VRCX7tcdn5quzvCb6EWrR8PA==}
hasBin: true
valibot@1.4.2: valibot@1.4.2:
resolution: {integrity: sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg==} resolution: {integrity: sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg==}
peerDependencies: peerDependencies:
@@ -7090,8 +7083,6 @@ snapshots:
dependencies: dependencies:
punycode: 2.3.1 punycode: 2.3.1
uuidv7@1.0.2: {}
valibot@1.4.2(typescript@5.9.3): valibot@1.4.2(typescript@5.9.3):
optionalDependencies: optionalDependencies:
typescript: 5.9.3 typescript: 5.9.3
+128
View File
@@ -0,0 +1,128 @@
'use server'
import { revalidatePath } from 'next/cache'
import { z } from 'zod'
import { requireUser } from '@/lib/auth/current'
import { AccessError } from '@/lib/errors'
import {
adoptAsEntry,
editMessage,
markRead,
sendMessage,
toggleReaction,
withdrawMessage,
} from '@/lib/chat/service'
import { REACTIONS } from '@/lib/chat/terms'
import { de } from '@/lib/i18n/de'
export type ActionResult = { ok: true } | { ok: false; error: string }
function toMessage(error: unknown): string {
if (error instanceof AccessError) {
return de.actionErrors[error.code] ?? de.actionErrors.default!
}
console.error('[chat] unerwartet', error)
return de.actionErrors.default!
}
const scope = z.union([
z.object({ kind: z.literal('case'), caseNumber: z.string().min(1) }),
z.object({ kind: z.literal('channel'), key: z.string().min(1) }),
])
const sendSchema = z.object({
id: z.string().min(1).max(64),
scope,
body: z.string().min(1).max(4000),
replyToId: z.string().max(64).nullish(),
})
export async function sendMessageAction(input: unknown): Promise<ActionResult> {
const user = await requireUser()
const parsed = sendSchema.safeParse(input)
if (!parsed.success) return { ok: false, error: de.actionErrors['message-empty']! }
try {
await sendMessage(user.permissions, parsed.data)
return { ok: true }
} catch (error) {
return { ok: false, error: toMessage(error) }
}
}
const editSchema = z.object({ id: z.string().min(1), body: z.string().min(1).max(4000) })
export async function editMessageAction(input: unknown): Promise<ActionResult> {
const user = await requireUser()
const parsed = editSchema.safeParse(input)
if (!parsed.success) return { ok: false, error: de.actionErrors['message-empty']! }
try {
await editMessage(user.permissions, parsed.data)
return { ok: true }
} catch (error) {
return { ok: false, error: toMessage(error) }
}
}
const idSchema = z.object({ id: z.string().min(1) })
export async function withdrawMessageAction(input: unknown): Promise<ActionResult> {
const user = await requireUser()
const parsed = idSchema.safeParse(input)
if (!parsed.success) return { ok: false, error: de.actionErrors.default! }
try {
await withdrawMessage(user.permissions, parsed.data.id)
return { ok: true }
} catch (error) {
return { ok: false, error: toMessage(error) }
}
}
const reactionSchema = z.object({ id: z.string().min(1), key: z.enum(REACTIONS) })
export async function toggleReactionAction(input: unknown): Promise<ActionResult> {
const user = await requireUser()
const parsed = reactionSchema.safeParse(input)
if (!parsed.success) return { ok: false, error: de.actionErrors.default! }
try {
await toggleReaction(user.permissions, parsed.data)
return { ok: true }
} catch (error) {
return { ok: false, error: toMessage(error) }
}
}
/**
* „Als Vermerk übernehmen". Danach wird die Akte neu geladen: der Vermerk
* gehört in den Verlauf, und der steht auf derselben Seite.
*/
export async function adoptAsEntryAction(input: unknown): Promise<ActionResult> {
const user = await requireUser()
const parsed = idSchema.safeParse(input)
if (!parsed.success) return { ok: false, error: de.actionErrors.default! }
try {
await adoptAsEntry(user.permissions, parsed.data.id)
revalidatePath('/faelle', 'layout')
return { ok: true }
} catch (error) {
return { ok: false, error: toMessage(error) }
}
}
const readSchema = z.object({
scopeType: z.enum(['case', 'channel']),
scopeId: z.string().min(1),
})
export async function markReadAction(input: unknown): Promise<ActionResult> {
const user = await requireUser()
const parsed = readSchema.safeParse(input)
if (!parsed.success) return { ok: false, error: de.actionErrors.default! }
await markRead(user.permissions, parsed.data.scopeType, parsed.data.scopeId)
return { ok: true }
}
@@ -47,6 +47,7 @@ async function Body({
title: string title: string
layout: string layout: string
createdAt: Date createdAt: Date
caseId: string | null
} }
permissions: EffectivePermissions permissions: EffectivePermissions
canEdit: boolean canEdit: boolean
@@ -69,6 +70,7 @@ async function Body({
sections={sections} sections={sections}
canEdit={canEdit} canEdit={canEdit}
documentNumber={document.documentNumber} documentNumber={document.documentNumber}
topic={document.caseId ? `case:${document.caseId}` : `user:${permissions.userId}`}
photoUrls={photoUrls} photoUrls={photoUrls}
/> />
) )
@@ -108,11 +110,13 @@ async function Body({
async function AttachmentBlock({ async function AttachmentBlock({
documentId, documentId,
documentNumber, documentNumber,
topic,
permissions, permissions,
canUpload, canUpload,
}: { }: {
documentId: string documentId: string
documentNumber: string documentNumber: string
topic: string
permissions: EffectivePermissions permissions: EffectivePermissions
canUpload: boolean canUpload: boolean
}) { }) {
@@ -120,6 +124,7 @@ async function AttachmentBlock({
return ( return (
<Attachments <Attachments
target={{ documentNumber }} target={{ documentNumber }}
topic={topic}
initial={toAttachmentViews(anhaenge)} initial={toAttachmentViews(anhaenge)}
canUpload={canUpload} canUpload={canUpload}
canRelease={permissions.isAdmin} canRelease={permissions.isAdmin}
@@ -274,6 +279,7 @@ export default async function DocumentPage({
<AttachmentBlock <AttachmentBlock
documentId={doc.id} documentId={doc.id}
documentNumber={doc.documentNumber} documentNumber={doc.documentNumber}
topic={doc.caseId ? `case:${doc.caseId}` : `user:${user.id}`}
permissions={user.permissions} permissions={user.permissions}
canUpload={canEdit} canUpload={canEdit}
/> />
@@ -0,0 +1,46 @@
'use client'
import { useActionState } from 'react'
import { notifyText } from '@/lib/i18n/shell'
import { saveNotifyModeAction, type ActionResult } from './actions'
const MODES = ['ALL', 'OWN_CASES', 'MENTIONS', 'OFF'] as const
export function NotifyForm({ current }: { current: string }) {
const [state, action, pending] = useActionState<ActionResult | null, FormData>(
saveNotifyModeAction,
null,
)
return (
<form action={action} className="mt-6">
<fieldset>
<legend className="font-condensed text-xs text-text-dim">{notifyText.mode}</legend>
<div className="mt-3 space-y-2">
{MODES.map((mode) => (
<label key={mode} className="flex items-center gap-3 text-sm">
<input type="radio" name="notifyMode" value={mode} defaultChecked={current === mode} />
{notifyText.modes[mode]}
</label>
))}
</div>
</fieldset>
<div className="mt-5 flex items-center gap-3">
<button
type="submit"
disabled={pending}
className="border border-line bg-raised px-4 py-2 font-condensed text-xs text-accent disabled:text-text-dim"
>
{notifyText.save}
</button>
{state?.ok ? (
<span className="font-condensed text-2xs text-text-dim">{notifyText.saved}</span>
) : null}
{state && !state.ok ? (
<span className="font-condensed text-2xs text-stamp-4">{state.error}</span>
) : null}
</div>
</form>
)
}
+28
View File
@@ -0,0 +1,28 @@
'use server'
import { revalidatePath } from 'next/cache'
import { z } from 'zod'
import { db } from '@/lib/db'
import { requireUser } from '@/lib/auth/current'
import { de } from '@/lib/i18n/de'
export type ActionResult = { ok: true } | { ok: false; error: string }
const schema = z.object({ notifyMode: z.enum(['ALL', 'OWN_CASES', 'MENTIONS', 'OFF']) })
/** Jeder stellt nur für sich selbst ein — die Kennung kommt aus der Sitzung. */
export async function saveNotifyModeAction(
_prev: ActionResult | null,
formData: FormData,
): Promise<ActionResult> {
const user = await requireUser()
const parsed = schema.safeParse(Object.fromEntries(formData))
if (!parsed.success) return { ok: false, error: de.actionErrors.default! }
await db.user.update({
where: { id: user.id },
data: { notifyMode: parsed.data.notifyMode },
})
revalidatePath('/einstellungen')
return { ok: true }
}
+24
View File
@@ -0,0 +1,24 @@
import type { Metadata } from 'next'
import { db } from '@/lib/db'
import { requireUser } from '@/lib/auth/current'
import { de } from '@/lib/i18n/de'
import { NotifyForm } from './NotifyForm'
export const metadata: Metadata = { title: de.notify.heading }
export const dynamic = 'force-dynamic'
export default async function SettingsPage() {
const user = await requireUser()
const eigen = await db.user.findUniqueOrThrow({
where: { id: user.id },
select: { notifyMode: true },
})
return (
<main className="mx-auto max-w-(--container-measure) px-6 py-10">
<h1 className="font-condensed">{de.notify.heading}</h1>
<p className="mt-2 text-sm text-text-dim">{de.notify.intro}</p>
<NotifyForm current={eigen.notifyMode} />
</main>
)
}
+21 -55
View File
@@ -3,10 +3,10 @@
import { useState, useTransition } from 'react' import { useState, useTransition } from 'react'
import Link from 'next/link' import Link from 'next/link'
import { linkText } from '@/lib/i18n/cases' import { linkText } from '@/lib/i18n/cases'
import { commonText } from '@/lib/i18n/common'
import { LINK_KINDS } from '@/lib/cases/terms' import { LINK_KINDS } from '@/lib/cases/terms'
import { linkCasesAction, unlinkCasesAction } from '../actions' import { AddPanel, PanelSelect } from '@/components/AddPanel'
import { classificationEdgeStyle } from '@/components/StampMark' import { classificationEdgeStyle } from '@/components/StampMark'
import { linkCasesAction, unlinkCasesAction } from '../actions'
type Verknuepfung = { type Verknuepfung = {
kind: string kind: string
@@ -29,10 +29,9 @@ export function CaseLinks({
links: Verknuepfung[] links: Verknuepfung[]
canEdit: boolean canEdit: boolean
}) { }) {
const [open, setOpen] = useState(false)
const [target, setTarget] = useState('') const [target, setTarget] = useState('')
const [kind, setKind] = useState<string>('RELATED') const [kind, setKind] = useState<string>('RELATED')
const [error, setError] = useState<string | null>(null) const [fehler, setFehler] = useState<string | null>(null)
const [pending, startTransition] = useTransition() const [pending, startTransition] = useTransition()
return ( return (
@@ -65,12 +64,12 @@ export function CaseLinks({
disabled={pending} disabled={pending}
onClick={() => onClick={() =>
startTransition(async () => { startTransition(async () => {
const result = await unlinkCasesAction({ const ergebnis = await unlinkCasesAction({
fromCaseNumber: caseNumber, fromCaseNumber: caseNumber,
toCaseNumber: link.other.caseNumber, toCaseNumber: link.other.caseNumber,
kind: link.kind, kind: link.kind,
}) })
if (!result.ok) setError(result.error) if (!ergebnis.ok) setFehler(ergebnis.error)
}) })
} }
className="font-condensed text-2xs text-text-dim" className="font-condensed text-2xs text-text-dim"
@@ -83,23 +82,29 @@ export function CaseLinks({
</ul> </ul>
)} )}
{error ? <p className="mt-2 font-condensed text-2xs text-stamp-4">{error}</p> : null} {fehler ? <p className="mt-2 font-condensed text-2xs text-stamp-4">{fehler}</p> : null}
{canEdit ? ( {canEdit ? (
open ? ( <AddPanel
<div className="mt-4 flex flex-wrap items-center gap-2"> label={linkText.add}
<select disabled={target.trim() === ''}
value={kind} onSubmit={async () => {
onChange={(event) => setKind(event.target.value)} const ergebnis = await linkCasesAction({
aria-label={linkText.kind} fromCaseNumber: caseNumber,
className="border border-line bg-raised px-2 py-1 text-2xs" toCaseNumber: target.trim(),
kind,
})
if (ergebnis.ok) setTarget('')
return ergebnis
}}
> >
<PanelSelect value={kind} onChange={setKind} label={linkText.kind}>
{LINK_KINDS.map((value) => ( {LINK_KINDS.map((value) => (
<option key={value} value={value}> <option key={value} value={value}>
{linkText.kinds[value] ?? value} {linkText.kinds[value] ?? value}
</option> </option>
))} ))}
</select> </PanelSelect>
<input <input
value={target} value={target}
onChange={(event) => setTarget(event.target.value)} onChange={(event) => setTarget(event.target.value)}
@@ -107,46 +112,7 @@ export function CaseLinks({
aria-label={linkText.target} aria-label={linkText.target}
className="border border-line bg-raised px-2 py-1 font-mono text-2xs" className="border border-line bg-raised px-2 py-1 font-mono text-2xs"
/> />
<button </AddPanel>
type="button"
disabled={pending || target.trim() === ''}
onClick={() =>
startTransition(async () => {
setError(null)
const result = await linkCasesAction({
fromCaseNumber: caseNumber,
toCaseNumber: target.trim(),
kind,
})
if (result.ok) {
setTarget('')
setOpen(false)
} else {
setError(result.error)
}
})
}
className="border border-line bg-raised px-3 py-1 font-condensed text-2xs text-accent disabled:text-text-dim"
>
{linkText.add}
</button>
<button
type="button"
onClick={() => setOpen(false)}
className="font-condensed text-2xs text-text-dim"
>
{commonText.cancel}
</button>
</div>
) : (
<button
type="button"
onClick={() => setOpen(true)}
className="mt-4 border border-line bg-raised px-3 py-1 font-condensed text-2xs text-accent"
>
{linkText.add}
</button>
)
) : null} ) : null}
</div> </div>
) )
@@ -1,7 +1,7 @@
'use client' 'use client'
import { useOptimistic, useRef, useState, useTransition } from 'react' import { useOptimistic, useRef, useState, useTransition } from 'react'
import { uuidv7 } from 'uuidv7' import { uuidv7 } from '@/lib/uuidv7'
import { caseText, entryText } from '@/lib/i18n/cases' import { caseText, entryText } from '@/lib/i18n/cases'
import { addEntryAction } from '../actions' import { addEntryAction } from '../actions'
@@ -2,9 +2,10 @@
import { useState, useTransition } from 'react' import { useState, useTransition } from 'react'
import Link from 'next/link' import Link from 'next/link'
import { participantText } from '@/lib/i18n/cases'
import { commonText } from '@/lib/i18n/common' import { commonText } from '@/lib/i18n/common'
import { participantText } from '@/lib/i18n/cases'
import { PARTICIPANT_ROLES } from '@/lib/cases/terms' import { PARTICIPANT_ROLES } from '@/lib/cases/terms'
import { AddPanel, PanelSelect } from '@/components/AddPanel'
import { addParticipantAction, removeParticipantAction } from '../actions' import { addParticipantAction, removeParticipantAction } from '../actions'
type Beteiligter = { type Beteiligter = {
@@ -35,31 +36,11 @@ export function Participants({
members: { id: string; displayName: string }[] members: { id: string; displayName: string }[]
files: { id: string; displayName: string }[] files: { id: string; displayName: string }[]
}) { }) {
const [open, setOpen] = useState(false)
const [role, setRole] = useState<string>('INVESTIGATOR') const [role, setRole] = useState<string>('INVESTIGATOR')
const [target, setTarget] = useState('') const [target, setTarget] = useState('')
const [error, setError] = useState<string | null>(null) const [fehler, setFehler] = useState<string | null>(null)
const [pending, startTransition] = useTransition() const [pending, startTransition] = useTransition()
function add() {
if (!target) return
setError(null)
const [art, id] = target.split(':')
startTransition(async () => {
const result = await addParticipantAction({
caseNumber,
role,
...(art === 'user' ? { userId: id } : { personnelFileId: id }),
})
if (result.ok) {
setTarget('')
setOpen(false)
} else {
setError(result.error)
}
})
}
return ( return (
<div> <div>
{participants.length === 0 ? ( {participants.length === 0 ? (
@@ -89,11 +70,11 @@ export function Participants({
disabled={pending} disabled={pending}
onClick={() => onClick={() =>
startTransition(async () => { startTransition(async () => {
const result = await removeParticipantAction({ const ergebnis = await removeParticipantAction({
caseNumber, caseNumber,
participantId: person.id, participantId: person.id,
}) })
if (!result.ok) setError(result.error) if (!ergebnis.ok) setFehler(ergebnis.error)
}) })
} }
className="font-condensed text-2xs text-text-dim" className="font-condensed text-2xs text-text-dim"
@@ -106,33 +87,32 @@ export function Participants({
</ul> </ul>
)} )}
{error ? <p className="mt-2 font-condensed text-2xs text-stamp-4">{error}</p> : null} {fehler ? <p className="mt-2 font-condensed text-2xs text-stamp-4">{fehler}</p> : null}
{canEdit ? ( {canEdit ? (
open ? ( <AddPanel
<div className="mt-4 border border-line p-3"> label={participantText.add}
<p className="max-w-(--container-measure) text-2xs text-text-dim"> hint={participantText.hint}
{participantText.hint} disabled={!target}
</p> onSubmit={async () => {
<div className="mt-3 flex flex-wrap items-center gap-2"> const [art, id] = target.split(':')
<select const ergebnis = await addParticipantAction({
value={role} caseNumber,
onChange={(event) => setRole(event.target.value)} role,
aria-label={participantText.role} ...(art === 'user' ? { userId: id } : { personnelFileId: id }),
className="border border-line bg-raised px-2 py-1 text-2xs" })
if (ergebnis.ok) setTarget('')
return ergebnis
}}
> >
<PanelSelect value={role} onChange={setRole} label={participantText.role}>
{PARTICIPANT_ROLES.map((value) => ( {PARTICIPANT_ROLES.map((value) => (
<option key={value} value={value}> <option key={value} value={value}>
{participantText.roles[value] ?? value} {participantText.roles[value] ?? value}
</option> </option>
))} ))}
</select> </PanelSelect>
<select <PanelSelect value={target} onChange={setTarget} label={participantText.person}>
value={target}
onChange={(event) => setTarget(event.target.value)}
aria-label={participantText.person}
className="border border-line bg-raised px-2 py-1 text-2xs"
>
<option value="">—</option> <option value="">—</option>
<optgroup label={participantText.pickUser}> <optgroup label={participantText.pickUser}>
{members.map((member) => ( {members.map((member) => (
@@ -148,33 +128,8 @@ export function Participants({
</option> </option>
))} ))}
</optgroup> </optgroup>
</select> </PanelSelect>
<button </AddPanel>
type="button"
disabled={pending || !target}
onClick={add}
className="border border-line bg-raised px-3 py-1 font-condensed text-2xs text-accent disabled:text-text-dim"
>
{participantText.add}
</button>
<button
type="button"
onClick={() => setOpen(false)}
className="font-condensed text-2xs text-text-dim"
>
{commonText.cancel}
</button>
</div>
</div>
) : (
<button
type="button"
onClick={() => setOpen(true)}
className="mt-4 border border-line bg-raised px-3 py-1 font-condensed text-2xs text-accent"
>
{participantText.add}
</button>
)
) : null} ) : null}
</div> </div>
) )
@@ -25,6 +25,8 @@ import { visiblePersonnelWhere } from '@/lib/access/personnelFilter'
import { DocumentList } from '@/components/documents/DocumentList' import { DocumentList } from '@/components/documents/DocumentList'
import { NewDocument } from '@/components/documents/NewDocument' import { NewDocument } from '@/components/documents/NewDocument'
import { Attachments } from '@/components/files/Attachments' import { Attachments } from '@/components/files/Attachments'
import { ChatLazy } from '@/components/chat/ChatLazy'
import { loadChatView } from '@/lib/chat/load'
import { listAttachments } from '@/lib/files/service' import { listAttachments } from '@/lib/files/service'
import { toAttachmentViews } from '@/lib/files/view' import { toAttachmentViews } from '@/lib/files/view'
import { allowedTargets, type CaseStatusName } from '@/lib/cases/lifecycle' import { allowedTargets, type CaseStatusName } from '@/lib/cases/lifecycle'
@@ -214,6 +216,7 @@ async function Relations({
<div className="mt-4"> <div className="mt-4">
<Attachments <Attachments
target={{ caseNumber: akte.caseNumber }} target={{ caseNumber: akte.caseNumber }}
topic={`case:${akte.id}`}
initial={toAttachmentViews(anhaenge)} initial={toAttachmentViews(anhaenge)}
canUpload={canEdit} canUpload={canEdit}
canRelease={permissions.isAdmin} canRelease={permissions.isAdmin}
@@ -239,6 +242,36 @@ async function Relations({
) )
} }
/**
* Der Aktenchat. Eigene Suspense-Grenze, weil er die meisten Abfragen macht
* und der Rest der Seite nicht auf ihn warten soll.
*/
async function ChatBlock({
akte,
user,
}: {
akte: { id: string; caseNumber: string; status: string; classification: number }
user: { id: string; permissions: EffectivePermissions }
}) {
const ansicht = await loadChatView(user.permissions, {
kind: 'case',
caseNumber: akte.caseNumber,
})
return (
<ChatLazy
scope={{ kind: 'case', caseNumber: akte.caseNumber, caseId: akte.id }}
topic={`case:${akte.id}`}
initial={ansicht.messages}
initialReactions={ansicht.reactions}
currentUserId={user.id}
lastReadAt={ansicht.lastReadAt}
canAdopt={canEditCase(user.permissions, akte, 'entry.create').ok}
mentionNames={ansicht.mentionNames}
/>
)
}
async function SectionBlock({ async function SectionBlock({
caseId, caseId,
caseNumber, caseNumber,
@@ -373,6 +406,20 @@ export default async function CasePage({
</Suspense> </Suspense>
</div> </div>
<section className="mt-12">
<Suspense
fallback={
<div className="space-y-3 border border-line p-4">
<SkeletonLine width="8rem" />
<SkeletonLine width="70%" />
<SkeletonLine width="55%" />
</div>
}
>
<ChatBlock akte={akte} user={user} />
</Suspense>
</section>
<nav className="mt-12 flex gap-4 border-b border-line pb-2 font-condensed text-xs"> <nav className="mt-12 flex gap-4 border-b border-line pb-2 font-condensed text-xs">
<span className="text-text-dim">{de.case.timeline}</span> <span className="text-text-dim">{de.case.timeline}</span>
<Link href={`/faelle/${akte.caseNumber}/aenderungen`}>{de.case.changes}</Link> <Link href={`/faelle/${akte.caseNumber}/aenderungen`}>{de.case.changes}</Link>
+116
View File
@@ -0,0 +1,116 @@
'use client'
import { useActionState, useState } from 'react'
import { channelText } from '@/lib/i18n/chat'
import { clearanceText, commonText } from '@/lib/i18n/common'
import { createChannelAction, type ActionResult } from './actions'
export function NewChannelForm({ levels }: { levels: { level: number; name: string }[] }) {
const [offen, setOffen] = useState(false)
const [state, action, pending] = useActionState<ActionResult | null, FormData>(
createChannelAction,
null,
)
if (!offen) {
return (
<button
type="button"
onClick={() => setOffen(true)}
className="border border-line bg-raised px-3 py-1 font-condensed text-2xs text-accent"
>
{channelText.create}
</button>
)
}
return (
<form action={action} className="max-w-(--container-measure) border border-line p-4">
<p className="font-condensed text-xs text-text-dim">{channelText.createTitle}</p>
<div className="mt-3 grid gap-4 sm:grid-cols-2">
<div>
<label htmlFor="name" className="font-condensed text-2xs text-text-dim">
{channelText.name}
</label>
<input
id="name"
name="name"
required
minLength={2}
maxLength={120}
className="mt-1 block w-full border border-line bg-raised px-3 py-2 text-sm"
/>
</div>
<div>
<label htmlFor="key" className="font-condensed text-2xs text-text-dim">
{channelText.key}
</label>
<input
id="key"
name="key"
required
pattern="[a-z0-9][a-z0-9-]{1,40}"
className="mt-1 block w-full border border-line bg-raised px-3 py-2 font-mono text-sm"
/>
<p className="mt-1 text-2xs text-text-dim">{channelText.keyHint}</p>
</div>
<div className="sm:col-span-2">
<label htmlFor="description" className="font-condensed text-2xs text-text-dim">
{channelText.description}
</label>
<input
id="description"
name="description"
maxLength={500}
className="mt-1 block w-full border border-line bg-raised px-3 py-2 text-sm"
/>
</div>
<div>
<label htmlFor="minClearance" className="font-condensed text-2xs text-text-dim">
{channelText.minClearance}
</label>
<select
id="minClearance"
name="minClearance"
defaultValue={0}
className="mt-1 block w-full border border-line bg-raised px-3 py-2 text-sm"
>
{levels.map((level) => (
<option key={level.level} value={level.level}>
{level.level} · {level.name}
</option>
))}
</select>
<p className="mt-1 text-2xs text-text-dim">
{channelText.minClearanceHint} {clearanceText.label}
</p>
</div>
</div>
{state && !state.ok ? (
<p className="mt-3 font-condensed text-2xs text-stamp-4">{state.error}</p>
) : null}
<div className="mt-4 flex gap-3">
<button
type="submit"
disabled={pending}
className="border border-line bg-raised px-3 py-1 font-condensed text-2xs text-accent disabled:text-text-dim"
>
{pending ? commonText.loading : channelText.create}
</button>
<button
type="button"
onClick={() => setOffen(false)}
className="font-condensed text-2xs text-text-dim"
>
{commonText.cancel}
</button>
</div>
</form>
)
}
+70
View File
@@ -0,0 +1,70 @@
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
import { requireUser } from '@/lib/auth/current'
import { AccessError } from '@/lib/errors'
import { loadChatView } from '@/lib/chat/load'
import { db } from '@/lib/db'
import { visibleChannelWhere } from '@/lib/access/chatFilter'
import { de } from '@/lib/i18n/de'
import { Chat } from '@/components/chat/Chat'
export const dynamic = 'force-dynamic'
export async function generateMetadata({
params,
}: {
params: Promise<{ key: string }>
}): Promise<Metadata> {
const { key } = await params
return { title: `#${key}` }
}
export default async function ChannelPage({ params }: { params: Promise<{ key: string }> }) {
const user = await requireUser()
const { key } = await params
const kanal = await db.channel.findFirst({
where: { AND: [visibleChannelWhere(user.permissions), { key, isArchived: false }] },
select: { id: true, key: true, name: true, description: true },
})
// Kein Zugriff heisst 404.
if (!kanal) notFound()
let ansicht
try {
ansicht = await loadChatView(user.permissions, { kind: 'channel', key: kanal.key })
} catch (error) {
if (error instanceof AccessError) notFound()
throw error
}
return (
<main className="mx-auto max-w-5xl px-6 py-10">
<header>
<span className="font-mono text-xs text-text-dim">#{kanal.key}</span>
<h1 className="mt-1 font-condensed">{kanal.name}</h1>
{kanal.description ? (
<p className="mt-2 max-w-(--container-measure) text-sm text-text-dim">
{kanal.description}
</p>
) : null}
</header>
<div className="mt-8">
<Chat
scope={{ kind: 'channel', key: kanal.key, channelId: kanal.id }}
topic={`channel:${kanal.id}`}
initial={ansicht.messages}
initialReactions={ansicht.reactions}
currentUserId={user.id}
lastReadAt={ansicht.lastReadAt}
// Ein Vermerk gehört in eine Akte. Ein Kanal hat keine.
canAdopt={false}
mentionNames={ansicht.mentionNames}
/>
</div>
<p className="mt-4 text-2xs text-text-dim">{de.chat.hint}</p>
</main>
)
}
+40
View File
@@ -0,0 +1,40 @@
'use server'
import { redirect } from 'next/navigation'
import { z } from 'zod'
import { requireUser } from '@/lib/auth/current'
import { AccessError } from '@/lib/errors'
import { createChannel } from '@/lib/chat/service'
import { de } from '@/lib/i18n/de'
export type ActionResult = { ok: true } | { ok: false; error: string }
const schema = z.object({
key: z.string().min(2).max(40),
name: z.string().min(2).max(120),
description: z.string().max(500).optional(),
minClearance: z.coerce.number().int().min(0).max(5),
})
export async function createChannelAction(
_prev: ActionResult | null,
formData: FormData,
): Promise<ActionResult> {
const user = await requireUser()
const parsed = schema.safeParse(Object.fromEntries(formData))
if (!parsed.success) return { ok: false, error: de.actionErrors.default! }
let key: string
try {
const kanal = await createChannel(user.permissions, parsed.data)
key = kanal.key
} catch (error) {
if (error instanceof AccessError) {
return { ok: false, error: de.actionErrors[error.code] ?? de.actionErrors.default! }
}
console.error('[kanaele] unerwartet', error)
return { ok: false, error: de.actionErrors.default! }
}
redirect(`/kanaele/${key}`)
}
+71
View File
@@ -0,0 +1,71 @@
import type { Metadata } from 'next'
import Link from 'next/link'
import { db } from '@/lib/db'
import { requireUser } from '@/lib/auth/current'
import { listChannels } from '@/lib/chat/service'
import { de } from '@/lib/i18n/de'
import { StampMark } from '@/components/StampMark'
import { NewChannelForm } from './NewChannelForm'
export const metadata: Metadata = { title: de.channel.heading }
export const dynamic = 'force-dynamic'
/**
* Freie Kanäle: Räume für Absprachen, die zu keiner Akte gehören.
*
* Ein Kanal über der Stufe des Lesers taucht nicht auf. Anders als bei Akten
* ist die Existenz eines Kanals aber kein Geheimnis für die, die seine Stufe
* haben — ein Raum, den man erraten müsste, wäre keiner.
*/
export default async function ChannelListPage() {
const user = await requireUser()
const [kanaele, levels] = await Promise.all([
listChannels(user.permissions),
db.clearanceLevelConfig.findMany({ orderBy: { level: 'asc' } }),
])
const levelName = new Map(levels.map((l) => [l.level, l.name]))
const darfAnlegen = user.permissions.isAdmin || user.permissions.permissions.has('case.create')
return (
<main className="mx-auto max-w-5xl px-6 py-10">
<h1 className="font-condensed">{de.channel.heading}</h1>
<p className="mt-2 max-w-(--container-measure) text-sm text-text-dim">
{de.channel.intro}
</p>
{kanaele.length === 0 ? (
<p className="mt-10 text-text-dim">{de.channel.empty}</p>
) : (
<ul className="mt-6 border-t border-line">
{kanaele.map((kanal) => (
<li key={kanal.id} className="border-b border-line">
<Link
href={`/kanaele/${kanal.key}`}
prefetch
className="flex flex-wrap items-baseline gap-x-4 gap-y-1 py-3 text-text no-underline"
>
<span className="font-mono text-xs text-text-dim">#{kanal.key}</span>
<span className="grow">{kanal.name}</span>
{kanal.minClearance > 0 ? (
<StampMark
level={kanal.minClearance}
name={levelName.get(kanal.minClearance) ?? de.common.unknown}
/>
) : null}
</Link>
</li>
))}
</ul>
)}
{darfAnlegen ? (
<div className="mt-8">
<NewChannelForm
levels={levels.filter((l) => l.level <= user.permissions.clearanceLevel)}
/>
</div>
) : null}
</main>
)
}
+2
View File
@@ -25,6 +25,8 @@ export default async function AppLayout({ children }: { children: React.ReactNod
{user.permissions.isAdmin || user.permissions.permissions.has('personnel.read') ? ( {user.permissions.isAdmin || user.permissions.permissions.has('personnel.read') ? (
<Link href="/personalakten">{de.nav.personnel}</Link> <Link href="/personalakten">{de.nav.personnel}</Link>
) : null} ) : null}
<Link href="/kanaele">{de.channel.heading}</Link>
<Link href="/einstellungen">{de.notify.heading}</Link>
{user.permissions.isAdmin || user.permissions.permissions.has('roles.manage') ? ( {user.permissions.isAdmin || user.permissions.permissions.has('roles.manage') ? (
<> <>
<Link href="/admin/rollen">{de.nav.admin}</Link> <Link href="/admin/rollen">{de.nav.admin}</Link>
@@ -1,10 +1,10 @@
'use client' 'use client'
import { useState, useTransition } from 'react' import { useState, useTransition } from 'react'
import { participantText } from '@/lib/i18n/cases'
import { commonText } from '@/lib/i18n/common'
import { assignmentText } from '@/lib/i18n/people' import { assignmentText } from '@/lib/i18n/people'
import { participantText } from '@/lib/i18n/cases'
import { ASSIGNMENT_ROLES } from '@/lib/personnel/terms' import { ASSIGNMENT_ROLES } from '@/lib/personnel/terms'
import { AddPanel, PanelSelect } from '@/components/AddPanel'
import { addAssignmentAction, removeAssignmentAction } from '../actions' import { addAssignmentAction, removeAssignmentAction } from '../actions'
type Zuordnung = { type Zuordnung = {
@@ -30,10 +30,9 @@ export function Assignments({
members: { id: string; displayName: string }[] members: { id: string; displayName: string }[]
canEdit: boolean canEdit: boolean
}) { }) {
const [open, setOpen] = useState(false)
const [role, setRole] = useState<string>('HANDLER') const [role, setRole] = useState<string>('HANDLER')
const [userId, setUserId] = useState('') const [userId, setUserId] = useState('')
const [error, setError] = useState<string | null>(null) const [fehler, setFehler] = useState<string | null>(null)
const [pending, startTransition] = useTransition() const [pending, startTransition] = useTransition()
return ( return (
@@ -57,11 +56,11 @@ export function Assignments({
disabled={pending} disabled={pending}
onClick={() => onClick={() =>
startTransition(async () => { startTransition(async () => {
const result = await removeAssignmentAction({ const ergebnis = await removeAssignmentAction({
personnelFileId, personnelFileId,
assignmentId: zuordnung.id, assignmentId: zuordnung.id,
}) })
if (!result.ok) setError(result.error) if (!ergebnis.ok) setFehler(ergebnis.error)
}) })
} }
className="font-condensed text-2xs text-text-dim" className="font-condensed text-2xs text-text-dim"
@@ -74,77 +73,35 @@ export function Assignments({
</ul> </ul>
)} )}
{error ? <p className="mt-2 font-condensed text-2xs text-stamp-4">{error}</p> : null} {fehler ? <p className="mt-2 font-condensed text-2xs text-stamp-4">{fehler}</p> : null}
{canEdit ? ( {canEdit ? (
open ? ( <AddPanel
<div className="mt-4 border border-line p-3"> label={assignmentText.add}
<p className="max-w-(--container-measure) text-2xs text-text-dim"> hint={assignmentText.hint}
{assignmentText.hint} disabled={!userId}
</p> onSubmit={async () => {
<div className="mt-3 flex flex-wrap items-center gap-2"> const ergebnis = await addAssignmentAction({ personnelFileId, userId, role })
<select if (ergebnis.ok) setUserId('')
value={role} return ergebnis
onChange={(event) => setRole(event.target.value)} }}
aria-label={participantText.role}
className="border border-line bg-raised px-2 py-1 text-2xs"
> >
<PanelSelect value={role} onChange={setRole} label={participantText.role}>
{ASSIGNMENT_ROLES.map((value) => ( {ASSIGNMENT_ROLES.map((value) => (
<option key={value} value={value}> <option key={value} value={value}>
{assignmentText.roles[value] ?? value} {assignmentText.roles[value] ?? value}
</option> </option>
))} ))}
</select> </PanelSelect>
<select <PanelSelect value={userId} onChange={setUserId} label={participantText.pickUser}>
value={userId}
onChange={(event) => setUserId(event.target.value)}
aria-label={participantText.pickUser}
className="border border-line bg-raised px-2 py-1 text-2xs"
>
<option value="">—</option> <option value="">—</option>
{members.map((member) => ( {members.map((member) => (
<option key={member.id} value={member.id}> <option key={member.id} value={member.id}>
{member.displayName} {member.displayName}
</option> </option>
))} ))}
</select> </PanelSelect>
<button </AddPanel>
type="button"
disabled={pending || !userId}
onClick={() =>
startTransition(async () => {
setError(null)
const result = await addAssignmentAction({ personnelFileId, userId, role })
if (result.ok) {
setUserId('')
setOpen(false)
} else {
setError(result.error)
}
})
}
className="border border-line bg-raised px-3 py-1 font-condensed text-2xs text-accent disabled:text-text-dim"
>
{assignmentText.add}
</button>
<button
type="button"
onClick={() => setOpen(false)}
className="font-condensed text-2xs text-text-dim"
>
{commonText.cancel}
</button>
</div>
</div>
) : (
<button
type="button"
onClick={() => setOpen(true)}
className="mt-4 border border-line bg-raised px-3 py-1 font-condensed text-2xs text-accent"
>
{assignmentText.add}
</button>
)
) : null} ) : null}
</div> </div>
) )
+14 -61
View File
@@ -1,10 +1,10 @@
'use client' 'use client'
import { useState, useTransition } from 'react' import { useState } from 'react'
import { linkText, participantText } from '@/lib/i18n/cases' import { linkText, participantText } from '@/lib/i18n/cases'
import { commonText } from '@/lib/i18n/common'
import { personnelText } from '@/lib/i18n/people' import { personnelText } from '@/lib/i18n/people'
import { PARTICIPANT_ROLES } from '@/lib/cases/terms' import { PARTICIPANT_ROLES } from '@/lib/cases/terms'
import { AddPanel, PanelSelect } from '@/components/AddPanel'
import { linkCaseToPersonAction } from '../actions' import { linkCaseToPersonAction } from '../actions'
/** /**
@@ -16,42 +16,27 @@ import { linkCaseToPersonAction } from '../actions'
* sichtbaren Fälle wäre auf einer Personalakte am falschen Ort. * sichtbaren Fälle wäre auf einer Personalakte am falschen Ort.
*/ */
export function LinkCase({ personnelFileId }: { personnelFileId: string }) { export function LinkCase({ personnelFileId }: { personnelFileId: string }) {
const [open, setOpen] = useState(false)
const [caseNumber, setCaseNumber] = useState('') const [caseNumber, setCaseNumber] = useState('')
const [role, setRole] = useState<string>('SUBJECT') const [role, setRole] = useState<string>('SUBJECT')
const [error, setError] = useState<string | null>(null)
const [pending, startTransition] = useTransition()
if (!open) {
return (
<button
type="button"
onClick={() => setOpen(true)}
className="mt-4 border border-line bg-raised px-3 py-1 font-condensed text-2xs text-accent"
>
{personnelText.linkCase}
</button>
)
}
return ( return (
<div className="mt-4 border border-line p-3"> <AddPanel
<p className="max-w-(--container-measure) text-2xs text-text-dim"> label={personnelText.linkCase}
{personnelText.linkCaseHint} hint={personnelText.linkCaseHint}
</p> disabled={caseNumber.trim() === ''}
<div className="mt-3 flex flex-wrap items-center gap-2"> onSubmit={async () => {
<select const ergebnis = await linkCaseToPersonAction({ personnelFileId, caseNumber, role })
value={role} if (ergebnis.ok) setCaseNumber('')
onChange={(event) => setRole(event.target.value)} return ergebnis
aria-label={participantText.role} }}
className="border border-line bg-raised px-2 py-1 text-2xs"
> >
<PanelSelect value={role} onChange={setRole} label={participantText.role}>
{PARTICIPANT_ROLES.map((value) => ( {PARTICIPANT_ROLES.map((value) => (
<option key={value} value={value}> <option key={value} value={value}>
{participantText.roles[value] ?? value} {participantText.roles[value] ?? value}
</option> </option>
))} ))}
</select> </PanelSelect>
<input <input
value={caseNumber} value={caseNumber}
onChange={(event) => setCaseNumber(event.target.value)} onChange={(event) => setCaseNumber(event.target.value)}
@@ -59,38 +44,6 @@ export function LinkCase({ personnelFileId }: { personnelFileId: string }) {
aria-label={linkText.target} aria-label={linkText.target}
className="border border-line bg-raised px-2 py-1 font-mono text-2xs" className="border border-line bg-raised px-2 py-1 font-mono text-2xs"
/> />
<button </AddPanel>
type="button"
disabled={pending || caseNumber.trim() === ''}
onClick={() =>
startTransition(async () => {
setError(null)
const result = await linkCaseToPersonAction({
personnelFileId,
caseNumber,
role,
})
if (result.ok) {
setCaseNumber('')
setOpen(false)
} else {
setError(result.error)
}
})
}
className="border border-line bg-raised px-3 py-1 font-condensed text-2xs text-accent disabled:text-text-dim"
>
{personnelText.linkCase}
</button>
<button
type="button"
onClick={() => setOpen(false)}
className="font-condensed text-2xs text-text-dim"
>
{commonText.cancel}
</button>
</div>
{error ? <p className="mt-2 font-condensed text-2xs text-stamp-4">{error}</p> : null}
</div>
) )
} }
-111
View File
@@ -1,111 +0,0 @@
import { getCurrentUser } from '@/lib/auth/current'
import { db } from '@/lib/db'
import { visibleAttachmentWhere } from '@/lib/access/attachmentFilter'
import { ensureListening, subscribe } from '@/lib/realtime/listener'
import { ATTACHMENT_CHANNEL } from '@/lib/realtime/publish'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
/** Damit Zwischenspeicher und Tunnel die Verbindung nicht für tot halten. */
const HEARTBEAT_MS = 25_000
/**
* Ereignisstrom für offene Ansichten.
*
* Nur `attachment.scanned` bislang; der Aktenchat kommt in Phase 6 dazu und
* benutzt denselben Weg. Server-Sent Events und kein WebSocket: die Richtung
* ist einseitig, es geht durch jeden Tunnel, und der Browser baut die
* Verbindung von selbst wieder auf.
*
* **Die Sichtbarkeitsprüfung läuft je Ereignis.** Ein Ereignis über einen
* Anhang, dessen Akte der Empfänger nicht sehen darf, wird nicht zugestellt —
* sonst verriete schon die Kennung die Existenz.
*/
export async function GET(request: Request): Promise<Response> {
const user = await getCurrentUser()
if (!user) return new Response(null, { status: 404 })
await ensureListening()
const encoder = new TextEncoder()
let heartbeat: ReturnType<typeof setInterval> | null = null
let unsubscribe: (() => void) | null = null
const stream = new ReadableStream({
start(controller) {
const send = (event: string, data: unknown) => {
try {
controller.enqueue(
encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`),
)
} catch {
// Verbindung schon zu; das Aufräumen übernimmt cancel().
}
}
send('ready', { at: new Date().toISOString() })
heartbeat = setInterval(() => {
try {
controller.enqueue(encoder.encode(': ping\n\n'))
} catch {
// s. o.
}
}, HEARTBEAT_MS)
unsubscribe = subscribe((event) => {
if (event.channel !== ATTACHMENT_CHANNEL) return
void zustellen(event.payload, user.permissions, send)
})
request.signal.addEventListener('abort', () => {
controller.close()
})
},
cancel() {
if (heartbeat) clearInterval(heartbeat)
unsubscribe?.()
},
})
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
// Der Tunnel darf nicht puffern, sonst kommt alles am Stück.
'X-Accel-Buffering': 'no',
},
})
}
async function zustellen(
attachmentId: string,
permissions: Awaited<ReturnType<typeof getCurrentUser>> extends infer U
? U extends { permissions: infer P }
? P
: never
: never,
send: (event: string, data: unknown) => void,
): Promise<void> {
try {
const anhang = await db.attachment.findFirst({
where: { AND: [visibleAttachmentWhere(permissions), { id: attachmentId }] },
select: {
id: true,
scanStatus: true,
scanMessage: true,
mimeType: true,
sizeBytes: true,
thumbKey: true,
blurhash: true,
width: true,
height: true,
},
})
if (!anhang) return
send('attachment.scanned', { ...anhang, hasThumb: anhang.thumbKey !== null, thumbKey: undefined })
} catch (error) {
console.error('[events] Ereignis nicht zugestellt', error)
}
}
+311
View File
@@ -0,0 +1,311 @@
import { getCurrentUser, type CurrentUser } from '@/lib/auth/current'
import { db } from '@/lib/db'
import { visibleCaseWhere } from '@/lib/access/caseFilter'
import { visibleChannelWhere, visibleMessageWhere } from '@/lib/access/chatFilter'
import { visibleAttachmentWhere } from '@/lib/access/attachmentFilter'
import { resolveCasePreviews, type CasePreview } from '@/lib/chat/preview'
import { extractCaseNumbers, type ChatMessageView } from '@/lib/chat/view'
import { ensureListening, subscribe } from '@/lib/realtime/listener'
import type { StreamEvent, Topic } from '@/lib/realtime/publish'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
/** Gegen Zwischenspeicher und Tunnel, die eine stille Verbindung für tot halten. */
const HEARTBEAT_MS = 25_000
/**
* Ereignisstrom für offene Ansichten.
*
* GET /api/stream?topics=case:<id>,channel:<id>
*
* Server-Sent Events und kein WebSocket: die Richtung ist einseitig, es geht
* durch jeden Tunnel, und der Browser baut die Verbindung von selbst wieder
* auf. Siehe docs/06-chat-und-realtime.md.
*
* Zwei Prüfungen, beide nötig:
* 1. **Beim Verbinden**: welche der angefragten Themen darf dieser Nutzer
* überhaupt abonnieren. Wer ein fremdes Thema nennt, bekommt es nicht —
* und erfährt auch nicht, ob es existiert.
* 2. **Je Ereignis**: der Inhalt wird mit den Rechten des Empfängers geladen.
* Eine Umklassifizierung mitten in einer offenen Verbindung wirkt dadurch
* sofort, ohne dass jemand neu laden muss.
*/
export async function GET(request: Request): Promise<Response> {
const user = await getCurrentUser()
if (!user) return new Response(null, { status: 404 })
const url = new URL(request.url)
const gewuenscht = (url.searchParams.get('topics') ?? '')
.split(',')
.map((t) => t.trim())
.filter(Boolean)
.slice(0, 20)
const erlaubt = await erlaubteThemen(user, gewuenscht)
await ensureListening()
const encoder = new TextEncoder()
let heartbeat: ReturnType<typeof setInterval> | null = null
let unsubscribe: (() => void) | null = null
let offen = true
const stream = new ReadableStream({
async start(controller) {
const send = (event: string, data: unknown, id?: string) => {
if (!offen) return
try {
const zeilen = [
...(id ? [`id: ${id}`] : []),
`event: ${event}`,
`data: ${JSON.stringify(data)}`,
'',
'',
]
controller.enqueue(encoder.encode(zeilen.join('\n')))
} catch {
offen = false
}
}
// Verpasstes nachliefern. Der Browser schickt beim Wiederverbinden von
// selbst die zuletzt gesehene Kennung mit; die Kennungen sind UUIDv7 und
// damit zeitlich sortiert, ein Vergleich darauf reicht.
const seit = request.headers.get('last-event-id') ?? url.searchParams.get('since')
if (seit) await nachliefern(user, erlaubt, seit, send)
send('ready', { topics: erlaubt })
heartbeat = setInterval(() => {
if (!offen) return
try {
controller.enqueue(encoder.encode(': ping\n\n'))
} catch {
offen = false
}
}, HEARTBEAT_MS)
unsubscribe = subscribe((event) => {
if (!erlaubt.includes(event.t)) return
void zustellen(user, event, send)
})
request.signal.addEventListener('abort', () => {
offen = false
try {
controller.close()
} catch {
// schon zu
}
})
},
cancel() {
offen = false
if (heartbeat) clearInterval(heartbeat)
unsubscribe?.()
},
})
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
// Der Tunnel darf nicht puffern, sonst kommt alles am Stück.
'X-Accel-Buffering': 'no',
},
})
}
/**
* Filtert die angefragten Themen auf die, die der Nutzer sehen darf.
*
* Das eigene Nutzerthema ist immer dabei, alles andere muss belegt werden.
*/
async function erlaubteThemen(user: CurrentUser, gewuenscht: string[]): Promise<Topic[]> {
const themen: Topic[] = [`user:${user.id}`]
const fallIds = gewuenscht
.filter((t) => t.startsWith('case:'))
.map((t) => t.slice(5))
.filter(Boolean)
const kanalIds = gewuenscht
.filter((t) => t.startsWith('channel:'))
.map((t) => t.slice(8))
.filter(Boolean)
if (fallIds.length > 0) {
const sichtbar = await db.case.findMany({
where: { AND: [visibleCaseWhere(user.permissions), { id: { in: fallIds } }] },
select: { id: true },
})
themen.push(...sichtbar.map((akte) => `case:${akte.id}` as Topic))
}
if (kanalIds.length > 0) {
const sichtbar = await db.channel.findMany({
where: { AND: [visibleChannelWhere(user.permissions), { id: { in: kanalIds } }] },
select: { id: true },
})
themen.push(...sichtbar.map((kanal) => `channel:${kanal.id}` as Topic))
}
return themen
}
type Send = (event: string, data: unknown, id?: string) => void
/** Was seit `seit` in den abonnierten Themen passiert ist. */
async function nachliefern(
user: CurrentUser,
themen: Topic[],
seit: string,
send: Send,
): Promise<void> {
const fallIds = themen.filter((t) => t.startsWith('case:')).map((t) => t.slice(5))
const kanalIds = themen.filter((t) => t.startsWith('channel:')).map((t) => t.slice(8))
if (fallIds.length === 0 && kanalIds.length === 0) return
try {
const verpasst = await db.chatMessage.findMany({
where: {
AND: [
visibleMessageWhere(user.permissions),
{ id: { gt: seit } },
{ OR: [{ caseId: { in: fallIds } }, { channelId: { in: kanalIds } }] },
],
},
orderBy: { id: 'asc' },
take: 200,
select: NACHRICHT,
})
for (const angereichert of await anreichern(user, verpasst)) {
send('chat.new', angereichert, angereichert.id)
}
} catch (error) {
console.error('[stream] Nachlieferung fehlgeschlagen', error)
}
}
const NACHRICHT = {
id: true,
body: true,
authorId: true,
replyToId: true,
editedAt: true,
withdrawnAt: true,
adoptedEntryId: true,
mentions: true,
createdAt: true,
caseId: true,
channelId: true,
} as const
/**
* Ergänzt, was die Anzeige braucht und was **je Empfänger verschieden** ist:
* den Namen des Autors und die Vorschaukarten zu erwähnten Aktenzeichen. Die
* Karten werden mit den Rechten des Empfängers aufgelöst, deshalb hier und
* nicht beim Absender.
*/
function vorschauenFuer(
body: string,
alle: Map<string, CasePreview>,
): Record<string, CasePreview> {
const treffer: Record<string, CasePreview> = {}
for (const nummer of extractCaseNumbers(body)) {
const vorschau = alle.get(nummer)
if (vorschau) treffer[nummer] = vorschau
}
return treffer
}
async function anreichern(
user: CurrentUser,
nachrichten: {
id: string
body: string
authorId: string
replyToId: string | null
editedAt: Date | null
withdrawnAt: Date | null
adoptedEntryId: string | null
mentions: string[]
createdAt: Date
}[],
): Promise<ChatMessageView[]> {
if (nachrichten.length === 0) return []
const [autoren, vorschauen] = await Promise.all([
db.user.findMany({
where: { id: { in: [...new Set(nachrichten.map((n) => n.authorId))] } },
select: { id: true, displayName: true, username: true },
}),
resolveCasePreviews(
user.permissions,
nachrichten.flatMap((n) => extractCaseNumbers(n.body)),
),
])
const nameVon = new Map(autoren.map((a) => [a.id, a.displayName]))
return nachrichten.map((nachricht) => ({
id: nachricht.id,
body: nachricht.withdrawnAt ? '' : nachricht.body,
authorId: nachricht.authorId,
authorName: nameVon.get(nachricht.authorId) ?? nachricht.authorId,
replyToId: nachricht.replyToId,
editedAt: nachricht.editedAt?.toISOString() ?? null,
withdrawnAt: nachricht.withdrawnAt?.toISOString() ?? null,
adoptedEntryId: nachricht.adoptedEntryId,
createdAt: nachricht.createdAt.toISOString(),
previews: vorschauenFuer(nachricht.body, vorschauen),
}))
}
/**
* Lädt den Inhalt zu einem Ereignis **mit den Rechten des Empfängers**.
*
* Findet die Abfrage nichts, wird nichts geschickt: weder Inhalt noch die
* Kennung. Sonst verriete schon die Kennung, dass es etwas gibt.
*/
async function zustellen(user: CurrentUser, event: StreamEvent, send: Send): Promise<void> {
try {
if (event.k.startsWith('chat.')) {
const nachricht = await db.chatMessage.findFirst({
where: { AND: [visibleMessageWhere(user.permissions), { id: event.i }] },
select: NACHRICHT,
})
if (!nachricht) return
const [angereichert] = await anreichern(user, [nachricht])
if (angereichert) send(event.k, angereichert, angereichert.id)
return
}
if (event.k === 'attachment.scanned') {
const anhang = await db.attachment.findFirst({
where: { AND: [visibleAttachmentWhere(user.permissions), { id: event.i }] },
select: {
id: true,
scanStatus: true,
scanMessage: true,
mimeType: true,
sizeBytes: true,
thumbKey: true,
blurhash: true,
width: true,
height: true,
},
})
if (anhang) {
send('attachment.scanned', { ...anhang, hasThumb: anhang.thumbKey !== null, thumbKey: undefined })
}
return
}
// Verlaufseinträge und Kopfdaten: nur die Kennung. Die Ansicht lädt
// daraufhin nach, statt dass hier ein zweiter Renderpfad entsteht.
send(event.k, { id: event.i })
} catch (error) {
console.error('[stream] Ereignis nicht zugestellt', error)
}
}
+106
View File
@@ -0,0 +1,106 @@
'use client'
import { useState, useTransition, type ReactNode } from 'react'
import { commonText } from '@/lib/i18n/common'
/**
* Ein aufklappbares Formular zum Hinzufügen.
*
* Beteiligte, Verknüpfungen, Dokumente, Zuordnungen — überall derselbe Ablauf:
* ein Knopf, ein kleines Formular, eine Server Action, eine Fehlermeldung an
* Ort und Stelle. Das viermal zu wiederholen kostete nicht nur Zeilen, sondern
* auch Bündelgrösse auf der Fallakte, die ohnehin die dichteste Seite ist.
*
* Der Rückruf bekommt `schliessen`, damit das Formular nur nach einem Erfolg
* zugeht — ein Fehler soll die Eingabe nicht wegräumen.
*/
export function AddPanel({
label,
hint,
children,
submitLabel,
onSubmit,
disabled,
}: {
label: string
hint?: string
/** Die Eingabefelder. Bekommen den Wartezustand über `disabled`. */
children: ReactNode
submitLabel?: string
onSubmit: () => Promise<{ ok: boolean; error?: string }>
disabled?: boolean
}) {
const [offen, setOffen] = useState(false)
const [fehler, setFehler] = useState<string | null>(null)
const [pending, startTransition] = useTransition()
if (!offen) {
return (
<button
type="button"
onClick={() => setOffen(true)}
className="mt-4 border border-line bg-raised px-3 py-1 font-condensed text-2xs text-accent"
>
{label}
</button>
)
}
return (
<div className="mt-4 border border-line p-3">
{hint ? <p className="max-w-(--container-measure) text-2xs text-text-dim">{hint}</p> : null}
<div className="mt-3 flex flex-wrap items-center gap-2">
{children}
<button
type="button"
disabled={pending || disabled}
onClick={() =>
startTransition(async () => {
setFehler(null)
const ergebnis = await onSubmit()
if (ergebnis.ok) setOffen(false)
else setFehler(ergebnis.error ?? commonText.retry)
})
}
className="border border-line bg-raised px-3 py-1 font-condensed text-2xs text-accent disabled:text-text-dim"
>
{submitLabel ?? label}
</button>
<button
type="button"
onClick={() => setOffen(false)}
className="font-condensed text-2xs text-text-dim"
>
{commonText.cancel}
</button>
</div>
{fehler ? <p className="mt-2 font-condensed text-2xs text-stamp-4">{fehler}</p> : null}
</div>
)
}
/** Die Auswahlfelder in einem `AddPanel` sehen überall gleich aus. */
export function PanelSelect({
value,
onChange,
label,
children,
}: {
value: string
onChange: (value: string) => void
label: string
children: ReactNode
}) {
return (
<select
value={value}
onChange={(event) => onChange(event.target.value)}
aria-label={label}
className="border border-line bg-raised px-2 py-1 text-2xs"
>
{children}
</select>
)
}
+391
View File
@@ -0,0 +1,391 @@
'use client'
import { useCallback, useEffect, useMemo, useRef, useState, useTransition } from 'react'
import { uuidv7 } from '@/lib/uuidv7'
import { chatText } from '@/lib/i18n/chat'
import { commonText } from '@/lib/i18n/common'
import { REACTIONS } from '@/lib/chat/terms'
import { useStream } from '@/components/realtime/useStream'
import { MessageBody } from './MessageBody'
import {
adoptAsEntryAction,
editMessageAction,
markReadAction,
sendMessageAction,
toggleReactionAction,
withdrawMessageAction,
} from '@/app/(app)/chat/actions'
import type { ChatMessageView } from '@/lib/chat/view'
export type ChatScopeView =
| { kind: 'case'; caseNumber: string; caseId: string }
| { kind: 'channel'; key: string; channelId: string }
type Reaktion = { messageId: string; userId: string; key: string }
/**
* Aktenchat und Kanalchat.
*
* Optisch klar vom Verlauf abgesetzt, weil er inhaltlich etwas anderes ist:
* formlos, kein Aktenbestandteil. Die Zeitachse des Verlaufs hat eine
* durchgehende Linie, der Chat nicht — der Unterschied muss ohne Beschriftung
* erkennbar sein. Siehe docs/10-design-system.md.
*
* Gesendet wird optimistisch: die Nachricht steht sofort da, mit einer
* clientseitig erzeugten UUIDv7 als Kennung. Kommt dieselbe Kennung über den
* Strom zurück, wird der optimistische Eintrag ersetzt statt verdoppelt — und
* ein wiederholtes Absenden nach einem Netzfehler erzeugt keine zweite
* Nachricht. Siehe docs/09-performance-und-ux.md.
*/
export function Chat({
scope,
topic,
initial,
initialReactions,
currentUserId,
lastReadAt,
canAdopt,
mentionNames,
}: {
scope: ChatScopeView
topic: string
initial: ChatMessageView[]
initialReactions: Reaktion[]
currentUserId: string
lastReadAt: string | null
canAdopt: boolean
mentionNames: Record<string, string>
}) {
const [nachrichten, setNachrichten] = useState(initial)
const [reaktionen, setReaktionen] = useState(initialReactions)
const [text, setText] = useState('')
const [antwortAuf, setAntwortAuf] = useState<string | null>(null)
const [bearbeitet, setBearbeitet] = useState<string | null>(null)
const [entwurf, setEntwurf] = useState('')
const [fehler, setFehler] = useState<string | null>(null)
const [pending, startTransition] = useTransition()
const ende = useRef<HTMLDivElement>(null)
// Die Trennlinie friert beim ersten Rendern ein: sie soll nicht
// verschwinden, während man liest.
const [grenze] = useState(lastReadAt)
const namen = useMemo(() => new Map(Object.entries(mentionNames)), [mentionNames])
const eingetroffen = useCallback((data: unknown) => {
const nachricht = data as ChatMessageView
setNachrichten((bisher) => {
const index = bisher.findIndex((n) => n.id === nachricht.id)
// Dedupe über die Kennung: der optimistische Eintrag wird ersetzt.
if (index >= 0) {
const kopie = [...bisher]
kopie[index] = nachricht
return kopie
}
return [...bisher, nachricht]
})
}, [])
useStream([topic], {
'chat.new': eingetroffen,
'chat.edit': eingetroffen,
'chat.withdrawn': eingetroffen,
'chat.adopted': eingetroffen,
'chat.reaction': () => {
// Reaktionen sind zu klein für ein eigenes Nutzlastformat; die Ansicht
// holt sie beim nächsten Laden nach.
},
})
useEffect(() => {
ende.current?.scrollIntoView({ block: 'nearest' })
}, [nachrichten.length])
// Beim Verlassen der Seite die Lesemarke setzen, nicht beim Öffnen: sonst
// wäre die Trennlinie beim Neuladen sofort weg.
useEffect(() => {
const scopeId = scope.kind === 'case' ? scope.caseId : scope.channelId
return () => {
void markReadAction({ scopeType: scope.kind, scopeId })
}
}, [scope])
function senden() {
const inhalt = text.trim()
if (!inhalt) return
const id = uuidv7()
const optimistisch: ChatMessageView = {
id,
body: inhalt,
authorId: currentUserId,
authorName: namen.get('__self__') ?? commonText.loading,
replyToId: antwortAuf,
editedAt: null,
withdrawnAt: null,
adoptedEntryId: null,
createdAt: new Date().toISOString(),
previews: {},
}
setNachrichten((bisher) => [...bisher, optimistisch])
setText('')
setAntwortAuf(null)
setFehler(null)
startTransition(async () => {
const ergebnis = await sendMessageAction({
id,
scope: scope.kind === 'case' ? { kind: 'case', caseNumber: scope.caseNumber } : { kind: 'channel', key: scope.key },
body: inhalt,
replyToId: optimistisch.replyToId,
})
if (!ergebnis.ok) {
// Der optimistische Eintrag verschwindet wieder, der Text kommt
// zurück ins Feld. Nichts geht verloren.
setNachrichten((bisher) => bisher.filter((n) => n.id !== id))
setText(inhalt)
setFehler(ergebnis.error)
}
})
}
const nachId = new Map(nachrichten.map((n) => [n.id, n]))
return (
<div className="border border-line">
<div className="border-b border-line px-4 py-2">
<p className="font-condensed text-xs text-text-dim">{chatText.heading}</p>
<p className="mt-0.5 text-2xs text-text-dim">{chatText.hint}</p>
</div>
<ol className="max-h-[28rem] space-y-4 overflow-y-auto px-4 py-4">
{nachrichten.length === 0 ? (
<li className="text-text-dim">{chatText.empty}</li>
) : null}
{nachrichten.map((nachricht, index) => {
const vorherige = nachrichten[index - 1]
const neuAbHier =
grenze !== null &&
nachricht.createdAt > grenze &&
(!vorherige || vorherige.createdAt <= grenze)
return (
<li key={nachricht.id}>
{neuAbHier ? (
<p className="mb-3 flex items-center gap-3 font-condensed text-2xs text-stamp-3">
<span className="h-px grow bg-stamp-3" />
{chatText.unreadDivider}
<span className="h-px grow bg-stamp-3" />
</p>
) : null}
{nachricht.replyToId ? (
<p className="mb-1 border-l-(length:--spacing-edge) border-line pl-2 text-2xs text-text-dim">
{chatText.replyingTo} {nachId.get(nachricht.replyToId)?.authorName ?? '—'}:{' '}
{(nachId.get(nachricht.replyToId)?.body ?? '').slice(0, 80)}
</p>
) : null}
<div className="flex flex-wrap items-baseline gap-x-3 text-2xs text-text-dim">
<span className="font-condensed">{nachricht.authorName}</span>
<time dateTime={nachricht.createdAt}>
{new Date(nachricht.createdAt).toLocaleString('de-DE')}
</time>
{nachricht.editedAt ? <span>{chatText.edited}</span> : null}
{nachricht.adoptedEntryId ? (
<span className="font-condensed text-accent">{chatText.adopted}</span>
) : null}
</div>
{nachricht.withdrawnAt ? (
<p className="mt-0.5 text-sm text-text-dim italic">{chatText.withdrawn}</p>
) : bearbeitet === nachricht.id ? (
<div className="mt-1">
<textarea
value={entwurf}
onChange={(event) => setEntwurf(event.target.value)}
rows={2}
className="block w-full border border-line bg-raised px-3 py-2 text-sm"
/>
<div className="mt-1 flex gap-3">
<button
type="button"
disabled={pending}
onClick={() =>
startTransition(async () => {
const ergebnis = await editMessageAction({
id: nachricht.id,
body: entwurf,
})
if (ergebnis.ok) setBearbeitet(null)
else setFehler(ergebnis.error)
})
}
className="font-condensed text-2xs text-accent"
>
{chatText.editSave}
</button>
<button
type="button"
onClick={() => setBearbeitet(null)}
className="font-condensed text-2xs text-text-dim"
>
{commonText.cancel}
</button>
</div>
</div>
) : (
<div className="mt-0.5">
<MessageBody
body={nachricht.body}
mentionNames={namen}
previews={new Map(Object.entries(nachricht.previews))}
/>
</div>
)}
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-2xs">
{REACTIONS.map((key) => {
const anzahl = reaktionen.filter(
(r) => r.messageId === nachricht.id && r.key === key,
).length
const eigene = reaktionen.some(
(r) => r.messageId === nachricht.id && r.key === key && r.userId === currentUserId,
)
return (
<button
key={key}
type="button"
title={chatText.reactions[key]}
onClick={() =>
startTransition(async () => {
// Sofort umschalten, der Server bestätigt nur.
setReaktionen((bisher) =>
eigene
? bisher.filter(
(r) =>
!(r.messageId === nachricht.id && r.key === key && r.userId === currentUserId),
)
: [...bisher, { messageId: nachricht.id, userId: currentUserId, key }],
)
await toggleReactionAction({ id: nachricht.id, key })
})
}
className={`border px-1.5 py-0.5 font-mono ${
eigene ? 'border-accent text-accent' : 'border-line text-text-dim'
}`}
>
{chatText.reactionSymbols[key]}
{anzahl > 0 ? ` ${anzahl}` : ''}
</button>
)
})}
{!nachricht.withdrawnAt ? (
<button
type="button"
onClick={() => setAntwortAuf(nachricht.id)}
className="font-condensed text-text-dim"
>
{chatText.reply}
</button>
) : null}
{/* Die Brücke zum Aktenbestand steht sichtbar an jeder
Nachricht, nicht in einem Kontextmenü. Siehe docs/06. */}
{canAdopt && !nachricht.withdrawnAt && !nachricht.adoptedEntryId ? (
<button
type="button"
disabled={pending}
onClick={() =>
startTransition(async () => {
const ergebnis = await adoptAsEntryAction({ id: nachricht.id })
if (!ergebnis.ok) setFehler(ergebnis.error)
})
}
className="font-condensed text-accent"
>
{chatText.adopt}
</button>
) : null}
{nachricht.authorId === currentUserId && !nachricht.withdrawnAt ? (
<>
<button
type="button"
onClick={() => {
setBearbeitet(nachricht.id)
setEntwurf(nachricht.body)
}}
className="font-condensed text-text-dim"
>
{chatText.edit}
</button>
<button
type="button"
disabled={pending}
onClick={() =>
startTransition(async () => {
await withdrawMessageAction({ id: nachricht.id })
})
}
className="font-condensed text-text-dim"
>
{chatText.withdraw}
</button>
</>
) : null}
</div>
</li>
)
})}
<div ref={ende} />
</ol>
<div className="border-t border-line px-4 py-3">
{antwortAuf ? (
<p className="mb-2 flex items-center gap-3 text-2xs text-text-dim">
{chatText.replyingTo} {nachId.get(antwortAuf)?.authorName ?? '—'}
<button
type="button"
onClick={() => setAntwortAuf(null)}
className="font-condensed"
>
{chatText.cancelReply}
</button>
</p>
) : null}
<textarea
value={text}
onChange={(event) => setText(event.target.value)}
onKeyDown={(event) => {
// Eingabetaste sendet, Umschalt+Eingabe macht einen Umbruch.
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault()
senden()
}
}}
rows={2}
placeholder={chatText.placeholder}
aria-label={chatText.placeholder}
className="block w-full border border-line bg-raised px-3 py-2 text-sm"
/>
<div className="mt-2 flex items-center gap-3">
<button
type="button"
onClick={senden}
disabled={text.trim() === ''}
className="border border-line bg-raised px-3 py-1 font-condensed text-2xs text-accent disabled:text-text-dim"
>
{chatText.send}
</button>
{fehler ? <span className="font-condensed text-2xs text-stamp-4">{fehler}</span> : null}
</div>
</div>
</div>
)
}
+73
View File
@@ -0,0 +1,73 @@
'use client'
import dynamic from 'next/dynamic'
import { useEffect, useRef, useState, type ComponentProps } from 'react'
import { chatText } from '@/lib/i18n/chat'
import { SkeletonLine } from '@/components/Skeleton'
import type { Chat } from './Chat'
const ChatInner = dynamic(() => import('./Chat').then((modul) => modul.Chat), {
loading: () => <Platzhalter />,
// Der Chat wird ohnehin erst nach dem Scrollen gebraucht und lebt von einer
// offenen Verbindung. Ihn auf dem Server mitzurendern brächte nichts und
// zöge sein Bündel in die erste Ladung der Fallakte.
ssr: false,
})
/**
* Lädt den Chat erst, wenn er in Sichtweite kommt.
*
* Zwei Gründe, beide handfest: die Fallakte ist das, weswegen die Seite
* geöffnet wird — ihr Bündelbudget soll nicht für einen Chat draufgehen, den
* viele nie aufklappen. Und der Chat hält eine offene Verbindung zum
* Ereignisstrom; wer nur die Akte liest, soll sie nicht aufbauen.
*
* Der Platzhalter hat die Höhe des Chats, damit beim Nachladen nichts springt.
* Siehe docs/09-performance-und-ux.md.
*/
export function ChatLazy(props: ComponentProps<typeof Chat>) {
const [sichtbar, setSichtbar] = useState(false)
const anker = useRef<HTMLDivElement>(null)
useEffect(() => {
const element = anker.current
if (!element) return
// Ohne IntersectionObserver (sehr alte Browser) sofort laden: lieber
// grösser als kaputt.
if (typeof IntersectionObserver === 'undefined') {
setSichtbar(true)
return
}
const beobachter = new IntersectionObserver(
(eintraege) => {
if (eintraege.some((eintrag) => eintrag.isIntersecting)) {
setSichtbar(true)
beobachter.disconnect()
}
},
// Etwas vorher anfangen, damit er beim Ankommen schon steht.
{ rootMargin: '300px' },
)
beobachter.observe(element)
return () => beobachter.disconnect()
}, [])
return <div ref={anker}>{sichtbar ? <ChatInner {...props} /> : <Platzhalter />}</div>
}
function Platzhalter() {
return (
<div className="border border-line">
<div className="border-b border-line px-4 py-2">
<p className="font-condensed text-xs text-text-dim">{chatText.heading}</p>
</div>
<div className="space-y-3 px-4 py-4">
<SkeletonLine width="12rem" />
<SkeletonLine width="70%" />
<SkeletonLine width="55%" />
</div>
</div>
)
}
+74
View File
@@ -0,0 +1,74 @@
import Link from 'next/link'
import { chatText } from '@/lib/i18n/chat'
import { splitBody } from '@/lib/chat/mentions'
import { stampColor } from '@/components/StampMark'
import type { CasePreview } from '@/lib/chat/preview'
/**
* Nachrichtentext mit hervorgehobenen Erwähnungen und Aktenzeichen.
*
* Der Text wird **zerlegt**, nicht als HTML gebaut: kein
* `dangerouslySetInnerHTML` für Nutzerinhalte, siehe docs/07.
*
* Die Vorschaukarte zu einem Aktenzeichen ist serverseitig aufgelöst. Eine
* Akte, die der Leser nicht sehen darf, sieht hier genauso aus wie eine, die
* es gar nicht gibt.
*/
export function MessageBody({
body,
mentionNames,
previews,
}: {
body: string
/** Anzeigename je erwähntem Benutzernamen, kleingeschrieben. */
mentionNames: Map<string, string>
previews: Map<string, CasePreview>
}) {
const stuecke = splitBody(body)
return (
<p className="max-w-(--container-measure) text-sm whitespace-pre-wrap">
{stuecke.map((stueck, index) => {
if (stueck.kind === 'text') return <span key={index}>{stueck.value}</span>
if (stueck.kind === 'mention') {
const name = stueck.value.slice(1).toLowerCase()
const bekannt = mentionNames.has(name)
return (
<span
key={index}
className={bekannt ? 'font-condensed text-accent' : undefined}
title={bekannt ? mentionNames.get(name) : undefined}
>
{stueck.value}
</span>
)
}
const vorschau = previews.get(stueck.value)
if (!vorschau) return <span key={index}>{stueck.value}</span>
if (!vorschau.visible) {
return (
<span key={index} className="font-mono text-2xs text-text-dim">
{stueck.value} · {chatText.hiddenCase}
</span>
)
}
return (
<Link
key={index}
href={`/faelle/${vorschau.caseNumber}`}
prefetch
className="inline-flex items-baseline gap-2 border-l-(length:--spacing-edge) pl-2 no-underline"
style={{ borderColor: stampColor(vorschau.classification) }}
>
<span className="font-mono text-2xs text-text-dim">{vorschau.caseNumber}</span>
<span className="text-sm">{vorschau.title}</span>
</Link>
)
})}
</p>
)
}
+5 -2
View File
@@ -4,7 +4,7 @@ import { useCallback, useRef, useState, useTransition } from 'react'
import { attachmentText } from '@/lib/i18n/documents' import { attachmentText } from '@/lib/i18n/documents'
import { MAX_FILE_BYTES, formatBytes } from '@/lib/files/limits' import { MAX_FILE_BYTES, formatBytes } from '@/lib/files/limits'
import { releaseAttachmentAction } from '@/app/(app)/dateien/actions' import { releaseAttachmentAction } from '@/app/(app)/dateien/actions'
import { useScanEvents, type ScanEvent } from './useScanEvents' import { useStream, type ScanEvent } from '@/components/realtime/useStream'
export type AttachmentView = { export type AttachmentView = {
id: string id: string
@@ -39,11 +39,14 @@ type Laufend = { key: string; name: string; progress: number; error?: string }
*/ */
export function Attachments({ export function Attachments({
target, target,
topic,
initial, initial,
canUpload, canUpload,
canRelease, canRelease,
}: { }: {
target: UploadTarget target: UploadTarget
/** Thema des Besitzers, damit die Prüfergebnisse ankommen. */
topic: string
initial: AttachmentView[] initial: AttachmentView[]
canUpload: boolean canUpload: boolean
canRelease: boolean canRelease: boolean
@@ -73,7 +76,7 @@ export function Attachments({
) )
}, []) }, [])
useScanEvents(uebernehmen) useStream([topic], { 'attachment.scanned': (data) => uebernehmen(data as ScanEvent) })
function hochladen(datei: File) { function hochladen(datei: File) {
const key = `${datei.name}-${Date.now()}-${Math.random()}` const key = `${datei.name}-${Date.now()}-${Math.random()}`
+5 -2
View File
@@ -3,7 +3,7 @@
import { useCallback, useRef, useState } from 'react' import { useCallback, useRef, useState } from 'react'
import { attachmentText, documentText } from '@/lib/i18n/documents' import { attachmentText, documentText } from '@/lib/i18n/documents'
import { MAX_FILE_BYTES } from '@/lib/files/limits' import { MAX_FILE_BYTES } from '@/lib/files/limits'
import { useScanEvents, type ScanEvent } from './useScanEvents' import { useStream, type ScanEvent } from '@/components/realtime/useStream'
import type { SaveSection } from '@/components/sections/types' import type { SaveSection } from '@/components/sections/types'
/** /**
@@ -22,6 +22,7 @@ export function PhotoField({
fieldKey, fieldKey,
label, label,
documentNumber, documentNumber,
topic,
initialAttachmentId, initialAttachmentId,
initialUrl, initialUrl,
initialVersion, initialVersion,
@@ -31,6 +32,8 @@ export function PhotoField({
fieldKey: string fieldKey: string
label: string label: string
documentNumber: string documentNumber: string
/** Thema des Besitzers, damit das Prüfergebnis ankommt. */
topic: string
initialAttachmentId: string | null initialAttachmentId: string | null
initialUrl: string | null initialUrl: string | null
initialVersion: number initialVersion: number
@@ -61,7 +64,7 @@ export function PhotoField({
[wartetAuf], [wartetAuf],
) )
useScanEvents(geprueft) useStream([topic], { 'attachment.scanned': (data) => geprueft(data as ScanEvent) })
function waehlen(datei: File) { function waehlen(datei: File) {
setFehler(null) setFehler(null)
-43
View File
@@ -1,43 +0,0 @@
'use client'
import { useEffect } from 'react'
export type ScanEvent = {
id: string
scanStatus: 'PENDING' | 'CLEAN' | 'BLOCKED' | 'FAILED'
scanMessage: string | null
mimeType: string
sizeBytes: number
blurhash: string | null
width: number | null
height: number | null
hasThumb: boolean
}
/**
* Hört auf das Ende von Dateiprüfungen.
*
* Eine einzige Verbindung je Seite, vom Browser selbst wieder aufgebaut, wenn
* sie abreisst. Kein Abfragen im Sekundentakt: eine Prüfung dauert je nach
* Dateigrösse zwischen einer und dreissig Sekunden, und solange im Leerlauf zu
* fragen wäre auf einem Heimserver unhöflich.
*/
export function useScanEvents(onScanned: (event: ScanEvent) => void): void {
useEffect(() => {
const quelle = new EventSource('/api/events')
const handler = (message: MessageEvent<string>) => {
try {
onScanned(JSON.parse(message.data) as ScanEvent)
} catch {
// Kaputte Nutzlast ignorieren, der nächste Seitenaufbau hat den Stand.
}
}
quelle.addEventListener('attachment.scanned', handler as EventListener)
return () => {
quelle.removeEventListener('attachment.scanned', handler as EventListener)
quelle.close()
}
}, [onScanned])
}
+64
View File
@@ -0,0 +1,64 @@
'use client'
import { useEffect, useRef } from 'react'
/**
* Eine Verbindung zum Ereignisstrom, so lange die Ansicht offen ist.
*
* `EventSource` baut nach einem Abriss von selbst wieder auf und schickt dabei
* die zuletzt gesehene Kennung mit, sodass der Server nachliefern kann. Das
* Aufräumen bei Tabwechsel übernimmt der Browser: eine Verbindung ohne
* Verkehr kostet nichts.
*
* Die Rückrufe stehen in einem Ref, damit ein neu erzeugter Handler nicht die
* ganze Verbindung neu aufbaut — sonst reisst der Strom bei jedem Tastendruck
* im Eingabefeld ab.
*/
export type StreamHandlers = Record<string, (data: unknown) => void>
export function useStream(topics: string[], handlers: StreamHandlers): void {
const aktuell = useRef(handlers)
aktuell.current = handlers
// Die Themen als Zeichenkette, damit ein neues Array mit gleichem Inhalt
// keine neue Verbindung auslöst.
const schluessel = topics.filter(Boolean).sort().join(',')
useEffect(() => {
if (!schluessel) return
const quelle = new EventSource(`/api/stream?topics=${encodeURIComponent(schluessel)}`)
const angemeldet: [string, EventListener][] = []
for (const name of Object.keys(aktuell.current)) {
const listener: EventListener = (event) => {
const nachricht = event as MessageEvent<string>
try {
aktuell.current[name]?.(JSON.parse(nachricht.data))
} catch {
// Kaputte Nutzlast ignorieren; der nächste Seitenaufbau hat den Stand.
}
}
quelle.addEventListener(name, listener)
angemeldet.push([name, listener])
}
return () => {
for (const [name, listener] of angemeldet) quelle.removeEventListener(name, listener)
quelle.close()
}
// Absichtlich nur an den Themen: die Rückrufe hängen im Ref.
}, [schluessel])
}
export type ScanEvent = {
id: string
scanStatus: 'PENDING' | 'CLEAN' | 'BLOCKED' | 'FAILED'
scanMessage: string | null
mimeType: string
sizeBytes: number
blurhash: string | null
width: number | null
height: number | null
hasThumb: boolean
}
+4
View File
@@ -20,6 +20,7 @@ export function Sections({
sections, sections,
canEdit, canEdit,
documentNumber, documentNumber,
topic,
photoUrls, photoUrls,
}: { }: {
scope: string scope: string
@@ -28,6 +29,8 @@ export function Sections({
canEdit: boolean canEdit: boolean
/** Nur bei Dokumenten gesetzt: Ziel für das Lichtbild. */ /** Nur bei Dokumenten gesetzt: Ziel für das Lichtbild. */
documentNumber?: string documentNumber?: string
/** Thema für die Rückmeldung der Dateiprüfung. */
topic?: string
/** Bereits geprüfte Lichtbilder, je Feldschlüssel. */ /** Bereits geprüfte Lichtbilder, je Feldschlüssel. */
photoUrls?: Record<string, string> photoUrls?: Record<string, string>
}) { }) {
@@ -77,6 +80,7 @@ export function Sections({
fieldKey={field.key} fieldKey={field.key}
label={field.label} label={field.label}
documentNumber={documentNumber} documentNumber={documentNumber}
topic={topic ?? ''}
initialAttachmentId={ initialAttachmentId={
section.visible && typeof section.value === 'string' ? section.value : null section.visible && typeof section.value === 'string' ? section.value : null
} }
+2
View File
@@ -33,6 +33,8 @@ test.describe('Geschuetzte Bereiche', () => {
'/faelle/neu', '/faelle/neu',
'/personalakten', '/personalakten',
'/personalakten/neu', '/personalakten/neu',
'/kanaele',
'/einstellungen',
'/dokumente/AfiS-2026-0001', '/dokumente/AfiS-2026-0001',
'/admin/rollen', '/admin/rollen',
'/admin/system', '/admin/system',
+29
View File
@@ -0,0 +1,29 @@
import type { Prisma } from '@afis/db'
import type { EffectivePermissions } from '@/lib/auth/permissions'
import { visibleCaseWhere } from '@/lib/access/caseFilter'
/**
* Chat: Aktenchat und freie Kanäle.
*
* Der Aktenchat erbt die Sichtbarkeit seiner Akte — wer die Akte nicht lesen
* darf, sieht auch nicht, dass darüber geredet wird. Ein Kanal hat eine eigene
* Mindeststufe.
*
* Ein Kanal ist kein Geheimnis für sich: dass es ihn gibt, darf jeder sehen,
* der seine Stufe hat. Anders als bei Akten gibt es hier keine „Existenz ist
* auch eine Information" — ein Raum ohne Zugang wäre sonst nicht auffindbar,
* und jemand müsste ihn erraten, um Zugang zu erbitten.
*/
export function visibleChannelWhere(p: EffectivePermissions): Prisma.ChannelWhereInput {
if (p.isAdmin) return {}
return { minClearance: { lte: p.clearanceLevel } }
}
export function visibleMessageWhere(p: EffectivePermissions): Prisma.ChatMessageWhereInput {
return {
OR: [
{ case: { is: visibleCaseWhere(p) } },
{ channel: { is: visibleChannelWhere(p) } },
],
}
}
+2
View File
@@ -43,6 +43,8 @@ export type AuditAction =
| 'attachment.upload' | 'attachment.upload'
| 'attachment.read' | 'attachment.read'
| 'attachment.release' | 'attachment.release'
| 'chat.adopt'
| 'channel.create'
| 'roles.update' | 'roles.update'
| 'export.pdf' | 'export.pdf'
+22 -1
View File
@@ -5,6 +5,8 @@ import { canCreateCase, canEditCase, canReclassify } from '@/lib/access/caseWrit
import { nextNumber } from '@/lib/numbering' import { nextNumber } from '@/lib/numbering'
import { findTransition, type CaseStatusName } from '@/lib/cases/lifecycle' import { findTransition, type CaseStatusName } from '@/lib/cases/lifecycle'
import { AccessError, must } from '@/lib/errors' import { AccessError, must } from '@/lib/errors'
import { enqueueCaseNotification } from '@/lib/notify/enqueue'
import { caseTopic, publish } from '@/lib/realtime/publish'
import type { EffectivePermissions } from '@/lib/auth/permissions' import type { EffectivePermissions } from '@/lib/auth/permissions'
/** /**
@@ -115,7 +117,12 @@ export async function addEntry(
const minClearance = input.minClearance ?? 0 const minClearance = input.minClearance ?? 0
if (minClearance > p.clearanceLevel) throw new AccessError('clearance-too-low') if (minClearance > p.clearanceLevel) throw new AccessError('clearance-too-low')
return db.$transaction(async (tx) => { const verfasser = await db.user.findUnique({
where: { id: p.userId },
select: { displayName: true },
})
const angelegt = await db.$transaction(async (tx) => {
// Die clientseitige UUIDv7 ist zugleich Idempotenzschlüssel: ein // Die clientseitige UUIDv7 ist zugleich Idempotenzschlüssel: ein
// wiederholtes Absenden nach einem Netzfehler erzeugt keinen zweiten // wiederholtes Absenden nach einem Netzfehler erzeugt keinen zweiten
// Eintrag. Siehe docs/09-performance-und-ux.md. // Eintrag. Siehe docs/09-performance-und-ux.md.
@@ -134,6 +141,17 @@ export async function addEntry(
select: { id: true }, select: { id: true },
}) })
await enqueueCaseNotification(tx, {
kind: 'entry.new',
caseId: target.id,
caseNumber: target.caseNumber,
classification: target.classification,
title: target.title,
actorId: p.userId,
actorName: verfasser?.displayName ?? p.userId,
meta: { entryId: entry.id },
})
await writeAudit(tx, { await writeAudit(tx, {
actorId: p.userId, actorId: p.userId,
action: 'entry.create', action: 'entry.create',
@@ -145,6 +163,9 @@ export async function addEntry(
return entry return entry
}) })
await publish(caseTopic(target.id), 'entry.new', angelegt.id)
return angelegt
} }
export async function changeStatus( export async function changeStatus(
+69
View File
@@ -0,0 +1,69 @@
import { db } from '@/lib/db'
import { resolveCasePreviews } from '@/lib/chat/preview'
import { extractCaseNumbers, type ChatMessageView } from '@/lib/chat/view'
import { listMessages, listReactions, loadReadMarker, type ChatScope } from '@/lib/chat/service'
import type { EffectivePermissions } from '@/lib/auth/permissions'
/**
* Alles, was die Chatansicht beim ersten Rendern braucht.
*
* Die Vorschaukarten und die Namen werden **hier** aufgelöst, mit den Rechten
* des Lesers. Der Strom-Endpunkt tut für nachkommende Nachrichten dasselbe;
* die Anzeige selbst löst nie etwas auf.
*/
export async function loadChatView(p: EffectivePermissions, scope: ChatScope) {
const { ort, nachrichten } = await listMessages(p, scope)
const scopeId = ort.caseId ?? ort.channelId!
const [autoren, vorschauen, reaktionen, lastReadAt, mitglieder] = await Promise.all([
db.user.findMany({
where: { id: { in: [...new Set(nachrichten.map((n) => n.authorId))] } },
select: { id: true, displayName: true },
}),
resolveCasePreviews(p, nachrichten.flatMap((n) => extractCaseNumbers(n.body))),
listReactions(p, nachrichten.map((n) => n.id)),
loadReadMarker(p, ort.caseId ? 'case' : 'channel', scopeId),
// Für die Anzeige der Erwähnungen: Benutzername auf Anzeigename.
db.user.findMany({
where: { isActive: true },
take: 300,
select: { username: true, displayName: true },
}),
])
const nameVon = new Map(autoren.map((a) => [a.id, a.displayName]))
const views: ChatMessageView[] = nachrichten.map((nachricht) => {
const previews: ChatMessageView['previews'] = {}
for (const nummer of extractCaseNumbers(nachricht.body)) {
const vorschau = vorschauen.get(nummer)
if (vorschau) previews[nummer] = vorschau
}
return {
id: nachricht.id,
// Eine zurückgezogene Nachricht kommt ohne Text an. Nicht ausblenden:
// gar nicht erst schicken.
body: nachricht.withdrawnAt ? '' : nachricht.body,
authorId: nachricht.authorId,
authorName: nameVon.get(nachricht.authorId) ?? nachricht.authorId,
replyToId: nachricht.replyToId,
editedAt: nachricht.editedAt?.toISOString() ?? null,
withdrawnAt: nachricht.withdrawnAt?.toISOString() ?? null,
adoptedEntryId: nachricht.adoptedEntryId,
createdAt: nachricht.createdAt.toISOString(),
previews,
}
})
return {
ort,
scopeId,
messages: views,
reactions: reaktionen,
lastReadAt: lastReadAt?.toISOString() ?? null,
mentionNames: Object.fromEntries(
mitglieder.map((m) => [m.username.toLowerCase(), m.displayName]),
),
}
}
+65
View File
@@ -0,0 +1,65 @@
/**
* Erwähnungen im Nachrichtentext.
*
* **Ohne Abhängigkeiten**, weil die Oberfläche dieselbe Erkennung für die
* Autovervollständigung braucht. Aufgelöst wird trotzdem serverseitig: der
* Client schickt Text, der Server bestimmt, wer gemeint ist.
*/
/** `@name` — Buchstaben, Ziffern, Punkt, Unterstrich, Bindestrich. */
export const MENTION = /@([a-z0-9._-]{2,32})/gi
export function extractMentionNames(body: string): string[] {
return [...new Set([...body.matchAll(MENTION)].map((match) => match[1]!.toLowerCase()))]
}
/**
* Zerlegt einen Text in Stücke, damit die Anzeige Erwähnungen und
* Aktenzeichen hervorheben kann, ohne HTML aus Nutzertext zu bauen.
* Siehe docs/07: kein `dangerouslySetInnerHTML` für Nutzerinhalte.
*/
export type Piece =
| { kind: 'text'; value: string }
| { kind: 'mention'; value: string; userId: string | null }
| { kind: 'caseNumber'; value: string }
const CASE_NUMBER = /\b[A-Za-z][A-Za-z0-9-]*-\d{4}-\d{3,8}\b/g
export function splitBody(
body: string,
bekannt: Map<string, string> = new Map(),
): Piece[] {
const treffer: { start: number; end: number; piece: Piece }[] = []
for (const match of body.matchAll(MENTION)) {
const name = match[1]!.toLowerCase()
treffer.push({
start: match.index,
end: match.index + match[0].length,
piece: { kind: 'mention', value: match[0], userId: bekannt.get(name) ?? null },
})
}
for (const match of body.matchAll(CASE_NUMBER)) {
treffer.push({
start: match.index,
end: match.index + match[0].length,
piece: { kind: 'caseNumber', value: match[0] },
})
}
treffer.sort((a, b) => a.start - b.start)
const stuecke: Piece[] = []
let position = 0
for (const { start, end, piece } of treffer) {
// Überlappungen kommen vor: `@AfiS-2026-0001` ist beides. Der erste
// Treffer gewinnt, der zweite wird verworfen.
if (start < position) continue
if (start > position) stuecke.push({ kind: 'text', value: body.slice(position, start) })
stuecke.push(piece)
position = end
}
if (position < body.length) stuecke.push({ kind: 'text', value: body.slice(position) })
return stuecke
}
+49
View File
@@ -0,0 +1,49 @@
import { db } from '@/lib/db'
import { visibleCaseWhere } from '@/lib/access/caseFilter'
import type { EffectivePermissions } from '@/lib/auth/permissions'
/**
* Vorschaukarten für Aktenzeichen im Chattext.
*
* **Serverseitig aufgelöst, mit der Stufe des Lesers.** Wer die Akte nicht
* sehen darf, bekommt eine neutrale Karte ohne Titel und ohne Status — nie den
* Titel mitschicken und im Client ausblenden. Siehe docs/06.
*
* Ob eine Akte existiert, verrät die Karte damit auch nicht: eine unbekannte
* und eine verborgene Akte sehen gleich aus.
*/
export type CasePreview =
| { caseNumber: string; visible: true; title: string; status: string; classification: number }
| { caseNumber: string; visible: false }
export async function resolveCasePreviews(
p: EffectivePermissions,
caseNumbers: string[],
): Promise<Map<string, CasePreview>> {
const eindeutig = [...new Set(caseNumbers)].slice(0, 50)
if (eindeutig.length === 0) return new Map()
const treffer = await db.case.findMany({
where: { AND: [visibleCaseWhere(p), { caseNumber: { in: eindeutig } }] },
select: { caseNumber: true, title: true, status: true, classification: true },
})
const sichtbar = new Map(treffer.map((akte) => [akte.caseNumber, akte]))
return new Map(
eindeutig.map((nummer) => {
const akte = sichtbar.get(nummer)
return [
nummer,
akte
? {
caseNumber: nummer,
visible: true as const,
title: akte.title,
status: akte.status,
classification: akte.classification,
}
: { caseNumber: nummer, visible: false as const },
]
}),
)
}
+487
View File
@@ -0,0 +1,487 @@
import { db } from '@/lib/db'
import { writeAudit } from '@/lib/audit'
import { AccessError, must } from '@/lib/errors'
import { visibleCaseWhere } from '@/lib/access/caseFilter'
import { visibleChannelWhere, visibleMessageWhere } from '@/lib/access/chatFilter'
import { canEditCase } from '@/lib/access/caseWrite'
import { extractMentionNames } from '@/lib/chat/mentions'
import type { ReactionKey } from '@/lib/chat/terms'
import { caseTopic, channelTopic, publish, type Topic } from '@/lib/realtime/publish'
import { enqueueCaseNotification } from '@/lib/notify/enqueue'
import type { EffectivePermissions } from '@/lib/auth/permissions'
import type { Prisma } from '@afis/db'
/**
* Aktenchat und freie Kanäle.
*
* Der Chat ist **ausdrücklich kein Aktenbestandteil** — er ist der formlose
* Teil, den die Abteilung bisher auf Discord hatte. Deshalb darf hier
* bearbeitet und zurückgezogen werden, ohne dass eine Version entsteht.
*
* Die Brücke zum Aktenbestand ist `adoptAsEntry`: eine Nachricht wird per
* Klick zu einem Vermerk. Ab da gelten die harten Regeln der Akte.
* Siehe docs/06-chat-und-realtime.md.
*/
export type ChatScope = { kind: 'case'; caseNumber: string } | { kind: 'channel'; key: string }
const NACHRICHT = {
id: true,
body: true,
authorId: true,
replyToId: true,
editedAt: true,
withdrawnAt: true,
adoptedEntryId: true,
mentions: true,
createdAt: true,
caseId: true,
channelId: true,
} satisfies Prisma.ChatMessageSelect
/**
* Löst den Ort auf und prüft, ob der Nutzer ihn überhaupt sehen darf.
*
* Gibt `null` statt zu werfen, wo der Aufrufer selbst über 404 entscheidet.
*/
export async function resolveScope(
p: EffectivePermissions,
scope: ChatScope,
): Promise<{ caseId?: string; channelId?: string; topic: Topic; label: string } | null> {
if (scope.kind === 'case') {
const akte = await db.case.findFirst({
where: { AND: [visibleCaseWhere(p), { caseNumber: scope.caseNumber }] },
select: { id: true, caseNumber: true },
})
return akte ? { caseId: akte.id, topic: caseTopic(akte.id), label: akte.caseNumber } : null
}
const kanal = await db.channel.findFirst({
where: { AND: [visibleChannelWhere(p), { key: scope.key, isArchived: false }] },
select: { id: true, key: true },
})
return kanal
? { channelId: kanal.id, topic: channelTopic(kanal.id), label: kanal.key }
: null
}
/**
* Die Nachrichten eines Ortes, neueste zuletzt.
*
* `since` blättert nach vorn und wird beim Wiederverbinden benutzt: die
* Kennungen sind UUIDv7 und damit zeitlich sortiert, ein Vergleich auf der
* Kennung reicht also, um „alles danach" zu holen.
*/
export async function listMessages(
p: EffectivePermissions,
scope: ChatScope,
options: { since?: string; take?: number } = {},
) {
const ort = await resolveScope(p, scope)
if (!ort) throw new AccessError('not-found')
const wo: Prisma.ChatMessageWhereInput = ort.caseId
? { caseId: ort.caseId }
: { channelId: ort.channelId }
const nachrichten = await db.chatMessage.findMany({
where: { AND: [wo, ...(options.since ? [{ id: { gt: options.since } }] : [])] },
orderBy: { id: options.since ? 'asc' : 'desc' },
take: options.take ?? 50,
select: NACHRICHT,
})
if (!options.since) nachrichten.reverse()
return { ort, nachrichten }
}
export async function loadMessage(p: EffectivePermissions, id: string) {
return db.chatMessage.findFirst({
where: { AND: [visibleMessageWhere(p), { id }] },
select: NACHRICHT,
})
}
/**
* Nachricht senden.
*
* Die Kennung kommt vom Client (UUIDv7) und ist zugleich Idempotenzschlüssel:
* ein wiederholtes Absenden nach einem Netzfehler erzeugt keine zweite
* Nachricht. Siehe docs/09-performance-und-ux.md.
*/
export async function sendMessage(
p: EffectivePermissions,
input: { id: string; scope: ChatScope; body: string; replyToId?: string | null },
): Promise<{ id: string }> {
const ort = await resolveScope(p, input.scope)
if (!ort) throw new AccessError('not-found')
const text = input.body.trim()
if (text.length === 0) throw new AccessError('message-empty')
if (text.length > 4000) throw new AccessError('message-too-long')
const vorhanden = await db.chatMessage.findUnique({
where: { id: input.id },
select: { id: true },
})
if (vorhanden) return vorhanden
// Erwähnungen serverseitig auflösen: der Client schickt Text, wer gemeint
// ist, bestimmt der Server. Sonst könnte man jeden benachrichtigen lassen.
const namen = extractMentionNames(text)
const erwaehnt = namen.length
? await db.user.findMany({
where: { isActive: true, username: { in: namen, mode: 'insensitive' } },
select: { id: true },
})
: []
const absender = await db.user.findUnique({
where: { id: p.userId },
select: { displayName: true },
})
const absenderName = absender?.displayName ?? p.userId
// Eine Antwort muss auf eine Nachricht desselben Ortes zeigen.
let replyToId: string | null = null
if (input.replyToId) {
const ziel = await db.chatMessage.findFirst({
where: {
id: input.replyToId,
...(ort.caseId ? { caseId: ort.caseId } : { channelId: ort.channelId }),
},
select: { id: true },
})
replyToId = ziel?.id ?? null
}
const angelegt = await db.$transaction(async (tx) => {
const nachricht = await tx.chatMessage.create({
data: {
id: input.id,
caseId: ort.caseId ?? null,
channelId: ort.channelId ?? null,
authorId: p.userId,
body: text,
replyToId,
mentions: erwaehnt.map((user) => user.id),
},
select: { id: true },
})
// Für den Worker. Hier steht nur, was hinausgehen darf — nie der Text der
// Nachricht. Wer sie bekommt, entscheidet diese Anwendung, nicht er.
if (ort.caseId) {
const akte = await tx.case.findUniqueOrThrow({
where: { id: ort.caseId },
select: { caseNumber: true, title: true, classification: true },
})
await enqueueCaseNotification(tx, {
kind: erwaehnt.length > 0 ? 'chat.mention' : 'chat.new',
caseId: ort.caseId,
caseNumber: akte.caseNumber,
classification: akte.classification,
title: akte.title,
actorId: p.userId,
actorName: absenderName,
onlyFor: erwaehnt.length > 0 ? erwaehnt.map((u) => u.id) : undefined,
meta: { messageId: nachricht.id },
})
} else if (erwaehnt.length > 0) {
// In einem Kanal gibt es keine Akte, deren Sichtbarkeit zu prüfen wäre.
// Die Mindeststufe des Kanals hat der Absender schon passiert, und
// erwähnt wird nur, wer sie ebenfalls hat.
await tx.notificationOutbox.createMany({
data: erwaehnt.map((user) => ({
kind: 'chat.mention',
channelId: ort.channelId,
actorId: p.userId,
recipientId: user.id,
meta: { messageId: nachricht.id, channelKey: ort.label, actorName: absenderName },
})),
})
}
return nachricht
})
await publish(ort.topic, 'chat.new', angelegt.id)
return angelegt
}
/** Bearbeiten. Sichtbar über `editedAt`, ohne Versionierung — kein Aktenbestand. */
export async function editMessage(
p: EffectivePermissions,
input: { id: string; body: string },
): Promise<void> {
const nachricht = await loadMessage(p, input.id)
if (!nachricht) throw new AccessError('not-found')
if (nachricht.authorId !== p.userId && !p.isAdmin) throw new AccessError('no-permission')
if (nachricht.withdrawnAt) throw new AccessError('message-withdrawn')
const text = input.body.trim()
if (text.length === 0) throw new AccessError('message-empty')
await db.chatMessage.update({
where: { id: input.id },
data: { body: text, editedAt: new Date() },
})
await publish(themaVon(nachricht), 'chat.edit', input.id)
}
/**
* Zurückziehen. Kein Löschen: eine Nachricht, auf die jemand geantwortet hat,
* darf nicht spurlos verschwinden. Es bleibt ein Platzhalter stehen.
*/
export async function withdrawMessage(
p: EffectivePermissions,
id: string,
): Promise<void> {
const nachricht = await loadMessage(p, id)
if (!nachricht) throw new AccessError('not-found')
if (nachricht.authorId !== p.userId && !p.isAdmin) throw new AccessError('no-permission')
if (nachricht.withdrawnAt) return
await db.chatMessage.update({ where: { id }, data: { withdrawnAt: new Date() } })
await publish(themaVon(nachricht), 'chat.withdrawn', id)
}
/** Reaktion setzen oder wegnehmen. Ein Klick auf dieselbe nimmt sie zurück. */
export async function toggleReaction(
p: EffectivePermissions,
input: { id: string; key: ReactionKey },
): Promise<{ active: boolean }> {
const nachricht = await loadMessage(p, input.id)
if (!nachricht) throw new AccessError('not-found')
const schluessel = {
messageId_userId_key: { messageId: input.id, userId: p.userId, key: input.key },
}
const vorhanden = await db.chatReaction.findUnique({ where: schluessel })
if (vorhanden) {
await db.chatReaction.delete({ where: schluessel })
} else {
await db.chatReaction.create({
data: { messageId: input.id, userId: p.userId, key: input.key },
})
}
await publish(themaVon(nachricht), 'chat.reaction', input.id)
return { active: !vorhanden }
}
export async function listReactions(p: EffectivePermissions, messageIds: string[]) {
if (messageIds.length === 0) return []
return db.chatReaction.findMany({
where: {
messageId: { in: messageIds },
message: { is: visibleMessageWhere(p) },
},
select: { messageId: true, userId: true, key: true },
})
}
/**
* „Als Vermerk übernehmen".
*
* Die Brücke zwischen Discord-Gewohnheit und sauberem Aktenbestand: aus einer
* formlosen Nachricht wird ein Verlaufseintrag, der die harten Regeln erbt —
* er ist ab da nicht mehr löschbar und jede Änderung erzeugt eine Version.
*
* Die Nachricht bleibt stehen und wird als übernommen markiert. Der Eintrag
* nennt seinen Ursprung, damit später nachvollziehbar ist, dass der Wortlaut
* aus dem Chat stammt und nicht für die Akte geschrieben wurde.
*/
export async function adoptAsEntry(
p: EffectivePermissions,
messageId: string,
): Promise<{ entryId: string }> {
const nachricht = await loadMessage(p, messageId)
if (!nachricht) throw new AccessError('not-found')
if (!nachricht.caseId) throw new AccessError('adopt-needs-case')
if (nachricht.withdrawnAt) throw new AccessError('message-withdrawn')
if (nachricht.adoptedEntryId) return { entryId: nachricht.adoptedEntryId }
const akte = await db.case.findFirst({
where: { AND: [visibleCaseWhere(p), { id: nachricht.caseId }] },
select: { id: true, caseNumber: true, title: true, status: true, classification: true },
})
if (!akte) throw new AccessError('not-found')
must(canEditCase(p, akte, 'entry.create'))
const autor = await db.user.findUnique({
where: { id: nachricht.authorId },
select: { displayName: true },
})
const entryId = crypto.randomUUID()
await db.$transaction(async (tx) => {
await tx.caseEntry.create({
data: {
id: entryId,
caseId: akte.id,
type: 'NOTE',
body: nachricht.body,
authorId: nachricht.authorId,
minClearance: 0,
},
})
// Der Hinweis auf den Ursprung steht als eigener Systemeintrag, nicht im
// Text: der Wortlaut des Vermerks bleibt der des Autors.
await tx.caseEntry.create({
data: {
id: crypto.randomUUID(),
caseId: akte.id,
type: 'SYSTEM',
body:
`Vermerk aus dem Aktenchat übernommen. Verfasst von ` +
`${autor?.displayName ?? nachricht.authorId} am ` +
`${nachricht.createdAt.toLocaleString('de-DE')}, übernommen durch ${p.userId}.`,
authorId: p.userId,
minClearance: 0,
},
})
await tx.chatMessage.update({
where: { id: messageId },
data: { adoptedEntryId: entryId },
})
await enqueueCaseNotification(tx, {
kind: 'entry.new',
caseId: akte.id,
caseNumber: akte.caseNumber,
classification: akte.classification,
title: akte.title,
actorId: p.userId,
actorName: autor?.displayName ?? p.userId,
meta: { entryId, fromMessageId: messageId },
})
await writeAudit(tx, {
actorId: p.userId,
action: 'chat.adopt',
targetType: 'ChatMessage',
targetId: messageId,
clearanceAtTime: p.clearanceLevel,
meta: { caseNumber: akte.caseNumber, entryId },
})
})
await publish(caseTopic(akte.id), 'chat.adopted', messageId)
await publish(caseTopic(akte.id), 'entry.new', entryId)
return { entryId }
}
function themaVon(nachricht: { caseId: string | null; channelId: string | null }): Topic {
if (nachricht.caseId) return caseTopic(nachricht.caseId)
return channelTopic(nachricht.channelId!)
}
// ---------------------------------------------------------------------------
// Ungelesen
// ---------------------------------------------------------------------------
/**
* Wann der Nutzer diesen Ort zuletzt gesehen hat.
*
* Der Marker wird beim Öffnen **nicht** sofort hochgesetzt, sondern erst, wenn
* der Nutzer die Ansicht verlässt oder ausdrücklich weiterliest — sonst wäre
* die Trennlinie „Neu seit deinem letzten Besuch" beim Neuladen sofort weg.
*/
export async function loadReadMarker(
p: EffectivePermissions,
scopeType: 'case' | 'channel',
scopeId: string,
): Promise<Date | null> {
const marker = await db.readMarker.findUnique({
where: { userId_scopeType_scopeId: { userId: p.userId, scopeType, scopeId } },
select: { lastReadAt: true },
})
return marker?.lastReadAt ?? null
}
export async function markRead(
p: EffectivePermissions,
scopeType: 'case' | 'channel',
scopeId: string,
at: Date = new Date(),
): Promise<void> {
await db.readMarker.upsert({
where: { userId_scopeType_scopeId: { userId: p.userId, scopeType, scopeId } },
create: { userId: p.userId, scopeType, scopeId, lastReadAt: at },
update: { lastReadAt: at },
})
}
// ---------------------------------------------------------------------------
// Kanäle
// ---------------------------------------------------------------------------
/** Die Kanäle, die dieser Nutzer sehen darf. */
export async function listChannels(p: EffectivePermissions) {
return db.channel.findMany({
where: { AND: [visibleChannelWhere(p), { isArchived: false }] },
orderBy: { name: 'asc' },
select: {
id: true,
key: true,
name: true,
description: true,
minClearance: true,
createdAt: true,
},
})
}
/**
* Kanal anlegen.
*
* Die Mindeststufe lässt sich nicht über die eigene heben — sonst legte
* jemand einen Raum an, den er selbst nicht mehr betreten kann, und dieselbe
* Regel gilt schon bei Akten und Dokumenten.
*/
export async function createChannel(
p: EffectivePermissions,
input: { key: string; name: string; description?: string | null; minClearance: number },
): Promise<{ key: string }> {
if (!p.isAdmin && !p.permissions.has('case.create')) throw new AccessError('no-permission')
if (input.minClearance > p.clearanceLevel && !p.isAdmin) {
throw new AccessError('clearance-too-low')
}
const key = input.key.trim().toLowerCase()
if (!/^[a-z0-9][a-z0-9-]{1,40}$/.test(key)) throw new AccessError('channel-key-invalid')
const vorhanden = await db.channel.findUnique({ where: { key }, select: { id: true } })
if (vorhanden) throw new AccessError('channel-key-taken')
return db.$transaction(async (tx) => {
const kanal = await tx.channel.create({
data: {
key,
name: input.name.trim().slice(0, 120),
description: input.description?.trim() || null,
minClearance: input.minClearance,
createdById: p.userId,
},
select: { key: true },
})
await writeAudit(tx, {
actorId: p.userId,
action: 'channel.create',
targetType: 'Channel',
targetId: kanal.key,
clearanceAtTime: p.clearanceLevel,
meta: { key: kanal.key, minClearance: input.minClearance },
})
return kanal
})
}
+13
View File
@@ -0,0 +1,13 @@
/**
* Fester Satz an Reaktionen, keine freie Emoji-Auswahl.
*
* Ohne Abhängigkeiten, weil der Client sie lädt — derselbe Schnitt wie
* `lib/cases/terms.ts`. Die Beschriftungen stehen in `lib/i18n/cases.ts`.
*/
export const REACTIONS = ['gesehen', 'zustimmung', 'frage', 'wichtig'] as const
export type ReactionKey = (typeof REACTIONS)[number]
export function isReaction(value: string): value is ReactionKey {
return (REACTIONS as readonly string[]).includes(value)
}
+28
View File
@@ -0,0 +1,28 @@
import type { CasePreview } from '@/lib/chat/preview'
/**
* Die Form, in der eine Nachricht die Grenze zur Anzeige überquert.
*
* Datumsangaben als Zeichenkette, weil ein `Date` diese Grenze nicht sauber
* überquert, und der Text einer zurückgezogenen Nachricht ist **leer** — nicht
* ausgeblendet. Was der Client nicht bekommt, kann er nicht zeigen.
*/
export type ChatMessageView = {
id: string
body: string
authorId: string
authorName: string
replyToId: string | null
editedAt: string | null
withdrawnAt: string | null
adoptedEntryId: string | null
createdAt: string
/** Nur zu Aktenzeichen, die im Text vorkommen. Serverseitig aufgelöst. */
previews: Record<string, CasePreview>
}
const CASE_NUMBER = /\b[A-Za-z][A-Za-z0-9-]*-\d{4}-\d{3,8}\b/g
export function extractCaseNumbers(body: string): string[] {
return [...new Set(body.match(CASE_NUMBER) ?? [])]
}
+56 -13
View File
@@ -5,7 +5,7 @@ import { scanBuffer } from '@/lib/files/clamav'
import { absolutePath, ensureDirFor, sha256Of, STORE, storageKeyFor } from '@/lib/files/paths' import { absolutePath, ensureDirFor, sha256Of, STORE, storageKeyFor } from '@/lib/files/paths'
import { detectType, matchesDeclared } from '@/lib/files/magic' import { detectType, matchesDeclared } from '@/lib/files/magic'
import { reencode } from '@/lib/files/reencode' import { reencode } from '@/lib/files/reencode'
import { notifyAttachmentScanned } from '@/lib/realtime/publish' import { caseTopic, notifyAttachmentScanned, userTopic, type Topic } from '@/lib/realtime/publish'
/** /**
* Die Prüfkette, in der Reihenfolge aus docs/07-dateien-und-sicherheit.md: * Die Prüfkette, in der Reihenfolge aus docs/07-dateien-und-sicherheit.md:
@@ -33,22 +33,34 @@ export async function runCheckChain(
): Promise<void> { ): Promise<void> {
const anhang = await db.attachment.findUnique({ const anhang = await db.attachment.findUnique({
where: { id: attachmentId }, where: { id: attachmentId },
select: { id: true, storageKey: true, mimeType: true, scanStatus: true }, select: {
id: true,
storageKey: true,
mimeType: true,
scanStatus: true,
uploadedById: true,
caseId: true,
entryId: true,
documentId: true,
},
}) })
if (!anhang || anhang.scanStatus !== 'PENDING') return if (!anhang || anhang.scanStatus !== 'PENDING') return
const thema = await themaVon(anhang)
try { try {
const inhalt = new Uint8Array(await readFile(absolutePath(anhang.storageKey))) const inhalt = new Uint8Array(await readFile(absolutePath(anhang.storageKey)))
// 1. Typprüfung. Der gemeldete Typ steht bis hierher in mimeType. // 1. Typprüfung. Der gemeldete Typ steht bis hierher in mimeType.
const erkannt = detectType(inhalt) const erkannt = detectType(inhalt)
if (!erkannt.ok) { if (!erkannt.ok) {
return blockieren(attachmentId, typFehler(erkannt.reason)) return blockieren(attachmentId, typFehler(erkannt.reason), thema)
} }
if (!matchesDeclared(erkannt.type, anhang.mimeType)) { if (!matchesDeclared(erkannt.type, anhang.mimeType)) {
return blockieren( return blockieren(
attachmentId, attachmentId,
`Angekündigt als ${anhang.mimeType}, erkannt als ${erkannt.type}`, `Angekündigt als ${anhang.mimeType}, erkannt als ${erkannt.type}`,
thema,
) )
} }
@@ -58,33 +70,34 @@ export async function runCheckChain(
if (!scan.ok) { if (!scan.ok) {
// Nicht blockiert, sondern unentschieden: die Datei ist nicht als böse // Nicht blockiert, sondern unentschieden: die Datei ist nicht als böse
// erkannt, sie ist ungeprüft. Ein Administrator kann sie freigeben. // erkannt, sie ist ungeprüft. Ein Administrator kann sie freigeben.
return abbrechen(attachmentId, scan.reason) return abbrechen(attachmentId, scan.reason, thema)
} }
if (!scan.clean) { if (!scan.clean) {
return blockieren(attachmentId, `Schadsoftware erkannt: ${scan.signature}`) return blockieren(attachmentId, `Schadsoftware erkannt: ${scan.signature}`, thema)
} }
} }
// 3. Neu kodieren. Hier verliert die Datei alles, was nicht Inhalt ist. // 3. Neu kodieren. Hier verliert die Datei alles, was nicht Inhalt ist.
const neu = await reencode(inhalt, erkannt.type) const neu = await reencode(inhalt, erkannt.type)
if (!neu.ok) { if (!neu.ok) {
if (!options.skipScan) return abbrechen(attachmentId, neu.reason) if (!options.skipScan) return abbrechen(attachmentId, neu.reason, thema)
// Bei der Freigabe von Hand bleibt die Datei so, wie sie ist. Das ist // Bei der Freigabe von Hand bleibt die Datei so, wie sie ist. Das ist
// die Entscheidung, die der Administrator trifft, und sie steht danach // die Entscheidung, die der Administrator trifft, und sie steht danach
// im Zustand der Datei. // im Zustand der Datei.
return freigeben(attachmentId, erkannt.type, { content: inhalt }, { return freigeben(attachmentId, erkannt.type, { content: inhalt }, thema, {
hinweis: `Ohne Virenprüfung und ohne Neukodierung freigegeben: ${neu.reason}`, hinweis: `Ohne Virenprüfung und ohne Neukodierung freigegeben: ${neu.reason}`,
}) })
} }
// 4. Freigeben. // 4. Freigeben.
await freigeben(attachmentId, erkannt.type, neu.result, { await freigeben(attachmentId, erkannt.type, neu.result, thema, {
hinweis: options.skipScan ? 'Ohne Virenprüfung freigegeben' : undefined, hinweis: options.skipScan ? 'Ohne Virenprüfung freigegeben' : undefined,
}) })
} catch (error) { } catch (error) {
await abbrechen( await abbrechen(
attachmentId, attachmentId,
error instanceof Error ? error.message : 'Prüfung fehlgeschlagen', error instanceof Error ? error.message : 'Prüfung fehlgeschlagen',
thema,
) )
} }
} }
@@ -103,10 +116,40 @@ type Fertig = {
blurhash?: string blurhash?: string
} }
/**
* Wen das Ergebnis der Prüfung angeht: die Akte, in der die Datei hängt, sonst
* der Hochladende selbst. Ohne Thema erführe niemand vom Ausgang, mit dem
* falschen erführen es die Falschen.
*/
async function themaVon(anhang: {
uploadedById: string
caseId: string | null
entryId: string | null
documentId: string | null
}): Promise<Topic> {
if (anhang.caseId) return caseTopic(anhang.caseId)
if (anhang.entryId) {
const eintrag = await db.caseEntry.findUnique({
where: { id: anhang.entryId },
select: { caseId: true },
})
if (eintrag) return caseTopic(eintrag.caseId)
}
if (anhang.documentId) {
const doc = await db.document.findUnique({
where: { id: anhang.documentId },
select: { caseId: true },
})
if (doc?.caseId) return caseTopic(doc.caseId)
}
return userTopic(anhang.uploadedById)
}
async function freigeben( async function freigeben(
attachmentId: string, attachmentId: string,
type: string, type: string,
neu: Fertig, neu: Fertig,
thema: Topic,
options: { hinweis?: string } = {}, options: { hinweis?: string } = {},
): Promise<void> { ): Promise<void> {
const sha256 = sha256Of(neu.content) const sha256 = sha256Of(neu.content)
@@ -146,23 +189,23 @@ async function freigeben(
}, },
}) })
await notifyAttachmentScanned(attachmentId) await notifyAttachmentScanned(attachmentId, thema)
} }
/** Endgültig abgelehnt: die Datei ist als gefährlich oder unzulässig erkannt. */ /** Endgültig abgelehnt: die Datei ist als gefährlich oder unzulässig erkannt. */
async function blockieren(attachmentId: string, grund: string): Promise<void> { async function blockieren(attachmentId: string, grund: string, thema: Topic): Promise<void> {
await db.attachment.update({ await db.attachment.update({
where: { id: attachmentId }, where: { id: attachmentId },
data: { scanStatus: 'BLOCKED', scanMessage: grund }, data: { scanStatus: 'BLOCKED', scanMessage: grund },
}) })
await notifyAttachmentScanned(attachmentId) await notifyAttachmentScanned(attachmentId, thema)
} }
/** Unentschieden: die Prüfung konnte nicht abgeschlossen werden. */ /** Unentschieden: die Prüfung konnte nicht abgeschlossen werden. */
async function abbrechen(attachmentId: string, grund: string): Promise<void> { async function abbrechen(attachmentId: string, grund: string, thema: Topic): Promise<void> {
await db.attachment.update({ await db.attachment.update({
where: { id: attachmentId }, where: { id: attachmentId },
data: { scanStatus: 'FAILED', scanMessage: grund }, data: { scanStatus: 'FAILED', scanMessage: grund },
}) })
await notifyAttachmentScanned(attachmentId) await notifyAttachmentScanned(attachmentId, thema)
} }
+64
View File
@@ -0,0 +1,64 @@
/**
* Aktenchat und freie Kanäle.
*
* Eigene Datei und nicht bei den Fallakten, obwohl der Aktenchat an einer
* Akte hängt: die Fallakte lädt ihre Texte sofort, den Chat erst beim
* Hinunterscrollen. Lägen sie zusammen, käme der Chat doch mit.
* Siehe docs/13-entscheidungen.md.
*/
export const chatText = {
heading: 'Aktenchat',
hint: 'Formlos und ausdrücklich kein Aktenbestandteil. Was bleiben soll, wird zum Vermerk.',
placeholder: 'Nachricht schreiben',
send: 'Senden',
sending: 'Wird gesendet',
empty: 'Noch nichts besprochen.',
edited: 'bearbeitet',
edit: 'Bearbeiten',
editSave: 'Änderung speichern',
withdraw: 'Zurückziehen',
withdrawn: 'Nachricht zurückgezogen',
reply: 'Antworten',
replyingTo: 'Antwort auf',
cancelReply: 'Antwort verwerfen',
adopt: 'Als Vermerk übernehmen',
adopted: 'Als Vermerk übernommen',
unreadDivider: 'Neu seit deinem letzten Besuch',
markRead: 'Als gelesen markieren',
unknownCase: 'Aktenzeichen',
hiddenCase: 'Nicht einsehbar',
offline: 'Verbindung unterbrochen, wird neu aufgebaut',
reactions: {
gesehen: 'Gesehen',
zustimmung: 'Zustimmung',
frage: 'Frage',
wichtig: 'Wichtig',
} as Record<string, string>,
// Zeichen statt Emoji: sie drucken, sie lesen sich im schmalen Schnitt und
// sie brauchen keine Schriftart, die es vielleicht nicht gibt. Für "wichtig"
// ein Dreieck und kein Ausrufezeichen — die Oberfläche ruft nicht.
reactionSymbols: {
gesehen: '✓',
zustimmung: '+',
frage: '?',
wichtig: '▲',
} as Record<string, string>,
} as const
export const channelText = {
one: 'Kanal',
many: 'Kanäle',
heading: 'Kanäle',
intro: 'Freie Räume für Absprachen, die zu keiner Akte gehören.',
empty: 'Noch kein Kanal. Ersten Kanal anlegen.',
create: 'Kanal anlegen',
createTitle: 'Neuer Kanal',
name: 'Name',
key: 'Kürzel',
keyHint: 'Kleinbuchstaben, Ziffern und Bindestrich. Steht später in der Adresse.',
description: 'Beschreibung',
minClearance: 'Mindeststufe',
minClearanceHint: 'Wer diese Stufe nicht hat, sieht den Kanal nicht.',
archived: 'Archiviert',
open: 'Öffnen',
} as const
+6
View File
@@ -39,6 +39,12 @@ export const actionErrorsText = {
'name-required': 'Ein Name mit mindestens zwei Zeichen ist nötig.', 'name-required': 'Ein Name mit mindestens zwei Zeichen ist nötig.',
'participant-target': 'Entweder ein Discord-Konto oder eine Personalakte, nicht beides.', 'participant-target': 'Entweder ein Discord-Konto oder eine Personalakte, nicht beides.',
'link-self': 'Eine Akte lässt sich nicht mit sich selbst verknüpfen.', 'link-self': 'Eine Akte lässt sich nicht mit sich selbst verknüpfen.',
'message-empty': 'Die Nachricht ist leer.',
'message-too-long': 'Die Nachricht ist länger als 4000 Zeichen.',
'message-withdrawn': 'Die Nachricht wurde zurückgezogen.',
'adopt-needs-case': 'Nur eine Nachricht aus einem Aktenchat wird zum Vermerk.',
'channel-key-taken': 'Dieses Kürzel gibt es schon.',
'channel-key-invalid': 'Kürzel: Kleinbuchstaben, Ziffern und Bindestrich, mindestens zwei Zeichen.',
default: 'Nicht gespeichert. Erneut versuchen.', default: 'Nicht gespeichert. Erneut versuchen.',
} as Record<string, string> } as Record<string, string>
+19 -2
View File
@@ -14,9 +14,23 @@
* Client-Komponente importiert **den Bereich, den sie braucht** — sonst traegt * Client-Komponente importiert **den Bereich, den sie braucht** — sonst traegt
* sie die Texte der ganzen Anwendung ins Buendel. * sie die Texte der ganzen Anwendung ins Buendel.
*/ */
import { appText, navText, authText, overviewText, healthText } from '@/lib/i18n/shell' import {
appText,
navText,
authText,
overviewText,
healthText,
notifyText,
} from '@/lib/i18n/shell'
import { commonText, actionErrorsText, errorsText, clearanceText } from '@/lib/i18n/common' import { commonText, actionErrorsText, errorsText, clearanceText } from '@/lib/i18n/common'
import { caseText, sectionText, entryText, participantText, linkText } from '@/lib/i18n/cases' import {
caseText,
sectionText,
entryText,
participantText,
linkText,
} from '@/lib/i18n/cases'
import { chatText, channelText } from '@/lib/i18n/chat'
import { personnelText, assignmentText } from '@/lib/i18n/people' import { personnelText, assignmentText } from '@/lib/i18n/people'
import { documentText, attachmentText } from '@/lib/i18n/documents' import { documentText, attachmentText } from '@/lib/i18n/documents'
import { roleAdminText, templateAdminText, settingsText } from '@/lib/i18n/admin' import { roleAdminText, templateAdminText, settingsText } from '@/lib/i18n/admin'
@@ -35,6 +49,8 @@ export const de = {
assignment: assignmentText, assignment: assignmentText,
participant: participantText, participant: participantText,
link: linkText, link: linkText,
chat: chatText,
channel: channelText,
document: documentText, document: documentText,
attachment: attachmentText, attachment: attachmentText,
common: commonText, common: commonText,
@@ -43,4 +59,5 @@ export const de = {
templateAdmin: templateAdminText, templateAdmin: templateAdminText,
settings: settingsText, settings: settingsText,
health: healthText, health: healthText,
notify: notifyText,
} as const } as const
+15
View File
@@ -84,3 +84,18 @@ export const healthText = {
clamav: 'Virenprüfung', clamav: 'Virenprüfung',
} as Record<string, string>, } as Record<string, string>,
} as const } as const
export const notifyText = {
heading: 'Benachrichtigungen',
intro:
'Discord erfährt nie den Inhalt einer Akte. Bei Akten ab Stufe 3 auch nicht den Titel — nur, dass sich etwas getan hat, und einen Link.',
mode: 'Was soll dich erreichen?',
save: 'Übernehmen',
saved: 'Übernommen',
modes: {
ALL: 'Alles, was ich sehen darf',
OWN_CASES: 'Nur meine Fälle',
MENTIONS: 'Nur Erwähnungen',
OFF: 'Nichts',
} as Record<string, string>,
} as const
+124
View File
@@ -0,0 +1,124 @@
import { db } from '@/lib/db'
import { READ_AUDIT_FROM_LEVEL } from '@/lib/audit'
import { visibleCaseWhere } from '@/lib/access/caseFilter'
import { resolvePermissions } from '@/lib/auth/effective'
import type { Prisma } from '@afis/db'
/**
* Benachrichtigungen in den Postausgang legen.
*
* **Die Web-App entscheidet, wer was erfahren darf; der Worker liefert nur
* aus.** Das ist die wichtigste Trennung hier: die Sichtbarkeitsprüfung steckt
* in dieser Anwendung, und sie im Worker ein zweites Mal zu bauen hiesse, sie
* zweimal richtig haben zu müssen.
*
* Deshalb steht in `meta` nur, was **stehen darf** — fertig entschieden. Ein
* Titel landet dort nur, wenn er auch nach Discord darf. Der Worker setzt
* daraus einen Satz zusammen und hat keine Entscheidung mehr zu treffen.
* Siehe docs/06-chat-und-realtime.md.
*/
/** Ab dieser Stufe geht nicht einmal der Titel nach Discord. */
const TITLE_HIDDEN_FROM = READ_AUDIT_FROM_LEVEL
export type OutboxKind = 'entry.new' | 'chat.new' | 'chat.mention' | 'case.status'
/**
* Die Empfänger einer Meldung zu einer Akte.
*
* Wer die Akte nicht sehen darf, bekommt nichts — auch keinen Hinweis, dass es
* sie gibt. Geprüft wird mit **derselben** Abfrage wie beim Lesen:
* `visibleCaseWhere`, je Empfänger. Das ist eine Abfrage pro Kandidat und bei
* dieser Nutzerzahl billiger als der Fehler, die Regel ein zweites Mal zu
* bauen und eine der beiden Fassungen später zu vergessen.
*/
async function empfaenger(
caseId: string,
actorId: string,
nurDiese?: string[],
): Promise<string[]> {
const [beteiligt, beobachtend, kandidaten] = await Promise.all([
db.caseParticipant.findMany({
where: { caseId, userId: { not: null } },
select: { userId: true },
}),
db.watch.findMany({ where: { caseId }, select: { userId: true } }),
db.user.findMany({
where: {
isActive: true,
notifyMode: { not: 'OFF' },
id: { not: actorId, ...(nurDiese ? { in: nurDiese } : {}) },
},
select: { id: true, notifyMode: true },
}),
])
const nah = new Set(
[...beteiligt.map((b) => b.userId!), ...beobachtend.map((w) => w.userId)].filter(Boolean),
)
const ziele: string[] = []
for (const nutzer of kandidaten) {
// Ausdrücklich gemeint zu sein hebt die Einstellung nicht auf, aber
// "MENTIONS" heisst eben genau: nur dann.
if (nutzer.notifyMode === 'MENTIONS' && nurDiese === undefined) continue
if (nutzer.notifyMode === 'OWN_CASES' && !nah.has(nutzer.id) && nurDiese === undefined) {
continue
}
const rechte = await resolvePermissions(nutzer.id)
const sichtbar = await db.case.findFirst({
where: { AND: [visibleCaseWhere(rechte), { id: caseId }] },
select: { id: true },
})
if (sichtbar) ziele.push(nutzer.id)
}
return ziele
}
/**
* Legt die Zeilen an: eine je Empfänger für die Direktnachricht, plus eine
* ohne Empfänger für den allgemeinen Kanal.
*/
export async function enqueueCaseNotification(
tx: Prisma.TransactionClient,
input: {
kind: OutboxKind
caseId: string
caseNumber: string
classification: number
actorId: string
actorName: string
/** Nur gesetzt, wenn ausdrücklich bestimmte Leute gemeint sind. */
onlyFor?: string[]
meta?: Record<string, unknown>
title?: string
},
): Promise<void> {
// Ab Stufe 3 geht nicht einmal der Titel raus. Discord kennt unsere
// Klassifizierung nicht, der Link führt zur App, die selbst prüft.
const titel = input.classification >= TITLE_HIDDEN_FROM ? null : (input.title ?? null)
const gemeinsam = {
kind: input.kind,
caseId: input.caseId,
actorId: input.actorId,
meta: {
caseNumber: input.caseNumber,
actorName: input.actorName,
title: titel,
...input.meta,
} as Prisma.InputJsonValue,
}
const ziele = await empfaenger(input.caseId, input.actorId, input.onlyFor)
await tx.notificationOutbox.createMany({
data: [
// In den allgemeinen Kanal, ohne Empfänger.
gemeinsam,
...ziele.map((ziel) => ({ ...gemeinsam, recipientId: ziel })),
],
})
}
+15 -8
View File
@@ -1,6 +1,6 @@
import { Client } from 'pg' import { Client } from 'pg'
import { invalidateAll, invalidateUser } from '@/lib/auth/effective' import { invalidateAll, invalidateUser } from '@/lib/auth/effective'
import { ATTACHMENT_CHANNEL } from '@/lib/realtime/publish' import { STREAM_CHANNEL, type StreamEvent } from '@/lib/realtime/publish'
/** /**
* Ein einziger `pg`-Client hält `LISTEN` offen und verteilt im Prozess weiter. * Ein einziger `pg`-Client hält `LISTEN` offen und verteilt im Prozess weiter.
@@ -10,13 +10,13 @@ import { ATTACHMENT_CHANNEL } from '@/lib/realtime/publish'
* Drei Kanäle: `member_changed` verwirft die Rechte eines Nutzers, * Drei Kanäle: `member_changed` verwirft die Rechte eines Nutzers,
* `role_mapping_changed` verwirft alles. Dadurch ist eine Rechteänderung in * `role_mapping_changed` verwirft alles. Dadurch ist eine Rechteänderung in
* Discord in unter einer Sekunde wirksam, ohne dass ein einziger Request auf * Discord in unter einer Sekunde wirksam, ohne dass ein einziger Request auf
* die Discord-API wartet. `attachment_scanned` meldet das Ende einer * die Discord-API wartet. `afis_stream` trägt alles, was offene Ansichten
* Dateiprüfung an die offenen Ansichten. * angeht: neue Chatnachrichten, Verlaufseinträge, geprüfte Dateien.
* *
* Die Verbindung läuft an pgbouncer vorbei: im Transaktionsmodus überlebt ein * Die Verbindung läuft an pgbouncer vorbei: im Transaktionsmodus überlebt ein
* `LISTEN` den Pool nicht. * `LISTEN` den Pool nicht.
*/ */
const CHANNELS = ['member_changed', 'role_mapping_changed', ATTACHMENT_CHANNEL] as const const CHANNELS = ['member_changed', 'role_mapping_changed', STREAM_CHANNEL] as const
/** /**
* Wer im Prozess auf Ereignisse wartet. Ein `Set` von Rückrufen statt eines * Wer im Prozess auf Ereignisse wartet. Ein `Set` von Rückrufen statt eines
@@ -24,7 +24,7 @@ const CHANNELS = ['member_changed', 'role_mapping_changed', ATTACHMENT_CHANNEL]
* verbunden ist, verpasst das Ereignis und holt sich den Stand beim nächsten * verbunden ist, verpasst das Ereignis und holt sich den Stand beim nächsten
* Laden. * Laden.
*/ */
type Subscriber = (event: { channel: string; payload: string }) => void type Subscriber = (event: StreamEvent) => void
const globalForSubscribers = globalThis as unknown as { afisSubscribers?: Set<Subscriber> } const globalForSubscribers = globalThis as unknown as { afisSubscribers?: Set<Subscriber> }
const subscribers = (globalForSubscribers.afisSubscribers ??= new Set<Subscriber>()) const subscribers = (globalForSubscribers.afisSubscribers ??= new Set<Subscriber>())
@@ -53,15 +53,22 @@ function connect(): Listener {
invalidateAll() invalidateAll()
return return
} }
if (message.payload) { if (message.channel !== STREAM_CHANNEL || !message.payload) return
let event: StreamEvent
try {
event = JSON.parse(message.payload) as StreamEvent
} catch {
return
}
for (const listener of subscribers) { for (const listener of subscribers) {
try { try {
listener({ channel: message.channel, payload: message.payload }) listener(event)
} catch (error) { } catch (error) {
console.error('[realtime] Empfänger hat geworfen', error) console.error('[realtime] Empfänger hat geworfen', error)
} }
} }
}
}) })
client.on('error', (error: unknown) => { client.on('error', (error: unknown) => {
+51 -5
View File
@@ -1,4 +1,5 @@
import { db } from '@/lib/db' import { db } from '@/lib/db'
import type { Prisma } from '@afis/db'
/** /**
* Ereignisse an alle offenen Ansichten schicken. * Ereignisse an alle offenen Ansichten schicken.
@@ -6,17 +7,62 @@ import { db } from '@/lib/db'
* Über `pg_notify`, nicht über einen prozessinternen Verteiler: dann stimmt es * Über `pg_notify`, nicht über einen prozessinternen Verteiler: dann stimmt es
* auch, wenn später ein zweiter Web-Prozess dazukommt, und es braucht keinen * auch, wenn später ein zweiter Web-Prozess dazukommt, und es braucht keinen
* zusätzlichen Dienst. Siehe docs/06-chat-und-realtime.md. * zusätzlichen Dienst. Siehe docs/06-chat-und-realtime.md.
*
* **In der Nutzlast steht nie ein Inhalt**, nur ein Thema, eine Art und eine
* Kennung. Wer das Ereignis bekommt, entscheidet der Strom-Endpunkt, und was
* er daraus zu sehen bekommt, entscheidet die Sichtbarkeitsprüfung. Ein Text
* in der Nutzlast wäre an jedem Filter vorbei.
*/ */
export const ATTACHMENT_CHANNEL = 'attachment_scanned' export const STREAM_CHANNEL = 'afis_stream'
export async function notifyAttachmentScanned(attachmentId: string): Promise<void> { export type EventKind =
| 'chat.new'
| 'chat.edit'
| 'chat.withdrawn'
| 'chat.reaction'
| 'chat.adopted'
| 'entry.new'
| 'entry.updated'
| 'case.update'
| 'attachment.scanned'
/** Wer das Ereignis angeht. */
export type Topic = `case:${string}` | `channel:${string}` | `user:${string}`
export function caseTopic(caseId: string): Topic {
return `case:${caseId}`
}
export function channelTopic(channelId: string): Topic {
return `channel:${channelId}`
}
export function userTopic(userId: string): Topic {
return `user:${userId}`
}
export type StreamEvent = { t: Topic; k: EventKind; i: string }
export async function publish(
topic: Topic,
kind: EventKind,
id: string,
tx?: Prisma.TransactionClient,
): Promise<void> {
const payload = JSON.stringify({ t: topic, k: kind, i: id } satisfies StreamEvent)
try { try {
// Der Kanalname ist eine Konstante, nur die Nutzlast kommt von aussen — const client = tx ?? db
// und die ist eine Kennung aus der Datenbank. await client.$executeRaw`SELECT pg_notify(${STREAM_CHANNEL}, ${payload})`
await db.$executeRaw`SELECT pg_notify(${ATTACHMENT_CHANNEL}, ${attachmentId})`
} catch (error) { } catch (error) {
// Ein verlorenes Ereignis kostet eine Aktualisierung von Hand, mehr nicht. // Ein verlorenes Ereignis kostet eine Aktualisierung von Hand, mehr nicht.
// Es ist kein Grund, die Änderung scheitern zu lassen, die es meldet.
console.error('[realtime] Ereignis nicht zugestellt', error) console.error('[realtime] Ereignis nicht zugestellt', error)
} }
} }
/** Kurzform für die Prüfkette der Dateien, die nur ein Thema kennt. */
export async function notifyAttachmentScanned(
attachmentId: string,
topic: Topic,
): Promise<void> {
await publish(topic, 'attachment.scanned', attachmentId)
}
+71
View File
@@ -0,0 +1,71 @@
/**
* UUIDv7, zeitlich sortiert.
*
* Selbst geschrieben statt als Abhängigkeit: das Paket wog 2,6 kB im Bündel
* der Fallakte, und die eine Eigenschaft, auf die sich das System verlässt,
* steht so ausdrücklich hier statt in fremdem Code.
*
* Die Eigenschaft ist: **die Kennungen sind lexikografisch sortierbar wie die
* Zeit.** Darauf beruhen zwei Dinge — die Nachlieferung beim Wiederverbinden
* (`id > seit`) und die Reihenfolge im Chat, ohne dass eine Sortierspalte
* nötig wäre. Siehe docs/06-chat-und-realtime.md.
*
* Aufbau nach RFC 9562:
*
* 48 Bit Zeitstempel (ms) | 4 Bit Version 7 | 12 Bit Zähler
* 2 Bit Variante | 62 Bit Zufall
*
* Der Zähler in `rand_a` sichert die Reihenfolge auch innerhalb derselben
* Millisekunde. Ohne ihn kämen zwei schnell hintereinander gesendete
* Nachrichten in zufälliger Reihenfolge an.
*/
let letzteZeit = 0
let zaehler = 0
/** 12 Bit, danach wird auf die nächste Millisekunde gewartet. */
const ZAEHLER_MAX = 0xfff
export function uuidv7(now: number = Date.now()): string {
if (now === letzteZeit) {
zaehler += 1
if (zaehler > ZAEHLER_MAX) {
// Mehr als 4096 Kennungen in einer Millisekunde: lieber eine
// Millisekunde vorgreifen als die Sortierung verlieren.
letzteZeit += 1
zaehler = 0
now = letzteZeit
}
} else if (now > letzteZeit) {
letzteZeit = now
zaehler = 0
} else {
// Die Uhr ist zurückgesprungen. Die Reihenfolge ist wichtiger als der
// genaue Zeitpunkt, also weiterzählen statt zurückfallen.
letzteZeit += 1
zaehler = 0
now = letzteZeit
}
const bytes = new Uint8Array(16)
// 48 Bit Zeitstempel, big endian.
const zeit = BigInt(now)
for (let i = 0; i < 6; i++) {
bytes[i] = Number((zeit >> BigInt(8 * (5 - i))) & 0xffn)
}
// Version 7 in den oberen vier Bit von Byte 6, darunter der Zähler.
bytes[6] = 0x70 | ((zaehler >> 8) & 0x0f)
bytes[7] = zaehler & 0xff
const zufall = new Uint8Array(8)
crypto.getRandomValues(zufall)
bytes.set(zufall, 8)
// Variante 10xx in den oberen zwei Bit von Byte 8.
bytes[8] = (bytes[8]! & 0x3f) | 0x80
const hex = [...bytes].map((b) => b.toString(16).padStart(2, '0')).join('')
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
}
-1
View File
@@ -22,7 +22,6 @@
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"sharp": "0.34.4", "sharp": "0.34.4",
"uuidv7": "1.0.2",
"zod": "^4.0.0" "zod": "^4.0.0"
}, },
"devDependencies": { "devDependencies": {
+175
View File
@@ -0,0 +1,175 @@
// @vitest-environment node
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { db } from '@/lib/db'
import { enqueueCaseNotification } from '@/lib/notify/enqueue'
import type { Prisma } from '@afis/db'
/**
* Discord kennt unsere Klassifizierung nicht.
*
* Deshalb entscheidet **diese Anwendung**, wer eine Meldung bekommt und was
* darin stehen darf; der Worker liefert nur aus. Diese Tests prüfen genau die
* beiden Zusicherungen, auf denen das beruht.
*/
const url = process.env.DIRECT_DATABASE_URL ?? process.env.DATABASE_URL
const suite = url ? describe : describe.skip
const marke = `notify-${Date.now()}`
suite('Benachrichtigungen', () => {
const nutzer = {
autor: `${marke}-autor`,
stufe1: `${marke}-stufe1`,
stufe4: `${marke}-stufe4`,
aus: `${marke}-aus`,
ausgeschlossen: `${marke}-ausgeschlossen`,
}
let geheimId = ''
let geheimNummer = ''
async function empfaengerVon(kind: string): Promise<string[]> {
const zeilen = await db.notificationOutbox.findMany({
where: { caseId: geheimId, kind },
select: { recipientId: true },
})
return zeilen
.map((z) => z.recipientId)
.filter((id): id is string => id !== null)
.sort()
}
beforeAll(async () => {
const vorlage = await db.documentTemplate.create({
data: { key: `${marke}-vorlage`, version: 1, name: 'Prüfvorlage', fields: [] },
})
// Eine Rolle mit Stufe 4, damit die Rechteauflösung etwas zu tun hat.
const rolle = await db.roleMapping.create({
data: {
discordRoleId: `${marke}-rolle`,
label: 'Prüfstufe 4',
clearanceLevel: 4,
permissions: [],
grantsMembership: true,
category: 'CLEARANCE',
},
})
for (const [name, id] of Object.entries(nutzer)) {
await db.user.create({
data: {
id,
username: id,
displayName: id,
notifyMode: name === 'aus' ? 'OFF' : 'ALL',
...(name === 'stufe4' || name === 'ausgeschlossen'
? { roles: { create: { discordRoleId: rolle.discordRoleId } } }
: {}),
},
})
}
const akte = await db.case.create({
data: {
caseNumber: `Meld-2026-${String(Date.now()).slice(-4)}`,
title: 'Streng vertraulicher Vorgang',
templateId: vorlage.id,
templateKey: vorlage.key,
createdById: nutzer.autor,
classification: 4,
status: 'OPEN',
},
})
geheimId = akte.id
geheimNummer = akte.caseNumber
await db.caseExclusion.create({
data: { caseId: akte.id, userId: nutzer.ausgeschlossen, reason: 'Betroffener' },
})
})
afterAll(async () => {
await db.$disconnect()
})
it('schreibt nur an die, die die Akte auch sehen dürfen', async () => {
await db.$transaction(async (tx) => {
await enqueueCaseNotification(tx as Prisma.TransactionClient, {
kind: 'entry.new',
caseId: geheimId,
caseNumber: geheimNummer,
classification: 4,
title: 'Streng vertraulicher Vorgang',
actorId: nutzer.autor,
actorName: 'Autor',
})
})
const ziele = await empfaengerVon('entry.new')
// Stufe 4 bekommt die Meldung.
expect(ziele).toContain(nutzer.stufe4)
// Stufe 1 nicht — die Akte gibt es für ihn nicht.
expect(ziele).not.toContain(nutzer.stufe1)
// Wer abgeschaltet hat, bekommt nichts.
expect(ziele).not.toContain(nutzer.aus)
// Der Ausschluss schlägt auch die Stufe.
expect(ziele).not.toContain(nutzer.ausgeschlossen)
// Und der Auslöser bekommt keine Meldung über sich selbst.
expect(ziele).not.toContain(nutzer.autor)
})
it('lässt den Titel ab Stufe 3 weg', async () => {
const zeile = await db.notificationOutbox.findFirstOrThrow({
where: { caseId: geheimId, kind: 'entry.new' },
select: { meta: true },
})
const meta = zeile.meta as { caseNumber?: string; title?: string | null }
// Das Aktenzeichen darf hinaus, der Titel nicht: Discord kennt unsere
// Klassifizierung nicht, der Link führt zur App, die selbst prüft.
expect(meta.caseNumber).toBe(geheimNummer)
expect(meta.title).toBeNull()
})
it('legt eine Zeile für den allgemeinen Kanal an, ohne Empfänger', async () => {
const allgemein = await db.notificationOutbox.findMany({
where: { caseId: geheimId, kind: 'entry.new', recipientId: null },
})
expect(allgemein).toHaveLength(1)
})
it('nimmt bei niedriger Stufe den Titel mit', async () => {
const offen = await db.case.create({
data: {
caseNumber: `Offn-2026-${String(Date.now()).slice(-4)}`,
title: 'Harmloser Vorgang',
templateId: (await db.documentTemplate.findFirstOrThrow({
where: { key: `${marke}-vorlage` },
})).id,
templateKey: `${marke}-vorlage`,
createdById: nutzer.autor,
classification: 1,
status: 'OPEN',
},
})
await db.$transaction(async (tx) => {
await enqueueCaseNotification(tx as Prisma.TransactionClient, {
kind: 'entry.new',
caseId: offen.id,
caseNumber: offen.caseNumber,
classification: 1,
title: 'Harmloser Vorgang',
actorId: nutzer.autor,
actorName: 'Autor',
})
})
const zeile = await db.notificationOutbox.findFirstOrThrow({
where: { caseId: offen.id },
select: { meta: true },
})
expect((zeile.meta as { title?: string | null }).title).toBe('Harmloser Vorgang')
})
})
+225
View File
@@ -0,0 +1,225 @@
// @vitest-environment node
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { db } from '@/lib/db'
import { visibleChannelWhere, visibleMessageWhere } from '@/lib/access/chatFilter'
import { adoptAsEntry, listMessages, sendMessage } from '@/lib/chat/service'
import { resolveCasePreviews } from '@/lib/chat/preview'
import { AccessError } from '@/lib/errors'
import type { EffectivePermissions, Permission } from '@/lib/auth/permissions'
/**
* Der Chat erbt die Sichtbarkeit seines Ortes. Der heikelste Teil ist die
* Vorschaukarte: ein Aktenzeichen im Text darf einem zu niedrig eingestuften
* Leser nichts über die Akte verraten — auch nicht, ob es sie gibt.
*/
const url = process.env.DIRECT_DATABASE_URL ?? process.env.DATABASE_URL
const suite = url ? describe : describe.skip
const marke = `chat-${Date.now()}`
function perms(
userId: string,
clearanceLevel: number,
options: { isAdmin?: boolean; permissions?: Permission[] } = {},
): EffectivePermissions {
return {
userId,
clearanceLevel,
permissions: new Set<Permission>(options.permissions ?? []),
isAdmin: options.isAdmin ?? false,
hasMembership: true,
}
}
suite('Aktenchat und Kanäle', () => {
const nutzer = { stufe1: `${marke}-stufe1`, stufe4: `${marke}-stufe4` }
let offenNummer = ''
let geheimNummer = ''
let offenId = ''
let geheimId = ''
let kanalOffen = ''
let kanalGeheim = ''
beforeAll(async () => {
const vorlage = await db.documentTemplate.create({
data: { key: `${marke}-vorlage`, version: 1, name: 'Prüfvorlage', fields: [] },
})
for (const id of Object.values(nutzer)) {
await db.user.create({ data: { id, username: id, displayName: id } })
}
const gemeinsam = {
templateId: vorlage.id,
templateKey: vorlage.key,
createdById: nutzer.stufe4,
}
const offen = await db.case.create({
data: {
...gemeinsam,
caseNumber: `Chat-2026-${String(Date.now()).slice(-4)}`,
title: 'Offener Vorgang',
classification: 1,
status: 'OPEN',
},
})
const geheim = await db.case.create({
data: {
...gemeinsam,
caseNumber: `Gehm-2026-${String(Date.now()).slice(-4)}`,
title: 'Streng vertraulich',
classification: 4,
status: 'OPEN',
},
})
offenNummer = offen.caseNumber
geheimNummer = geheim.caseNumber
offenId = offen.id
geheimId = geheim.id
const k1 = await db.channel.create({
data: {
key: `${marke}-allgemein`.slice(0, 40),
name: 'Allgemein',
minClearance: 0,
createdById: nutzer.stufe4,
},
})
const k2 = await db.channel.create({
data: {
key: `${marke}-leitung`.slice(0, 40),
name: 'Leitung',
minClearance: 4,
createdById: nutzer.stufe4,
},
})
kanalOffen = k1.key
kanalGeheim = k2.key
})
afterAll(async () => {
await db.$disconnect()
})
it('verbirgt den Chat einer zu hoch eingestuften Akte', async () => {
await sendMessage(perms(nutzer.stufe4, 4), {
id: `${marke}-geheim-1`,
scope: { kind: 'case', caseNumber: geheimNummer },
body: 'Interner Stand',
})
// Nicht "leere Liste", sondern: es gibt den Ort für ihn nicht.
await expect(
listMessages(perms(nutzer.stufe1, 1), { kind: 'case', caseNumber: geheimNummer }),
).rejects.toThrow(AccessError)
const sichtbar = await db.chatMessage.findMany({
where: { AND: [visibleMessageWhere(perms(nutzer.stufe1, 1)), { caseId: geheimId }] },
select: { id: true },
})
expect(sichtbar).toHaveLength(0)
})
it('verbirgt einen Kanal über der eigenen Stufe', async () => {
const kanaele = await db.channel.findMany({
where: {
AND: [
visibleChannelWhere(perms(nutzer.stufe1, 1)),
{ key: { in: [kanalOffen, kanalGeheim] } },
],
},
select: { key: true },
})
expect(kanaele.map((k) => k.key)).toEqual([kanalOffen])
})
it('verrät über die Vorschaukarte nichts über eine verborgene Akte', async () => {
const fuerStufe1 = await resolveCasePreviews(perms(nutzer.stufe1, 1), [
offenNummer,
geheimNummer,
'AfiS-1999-0001',
])
// Sichtbar: mit Titel. Verborgen: ohne alles — und genauso wie eine Akte,
// die es gar nicht gibt.
expect(fuerStufe1.get(offenNummer)).toMatchObject({ visible: true, title: 'Offener Vorgang' })
expect(fuerStufe1.get(geheimNummer)).toEqual({ caseNumber: geheimNummer, visible: false })
expect(fuerStufe1.get('AfiS-1999-0001')).toEqual({
caseNumber: 'AfiS-1999-0001',
visible: false,
})
const fuerStufe4 = await resolveCasePreviews(perms(nutzer.stufe4, 4), [geheimNummer])
expect(fuerStufe4.get(geheimNummer)).toMatchObject({ visible: true })
})
it('behandelt dieselbe Kennung zweimal gesendet als eine Nachricht', async () => {
const id = `${marke}-doppelt`
const scope = { kind: 'case' as const, caseNumber: offenNummer }
await sendMessage(perms(nutzer.stufe1, 1), { id, scope, body: 'Einmal' })
await sendMessage(perms(nutzer.stufe1, 1), { id, scope, body: 'Nochmal' })
const treffer = await db.chatMessage.findMany({ where: { id }, select: { body: true } })
expect(treffer).toHaveLength(1)
// Der erste Text bleibt: das zweite Absenden war ein Wiederholungsversuch,
// keine Änderung.
expect(treffer[0]?.body).toBe('Einmal')
})
it('löst Erwähnungen serverseitig auf', async () => {
const id = `${marke}-erwaehnung`
await sendMessage(perms(nutzer.stufe4, 4), {
id,
scope: { kind: 'case', caseNumber: offenNummer },
// Der zweite Name gibt es nicht — er darf nicht in mentions landen.
body: `Bitte schauen @${nutzer.stufe1} und @niemand`,
})
const nachricht = await db.chatMessage.findUniqueOrThrow({
where: { id },
select: { mentions: true },
})
expect(nachricht.mentions).toEqual([nutzer.stufe1])
})
it('macht aus einer Nachricht einen Vermerk und lässt sie stehen', async () => {
const id = `${marke}-uebernahme`
const p = perms(nutzer.stufe4, 4, { permissions: ['entry.create', 'case.edit'] })
await sendMessage(p, {
id,
scope: { kind: 'case', caseNumber: offenNummer },
body: 'Der Zeuge widerruft seine Aussage.',
})
const { entryId } = await adoptAsEntry(p, id)
const eintrag = await db.caseEntry.findUniqueOrThrow({ where: { id: entryId } })
expect(eintrag.body).toBe('Der Zeuge widerruft seine Aussage.')
// Der Vermerk gehört dem Verfasser, nicht dem, der ihn übernommen hat.
expect(eintrag.authorId).toBe(nutzer.stufe4)
expect(eintrag.type).toBe('NOTE')
const nachricht = await db.chatMessage.findUniqueOrThrow({ where: { id } })
expect(nachricht.adoptedEntryId).toBe(entryId)
// Ein Systemeintrag nennt den Ursprung, damit später niemand denkt, der
// Wortlaut sei für die Akte geschrieben worden.
const hinweis = await db.caseEntry.findFirst({
where: { caseId: offenId, type: 'SYSTEM', body: { contains: 'Aktenchat' } },
select: { id: true },
})
expect(hinweis).not.toBeNull()
// Zweimal übernehmen erzeugt keinen zweiten Vermerk.
expect((await adoptAsEntry(p, id)).entryId).toBe(entryId)
})
it('macht aus einer Kanalnachricht keinen Vermerk', async () => {
const id = `${marke}-kanal`
const p = perms(nutzer.stufe4, 4, { permissions: ['entry.create', 'case.edit'] })
await sendMessage(p, { id, scope: { kind: 'channel', key: kanalOffen }, body: 'Absprache' })
// Ein Vermerk gehört in eine Akte. Ein Kanal hat keine.
await expect(adoptAsEntry(p, id)).rejects.toThrow(AccessError)
})
})
+44
View File
@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest'
import { extractMentionNames, splitBody } from '@/lib/chat/mentions'
import { extractCaseNumbers } from '@/lib/chat/view'
describe('Erwähnungen und Aktenzeichen im Text', () => {
it('findet Erwähnungen und normalisiert die Schreibweise', () => {
expect(extractMentionNames('Hallo @Vogel und @vogel und @kranz_j')).toEqual([
'vogel',
'kranz_j',
])
})
it('zerlegt den Text, statt HTML daraus zu bauen', () => {
// Kein dangerouslySetInnerHTML für Nutzerinhalte, siehe docs/07.
const stuecke = splitBody('Siehe AfiS-2026-0042, @vogel weiss mehr')
expect(stuecke.map((s) => s.kind)).toEqual(['text', 'caseNumber', 'text', 'mention', 'text'])
expect(stuecke[1]).toMatchObject({ kind: 'caseNumber', value: 'AfiS-2026-0042' })
})
it('lässt Auszeichnungszeichen als Text stehen', () => {
const stuecke = splitBody('<script>alert(1)</script>')
expect(stuecke).toEqual([{ kind: 'text', value: '<script>alert(1)</script>' }])
})
it('macht aus einer Überlappung nicht zwei Stücke', () => {
// `@AfiS-2026-0001` ist beides. Der erste Treffer gewinnt, sonst stünde
// ein Teil des Textes doppelt da.
const stuecke = splitBody('@AfiS-2026-0001')
const zusammen = stuecke.map((s) => s.value).join('')
expect(zusammen).toBe('@AfiS-2026-0001')
})
it('erkennt gewachsene Präfixe im Aktenzeichen', () => {
expect(extractCaseNumbers('AKT-PA-2026-62843 und AfiS-2026-0007')).toEqual([
'AKT-PA-2026-62843',
'AfiS-2026-0007',
])
})
it('hält Text ohne Treffer zusammen', () => {
expect(splitBody('nur Text')).toEqual([{ kind: 'text', value: 'nur Text' }])
expect(extractCaseNumbers('kein Zeichen hier')).toEqual([])
})
})
+47
View File
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest'
import { uuidv7 } from '@/lib/uuidv7'
/**
* Auf der Sortierbarkeit dieser Kennungen beruhen zwei Dinge: die
* Nachlieferung verpasster Nachrichten (`id > seit`) und die Reihenfolge im
* Chat. Bricht sie, kommen Nachrichten durcheinander an oder gehen bei einem
* Wiederverbinden verloren — beides fällt spät und unangenehm auf.
*/
describe('UUIDv7', () => {
it('hat die richtige Form, Version und Variante', () => {
const id = uuidv7()
expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/)
})
it('trägt den Zeitstempel in den ersten 48 Bit', () => {
// Muss über allem liegen, was vorherige Aufrufe schon vergeben haben:
// der Zähler im Modul geht bewusst nie zurück.
const zeitpunkt = Date.now() + 60_000
const id = uuidv7(zeitpunkt)
const hex = id.replace(/-/g, '').slice(0, 12)
expect(Number.parseInt(hex, 16)).toBe(zeitpunkt)
})
it('bleibt innerhalb derselben Millisekunde sortierbar', () => {
// Ohne Zähler kämen zwei schnell hintereinander gesendete Nachrichten in
// zufälliger Reihenfolge an.
const zeitpunkt = Date.now()
const ids = Array.from({ length: 500 }, () => uuidv7(zeitpunkt))
expect([...ids].sort()).toEqual(ids)
expect(new Set(ids).size).toBe(ids.length)
})
it('sortiert über Millisekunden hinweg', () => {
const frueh = uuidv7(1_700_000_000_000)
const spaet = uuidv7(1_700_000_000_001)
expect(frueh < spaet).toBe(true)
})
it('fällt bei einer zurückspringenden Uhr nicht zurück', () => {
// Sommerzeit, NTP-Korrektur: die Reihenfolge ist wichtiger als der genaue
// Zeitpunkt, sonst überholt eine neue Nachricht eine alte.
const vorher = uuidv7(1_800_000_000_000)
const nachher = uuidv7(1_700_000_000_000)
expect(nachher > vorher).toBe(true)
})
})
+6
View File
@@ -19,6 +19,10 @@ export type WorkerEnv = {
fullSyncIntervalMs: number fullSyncIntervalMs: number
heartbeatIntervalMs: number heartbeatIntervalMs: number
adminChannelId?: string adminChannelId?: string
/** Takt des Postausgangs als Sicherheitsnetz neben `notification_queued`. */
outboxIntervalMs: number
/** Steht in den Benachrichtigungen als Link. */
appUrl: string
} }
function required(name: string): string { function required(name: string): string {
@@ -39,5 +43,7 @@ export function loadEnv(): WorkerEnv {
fullSyncIntervalMs: Number(process.env.WORKER_FULL_SYNC_MS ?? 5 * 60_000), fullSyncIntervalMs: Number(process.env.WORKER_FULL_SYNC_MS ?? 5 * 60_000),
heartbeatIntervalMs: Number(process.env.WORKER_HEARTBEAT_MS ?? 30_000), heartbeatIntervalMs: Number(process.env.WORKER_HEARTBEAT_MS ?? 30_000),
adminChannelId: process.env.DISCORD_ADMIN_CHANNEL_ID, adminChannelId: process.env.DISCORD_ADMIN_CHANNEL_ID,
outboxIntervalMs: Number(process.env.WORKER_OUTBOX_MS ?? 30_000),
appUrl: process.env.APP_URL ?? 'http://localhost:3000',
} }
} }
+43
View File
@@ -1,8 +1,10 @@
import { Client, Events, GatewayIntentBits, type Guild } from 'discord.js' import { Client, Events, GatewayIntentBits, type Guild } from 'discord.js'
import { Client as PgClient } from 'pg'
import { createPrismaClient } from '@afis/db' import { createPrismaClient } from '@afis/db'
import { loadEnv } from './env.js' import { loadEnv } from './env.js'
import { writeHeartbeat } from './heartbeat.js' import { writeHeartbeat } from './heartbeat.js'
import { enforceMembership, handleDeparture, syncMember, syncRoles } from './sync.js' import { enforceMembership, handleDeparture, syncMember, syncRoles } from './sync.js'
import { dispatchOutbox } from './notify.js'
/** /**
* Rollen-Abgleich-Worker. Hängt am Discord-Gateway und hält Guild-Rollen und * Rollen-Abgleich-Worker. Hängt am Discord-Gateway und hält Guild-Rollen und
@@ -116,6 +118,27 @@ async function main(): Promise<void> {
).catch((error: unknown) => console.error('[worker] Lebenszeichen fehlgeschlagen', error)) ).catch((error: unknown) => console.error('[worker] Lebenszeichen fehlgeschlagen', error))
}, env.heartbeatIntervalMs) }, env.heartbeatIntervalMs)
// Postausgang: der Takt ist das Sicherheitsnetz, der Anstoss über
// `notification_queued` der Regelfall. Siehe worker/src/notify.ts.
let laeuft = false
async function zustellen(grund: string): Promise<void> {
if (!gatewayReady || laeuft) return
laeuft = true
try {
const anzahl = await dispatchOutbox(db, client, {
appUrl: env.appUrl,
adminChannelId: env.adminChannelId,
})
if (anzahl > 0) console.log(`[worker] ${anzahl} Benachrichtigungen zugestellt (${grund})`)
} catch (error) {
console.error('[worker] Postausgang fehlgeschlagen', error)
} finally {
laeuft = false
}
}
const outbox = setInterval(() => void zustellen('planmäßig'), env.outboxIntervalMs)
const periodicSync = setInterval(() => { const periodicSync = setInterval(() => {
if (!gatewayReady) return if (!gatewayReady) return
void fullSync('planmäßig').catch((error: unknown) => void fullSync('planmäßig').catch((error: unknown) =>
@@ -127,6 +150,9 @@ async function main(): Promise<void> {
console.log(`[worker] ${signal}, fahre herunter`) console.log(`[worker] ${signal}, fahre herunter`)
clearInterval(heartbeat) clearInterval(heartbeat)
clearInterval(periodicSync) clearInterval(periodicSync)
clearInterval(outbox)
listener.removeAllListeners()
await listener.end().catch(() => undefined)
await writeHeartbeat(db, 'down', 'planmäßig beendet').catch(() => undefined) await writeHeartbeat(db, 'down', 'planmäßig beendet').catch(() => undefined)
await client.destroy() await client.destroy()
await db.$disconnect() await db.$disconnect()
@@ -136,6 +162,23 @@ async function main(): Promise<void> {
process.on('SIGTERM', () => void shutdown('SIGTERM')) process.on('SIGTERM', () => void shutdown('SIGTERM'))
process.on('SIGINT', () => void shutdown('SIGINT')) process.on('SIGINT', () => void shutdown('SIGINT'))
// Eigener Client für LISTEN: im Transaktionsmodus von pgbouncer überlebt
// ein LISTEN den Pool nicht, und Prisma gibt seine Verbindung nicht her.
const listener = new PgClient({
connectionString: process.env.DIRECT_DATABASE_URL ?? env.databaseUrl,
})
listener.on('error', (error: unknown) =>
console.error('[worker] LISTEN-Verbindung verloren', error),
)
listener.on('notification', () => void zustellen('angestoßen'))
await listener
.connect()
.then(() => listener.query('LISTEN notification_queued'))
.catch((error: unknown) => {
// Ohne LISTEN bleibt der Takt. Das ist langsamer, aber nicht kaputt.
console.error('[worker] LISTEN nicht möglich, nutze nur den Takt', error)
})
await writeHeartbeat(db, 'degraded', 'Start läuft') await writeHeartbeat(db, 'degraded', 'Start läuft')
await client.login(env.discordBotToken) await client.login(env.discordBotToken)
} }
+128
View File
@@ -0,0 +1,128 @@
import type { Client } from 'discord.js'
import type { PrismaClient } from '@afis/db'
/**
* Zustellung der Benachrichtigungen.
*
* Der Worker ist hier **nur Transport**. Wer etwas erfahren darf und was davon
* gezeigt werden darf, hat die Web-App beim Einstellen in den Postausgang
* entschieden — sie hat die Sichtbarkeitsprüfung, er hat sie nicht. In `meta`
* steht deshalb ausschliesslich, was stehen darf: ein Titel liegt dort nur,
* wenn er auch nach Discord darf. Siehe docs/06-chat-und-realtime.md.
*
* Der Dauerläufer holt in zwei Takten: sofort, wenn `notification_queued`
* kommt, und alle 30 Sekunden als Sicherheitsnetz. Kein Einmal-Container,
* siehe CLAUDE.md, harte Regel 8.
*/
/** Nach so vielen Fehlversuchen wird eine Zeile liegengelassen. */
const MAX_ATTEMPTS = 5
/** Wie viele Zeilen ein Durchgang höchstens abarbeitet. */
const BATCH = 25
type Meta = {
caseNumber?: string
actorName?: string
title?: string | null
channelKey?: string
}
/**
* Baut den Satz. Bewusst knapp und ohne jeden Inhalt aus der Akte — der Link
* führt zur Anwendung, und die prüft dann selbst.
*/
export function composeMessage(
kind: string,
meta: Meta,
appUrl: string,
): string | null {
const wer = meta.actorName ?? 'Jemand'
if (meta.caseNumber) {
const link = `${appUrl.replace(/\/$/, '')}/faelle/${meta.caseNumber}`
const bezeichnung = meta.title
? `**${meta.caseNumber}** — ${meta.title}`
: `**${meta.caseNumber}**`
if (kind === 'entry.new') return `Neuer Eintrag in ${bezeichnung} · ${wer} · ${link}`
if (kind === 'chat.mention') return `${wer} hat dich in ${bezeichnung} erwähnt · ${link}`
if (kind === 'chat.new') return `Neue Nachricht im Aktenchat ${bezeichnung} · ${wer} · ${link}`
if (kind === 'case.status') return `Status geändert in ${bezeichnung} · ${wer} · ${link}`
return null
}
if (meta.channelKey && kind === 'chat.mention') {
const link = `${appUrl.replace(/\/$/, '')}/kanaele/${meta.channelKey}`
return `${wer} hat dich in #${meta.channelKey} erwähnt · ${link}`
}
return null
}
export async function dispatchOutbox(
db: PrismaClient,
client: Client,
options: { appUrl: string; adminChannelId?: string },
): Promise<number> {
const offen = await db.notificationOutbox.findMany({
where: { sentAt: null, attempts: { lt: MAX_ATTEMPTS } },
orderBy: { id: 'asc' },
take: BATCH,
})
let zugestellt = 0
for (const eintrag of offen) {
const text = composeMessage(eintrag.kind, (eintrag.meta ?? {}) as Meta, options.appUrl)
// Nichts zu sagen ist kein Fehler: die Zeile wird abgehakt, damit sie den
// Postausgang nicht verstopft.
if (!text) {
await db.notificationOutbox.update({
where: { id: eintrag.id },
data: { sentAt: new Date(), lastError: 'nichts zu melden' },
})
continue
}
try {
if (eintrag.recipientId) {
const nutzer = await client.users.fetch(eintrag.recipientId)
await nutzer.send(text)
} else if (options.adminChannelId) {
const kanal = await client.channels.fetch(options.adminChannelId)
if (kanal?.isTextBased() && 'send' in kanal) {
await kanal.send(text)
}
} else {
// Kein allgemeiner Kanal eingerichtet: die Zeile ist erledigt, ohne
// dass etwas hinausging.
await db.notificationOutbox.update({
where: { id: eintrag.id },
data: { sentAt: new Date(), lastError: 'kein Kanal konfiguriert' },
})
continue
}
await db.notificationOutbox.update({
where: { id: eintrag.id },
data: { sentAt: new Date(), attempts: { increment: 1 } },
})
zugestellt++
} catch (error) {
// Eine geschlossene Direktnachricht ist keine Störung, sondern eine
// Entscheidung des Empfängers. Sie wird gezählt und irgendwann
// aufgegeben, aber nie laut.
await db.notificationOutbox.update({
where: { id: eintrag.id },
data: {
attempts: { increment: 1 },
lastError: error instanceof Error ? error.message.slice(0, 200) : 'unbekannt',
},
})
}
}
return zugestellt
}
+42
View File
@@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest'
import { composeMessage } from '../src/notify.js'
/**
* Der Worker baut den Satz aus dem, was die Web-App freigegeben hat. Hier wird
* geprüft, dass er nichts hinzuerfindet — vor allem keinen Titel, wo keiner
* mitgeschickt wurde.
*/
describe('Text der Benachrichtigung', () => {
const url = 'https://afis.example.de/'
it('nennt Aktenzeichen und Link, den Titel nur wenn er mitkam', () => {
const ohne = composeMessage(
'entry.new',
{ caseNumber: 'AfiS-2026-0042', actorName: 'Insp. Vogel', title: null },
url,
)
expect(ohne).toBe(
'Neuer Eintrag in **AfiS-2026-0042** · Insp. Vogel · https://afis.example.de/faelle/AfiS-2026-0042',
)
expect(ohne).not.toContain('undefined')
const mit = composeMessage(
'entry.new',
{ caseNumber: 'AfiS-2026-0042', actorName: 'Insp. Vogel', title: 'Korruptionsverdacht' },
url,
)
expect(mit).toContain('Korruptionsverdacht')
})
it('kann eine Erwähnung in einem Kanal melden', () => {
expect(
composeMessage('chat.mention', { channelKey: 'leitung', actorName: 'Vogel' }, url),
).toBe('Vogel hat dich in #leitung erwähnt · https://afis.example.de/kanaele/leitung')
})
it('schweigt, wenn nichts Verwertbares mitkam', () => {
// Lieber gar nichts als ein Satz mit Lücken.
expect(composeMessage('entry.new', {}, url)).toBeNull()
expect(composeMessage('unbekannt', { caseNumber: 'AfiS-2026-0001' }, url)).toBeNull()
})
})