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.
This commit is contained in:
Harlekin
2026-09-02 21:06:03 +02:00
parent 10eeea4897
commit cf4348b8b1
59 changed files with 3776 additions and 510 deletions
+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) ?? [])]
}