Prüfung / Lint, Typen, Tests (push) Canceled after 0s
Prüfung / Images bauen (pdf) (push) Canceled after 0s
Prüfung / Images bauen (web) (push) Canceled after 0s
Prüfung / Images bauen (worker) (push) Canceled after 0s
Images / bauen (pdf, docker/pdf.Dockerfile) (push) Canceled after 0s
Images / bauen (web, docker/web.Dockerfile) (push) Canceled after 0s
Images / bauen (worker, docker/worker.Dockerfile) (push) Canceled after 0s
375 Vorkommen in 210 Dateien. Betrifft Kommentare, Oberflaechentexte und die README; an keiner Stelle Bedeutung, nur Zeichensetzung.
466 lines
15 KiB
TypeScript
466 lines
15 KiB
TypeScript
import { db } from '@/lib/db'
|
|
import { writeAudit } from '@/lib/audit/write'
|
|
import { AccessError, must } from '@/lib/errors'
|
|
import { visibleCaseWhere } from '@/lib/access/caseFilter'
|
|
import { visibleMessageWhere } from '@/lib/access/chatFilter'
|
|
import { visiblePersonnelWhere } from '@/lib/access/personnelFilter'
|
|
import { visibleDepartmentWhere } from '@/lib/access/departmentFilter'
|
|
import { canEditCase } from '@/lib/access/caseWrite'
|
|
import { extractMentionNames } from '@/lib/chat/mentions'
|
|
import type { ReactionKey } from '@/lib/chat/terms'
|
|
import {
|
|
caseTopic,
|
|
departmentTopic,
|
|
personnelTopic,
|
|
publish,
|
|
type Topic,
|
|
} from '@/lib/realtime/publish'
|
|
import { enqueueCaseNotification, enqueuePersonnelNotification } from '@/lib/notify/enqueue'
|
|
import type { EffectivePermissions } from '@/lib/auth/permissions'
|
|
import type { Prisma } from '@afis/db'
|
|
|
|
/**
|
|
* Der Aktenchat.
|
|
*
|
|
* Er hängt immer an einer Akte - einer Fallakte oder einer Personalakte - und
|
|
* 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: 'personnel'; fileId: string }
|
|
| { kind: 'department'; departmentId: string }
|
|
|
|
const NACHRICHT = {
|
|
id: true,
|
|
body: true,
|
|
authorId: true,
|
|
replyToId: true,
|
|
editedAt: true,
|
|
withdrawnAt: true,
|
|
adoptedEntryId: true,
|
|
mentions: true,
|
|
createdAt: true,
|
|
caseId: true,
|
|
personnelFileId: true,
|
|
departmentId: 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
|
|
personnelFileId?: string
|
|
departmentId?: 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
|
|
}
|
|
|
|
if (scope.kind === 'department') {
|
|
const abteilung = await db.department.findFirst({
|
|
where: { AND: [visibleDepartmentWhere(p), { id: scope.departmentId }] },
|
|
select: { id: true, key: true },
|
|
})
|
|
return abteilung
|
|
? {
|
|
departmentId: abteilung.id,
|
|
topic: departmentTopic(abteilung.id),
|
|
label: abteilung.key,
|
|
}
|
|
: null
|
|
}
|
|
|
|
// Personalakten setzen neben der Stufe ein eigenes Recht voraus.
|
|
if (!p.isAdmin && !p.permissions.has('personnel.read')) return null
|
|
|
|
const akte = await db.personnelFile.findFirst({
|
|
where: { AND: [visiblePersonnelWhere(p), { id: scope.fileId }] },
|
|
select: { id: true, displayName: true },
|
|
})
|
|
return akte
|
|
? { personnelFileId: akte.id, topic: personnelTopic(akte.id), label: akte.displayName }
|
|
: 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 }
|
|
: ort.personnelFileId
|
|
? { personnelFileId: ort.personnelFileId }
|
|
: { departmentId: ort.departmentId }
|
|
|
|
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 }
|
|
: ort.personnelFileId
|
|
? { personnelFileId: ort.personnelFileId }
|
|
: { departmentId: ort.departmentId }),
|
|
},
|
|
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,
|
|
personnelFileId: ort.personnelFileId ?? null,
|
|
departmentId: ort.departmentId ?? 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 (ort.personnelFileId) {
|
|
// Im Chat einer Personalakte geht nur an ausdrücklich Erwähnte etwas
|
|
// hinaus, und auch das nur an die, die die Akte lesen dürfen.
|
|
await enqueuePersonnelNotification(tx, {
|
|
personnelFileId: ort.personnelFileId,
|
|
actorId: p.userId,
|
|
actorName: absenderName,
|
|
mentioned: erwaehnt.map((user) => user.id),
|
|
meta: { messageId: nachricht.id },
|
|
})
|
|
}
|
|
|
|
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
|
|
personnelFileId: string | null
|
|
departmentId: string | null
|
|
}): Topic {
|
|
if (nachricht.caseId) return caseTopic(nachricht.caseId)
|
|
if (nachricht.personnelFileId) return personnelTopic(nachricht.personnelFileId)
|
|
return departmentTopic(nachricht.departmentId!)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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' | 'personnel' | 'department',
|
|
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' | 'personnel' | 'department',
|
|
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 },
|
|
})
|
|
}
|