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
+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.read'
| 'attachment.release'
| 'chat.adopt'
| 'channel.create'
| 'roles.update'
| 'export.pdf'
+22 -1
View File
@@ -5,6 +5,8 @@ import { canCreateCase, canEditCase, canReclassify } from '@/lib/access/caseWrit
import { nextNumber } from '@/lib/numbering'
import { findTransition, type CaseStatusName } from '@/lib/cases/lifecycle'
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'
/**
@@ -115,7 +117,12 @@ export async function addEntry(
const minClearance = input.minClearance ?? 0
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
// wiederholtes Absenden nach einem Netzfehler erzeugt keinen zweiten
// Eintrag. Siehe docs/09-performance-und-ux.md.
@@ -134,6 +141,17 @@ export async function addEntry(
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, {
actorId: p.userId,
action: 'entry.create',
@@ -145,6 +163,9 @@ export async function addEntry(
return entry
})
await publish(caseTopic(target.id), 'entry.new', angelegt.id)
return angelegt
}
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 { detectType, matchesDeclared } from '@/lib/files/magic'
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:
@@ -33,22 +33,34 @@ export async function runCheckChain(
): Promise<void> {
const anhang = await db.attachment.findUnique({
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
const thema = await themaVon(anhang)
try {
const inhalt = new Uint8Array(await readFile(absolutePath(anhang.storageKey)))
// 1. Typprüfung. Der gemeldete Typ steht bis hierher in mimeType.
const erkannt = detectType(inhalt)
if (!erkannt.ok) {
return blockieren(attachmentId, typFehler(erkannt.reason))
return blockieren(attachmentId, typFehler(erkannt.reason), thema)
}
if (!matchesDeclared(erkannt.type, anhang.mimeType)) {
return blockieren(
attachmentId,
`Angekündigt als ${anhang.mimeType}, erkannt als ${erkannt.type}`,
thema,
)
}
@@ -58,33 +70,34 @@ export async function runCheckChain(
if (!scan.ok) {
// Nicht blockiert, sondern unentschieden: die Datei ist nicht als böse
// erkannt, sie ist ungeprüft. Ein Administrator kann sie freigeben.
return abbrechen(attachmentId, scan.reason)
return abbrechen(attachmentId, scan.reason, thema)
}
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.
const neu = await reencode(inhalt, erkannt.type)
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
// die Entscheidung, die der Administrator trifft, und sie steht danach
// 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}`,
})
}
// 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,
})
} catch (error) {
await abbrechen(
attachmentId,
error instanceof Error ? error.message : 'Prüfung fehlgeschlagen',
thema,
)
}
}
@@ -103,10 +116,40 @@ type Fertig = {
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(
attachmentId: string,
type: string,
neu: Fertig,
thema: Topic,
options: { hinweis?: string } = {},
): Promise<void> {
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. */
async function blockieren(attachmentId: string, grund: string): Promise<void> {
async function blockieren(attachmentId: string, grund: string, thema: Topic): Promise<void> {
await db.attachment.update({
where: { id: attachmentId },
data: { scanStatus: 'BLOCKED', scanMessage: grund },
})
await notifyAttachmentScanned(attachmentId)
await notifyAttachmentScanned(attachmentId, thema)
}
/** 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({
where: { id: attachmentId },
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.',
'participant-target': 'Entweder ein Discord-Konto oder eine Personalakte, nicht beides.',
'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.',
} as Record<string, string>
+19 -2
View File
@@ -14,9 +14,23 @@
* Client-Komponente importiert **den Bereich, den sie braucht** — sonst traegt
* 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 { 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 { documentText, attachmentText } from '@/lib/i18n/documents'
import { roleAdminText, templateAdminText, settingsText } from '@/lib/i18n/admin'
@@ -35,6 +49,8 @@ export const de = {
assignment: assignmentText,
participant: participantText,
link: linkText,
chat: chatText,
channel: channelText,
document: documentText,
attachment: attachmentText,
common: commonText,
@@ -43,4 +59,5 @@ export const de = {
templateAdmin: templateAdminText,
settings: settingsText,
health: healthText,
notify: notifyText,
} as const
+15
View File
@@ -84,3 +84,18 @@ export const healthText = {
clamav: 'Virenprüfung',
} as Record<string, string>,
} 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 })),
],
})
}
+19 -12
View File
@@ -1,6 +1,6 @@
import { Client } from 'pg'
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.
@@ -10,13 +10,13 @@ import { ATTACHMENT_CHANNEL } from '@/lib/realtime/publish'
* Drei Kanäle: `member_changed` verwirft die Rechte eines Nutzers,
* `role_mapping_changed` verwirft alles. Dadurch ist eine Rechteänderung in
* Discord in unter einer Sekunde wirksam, ohne dass ein einziger Request auf
* die Discord-API wartet. `attachment_scanned` meldet das Ende einer
* Dateiprüfung an die offenen Ansichten.
* die Discord-API wartet. `afis_stream` trägt alles, was offene Ansichten
* angeht: neue Chatnachrichten, Verlaufseinträge, geprüfte Dateien.
*
* Die Verbindung läuft an pgbouncer vorbei: im Transaktionsmodus überlebt ein
* `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
@@ -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
* Laden.
*/
type Subscriber = (event: { channel: string; payload: string }) => void
type Subscriber = (event: StreamEvent) => void
const globalForSubscribers = globalThis as unknown as { afisSubscribers?: Set<Subscriber> }
const subscribers = (globalForSubscribers.afisSubscribers ??= new Set<Subscriber>())
@@ -53,13 +53,20 @@ function connect(): Listener {
invalidateAll()
return
}
if (message.payload) {
for (const listener of subscribers) {
try {
listener({ channel: message.channel, payload: message.payload })
} catch (error) {
console.error('[realtime] Empfänger hat geworfen', error)
}
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) {
try {
listener(event)
} catch (error) {
console.error('[realtime] Empfänger hat geworfen', error)
}
}
})
+51 -5
View File
@@ -1,4 +1,5 @@
import { db } from '@/lib/db'
import type { Prisma } from '@afis/db'
/**
* 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
* auch, wenn später ein zweiter Web-Prozess dazukommt, und es braucht keinen
* 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 {
// Der Kanalname ist eine Konstante, nur die Nutzlast kommt von aussen —
// und die ist eine Kennung aus der Datenbank.
await db.$executeRaw`SELECT pg_notify(${ATTACHMENT_CHANNEL}, ${attachmentId})`
const client = tx ?? db
await client.$executeRaw`SELECT pg_notify(${STREAM_CHANNEL}, ${payload})`
} catch (error) {
// 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)
}
}
/** 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)}`
}