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.
488 lines
16 KiB
TypeScript
488 lines
16 KiB
TypeScript
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
|
|
})
|
|
}
|