Vier der acht Änderungswünsche. **Knöpfe.** Rund vierzig der siebzig Knöpfe waren blosser Text in Akzentfarbe, von einem Link nicht zu unterscheiden. Jeder trägt jetzt Rahmen und Grundfläche, über die Klasse .btn an einer Stelle definiert statt siebzigmal als Klassenkette. Beim Drücken zieht der Knopf sich zusammen und bekommt einen inneren Ring - er wirkt kurz dicker, ohne dass die Rahmenbreite sich ändert und das Umfeld springt; die Rückmeldung setzt ohne Verzögerung ein und klingt langsam ab, sonst bliebe ein kurzer Klick unsichtbar. Dazu die neue Regel: Flächen bleiben eckig, alles Bedienbare ist rund. Damit ist die Rundung eine Auskunft und keine Verzierung. **Verwaltung.** Rollen, Vorlagen und Änderungsrichtlinie waren drei Einträge in der Kopfzeile für etwas, das man selten und dann zusammenhängend braucht. Jetzt ein Eintrag /admin mit Reiterleiste, dazu eine gemeinsame Tabs-Komponente, die auch der Fallakte zeigt, welcher Reiter offen ist. **Kanäle.** Ersatzlos entfernt, mitsamt Tabelle. Der Chat gehört dorthin, wo der Vorgang steht; ein eigener Reiter mit freien Räumen war der Rückweg nach Discord. Die Personalakte hat jetzt denselben Chat wie die Fallakte - ohne „als Vermerk übernehmen", denn dafür braucht es einen Verlauf, und Meldungen gehen nur an Erwähnte, nie in den allgemeinen Kanal: dass es zu jemandem eine Personalakte gibt, ist selbst eine Auskunft. Ein CHECK erzwingt jetzt, dass eine Nachricht zu genau einem Ort gehört. **Kopfzeile.** Suchbalken statt Suchlink, als gewöhnliches Formular ohne ein Byte im Bündel. Thema, Benachrichtigungen und Abmelden sitzen hinter dem Profilbild, das von Discord kommt; wer keins hat, bekommt seine Initialen. Nebenbei: Aktenzeichen in Tests waren nur auf vier Stellen eindeutig und kollidierten zwischen Läufen, weil Akten nicht löschbar sind.
425 lines
14 KiB
TypeScript
425 lines
14 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 { visibleMessageWhere } from '@/lib/access/chatFilter'
|
|
import { visiblePersonnelWhere } from '@/lib/access/personnelFilter'
|
|
import { canEditCase } from '@/lib/access/caseWrite'
|
|
import { extractMentionNames } from '@/lib/chat/mentions'
|
|
import type { ReactionKey } from '@/lib/chat/terms'
|
|
import { caseTopic, 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 }
|
|
|
|
const NACHRICHT = {
|
|
id: true,
|
|
body: true,
|
|
authorId: true,
|
|
replyToId: true,
|
|
editedAt: true,
|
|
withdrawnAt: true,
|
|
adoptedEntryId: true,
|
|
mentions: true,
|
|
createdAt: true,
|
|
caseId: true,
|
|
personnelFileId: 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; 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
|
|
}
|
|
|
|
// 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 }
|
|
: { personnelFileId: ort.personnelFileId }
|
|
|
|
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 } : { personnelFileId: ort.personnelFileId }),
|
|
},
|
|
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,
|
|
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 }): Topic {
|
|
if (nachricht.caseId) return caseTopic(nachricht.caseId)
|
|
return personnelTopic(nachricht.personnelFileId!)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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',
|
|
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',
|
|
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 },
|
|
})
|
|
}
|