Files
AfiS-Aktensystem/web/app/api/stream/route.ts
T
HarlekinandClaude Opus 5 1d637bc85e Abteilungen als Ebene über der Personalakte
Punkt 3 der Wünsche.

Die Abteilung stand als Freitext in der Personalakte. Damit liess sich nichts
fragen: „SHD", „Shd" und „Sicherheitsdienst" waren drei Abteilungen. Sie ist
jetzt ein eigenes Modell, im Adminbereich verwaltbar, und die drei vorhandenen
Freitexte sind in der Migration zu Abteilungen geworden.

**Eine Fallakte trägt keine Abteilung.** Sie gehört zu einer, wenn eine
beteiligte Person ihr angehört — abgeleitet über CaseParticipant und die
Personalakte. Eine eingetragene Zuordnung wäre nach zwei Wochen falsch: Leute
wechseln, Beteiligte kommen dazu, und niemand pflegt das nach. Dasselbe gilt
für die Dokumente: ein Dokument unmittelbar an der Abteilung wäre halb
verdrahtet, weil die Vorlagen über TemplateUse nur Fall- und Personalakte
kennen.

Die Abteilungsseite zeigt Mitglieder, abgeleitete Fälle und Dokumente,
Vermerke, Chat und ein Kreisdiagramm der Fallarten; die Übersicht eines über
alle Abteilungen. Das Diagramm ist serverseitig gerendertes SVG — eine
Bibliothek wäre das Teuerste auf der Seite gewesen. Die Segmente sind
Abstufungen von --steel und nicht die Stufenfarben: ein Tortenstück in Oxidrot
würde als „streng geheim" gelesen.

Die Zusammenfassung ist die gefährliche Stelle. Eine Abteilungsseite fasst
zusammen, was einzeln längst geschützt ist, und eine Zusammenfassung, die nicht
filtert, verrät mehr als jede Einzelansicht. Deshalb läuft hier jede Liste und
jede Zahl durch dieselben Fragmente wie überall; ein Test prüft, dass die Summe
der Diagrammanteile mit der Länge der Liste übereinstimmt. Wo jemand
Personalakten gar nicht sehen darf, steht null und keine Null.

Vermerke erben Regel 2 unverändert: kein DELETE, Version bei jeder Änderung,
Grabstein bei jeder Zurücknahme, als Regel und Trigger in der Datenbank. Ohne
Korrekturkette, Anhänge und Karenzzeit — was Beweiswert hat, gehört in eine
Fallakte.

Nebenbei: das Zugriffsprotokoll hängt jetzt am dafür vorgesehenen Recht
audit.read statt an admin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 19:57:28 +02:00

338 lines
11 KiB
TypeScript

import { getCurrentUser, type CurrentUser } from '@/lib/auth/current'
import { db } from '@/lib/db'
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 { visibleAttachmentWhere } from '@/lib/access/attachmentFilter'
import { resolveCasePreviews, type CasePreview } from '@/lib/chat/preview'
import { extractCaseNumbers, type ChatMessageView } from '@/lib/chat/view'
import { ensureListening, subscribe } from '@/lib/realtime/listener'
import type { StreamEvent, Topic } from '@/lib/realtime/publish'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
/** Gegen Zwischenspeicher und Tunnel, die eine stille Verbindung für tot halten. */
const HEARTBEAT_MS = 25_000
/**
* Ereignisstrom für offene Ansichten.
*
* GET /api/stream?topics=case:<id>,personnel:<id>
*
* Server-Sent Events und kein WebSocket: die Richtung ist einseitig, es geht
* durch jeden Tunnel, und der Browser baut die Verbindung von selbst wieder
* auf. Siehe docs/06-chat-und-realtime.md.
*
* Zwei Prüfungen, beide nötig:
* 1. **Beim Verbinden**: welche der angefragten Themen darf dieser Nutzer
* überhaupt abonnieren. Wer ein fremdes Thema nennt, bekommt es nicht —
* und erfährt auch nicht, ob es existiert.
* 2. **Je Ereignis**: der Inhalt wird mit den Rechten des Empfängers geladen.
* Eine Umklassifizierung mitten in einer offenen Verbindung wirkt dadurch
* sofort, ohne dass jemand neu laden muss.
*/
export async function GET(request: Request): Promise<Response> {
const user = await getCurrentUser()
if (!user) return new Response(null, { status: 404 })
const url = new URL(request.url)
const gewuenscht = (url.searchParams.get('topics') ?? '')
.split(',')
.map((t) => t.trim())
.filter(Boolean)
.slice(0, 20)
const erlaubt = await erlaubteThemen(user, gewuenscht)
await ensureListening()
const encoder = new TextEncoder()
let heartbeat: ReturnType<typeof setInterval> | null = null
let unsubscribe: (() => void) | null = null
let offen = true
const stream = new ReadableStream({
async start(controller) {
const send = (event: string, data: unknown, id?: string) => {
if (!offen) return
try {
const zeilen = [
...(id ? [`id: ${id}`] : []),
`event: ${event}`,
`data: ${JSON.stringify(data)}`,
'',
'',
]
controller.enqueue(encoder.encode(zeilen.join('\n')))
} catch {
offen = false
}
}
// Verpasstes nachliefern. Der Browser schickt beim Wiederverbinden von
// selbst die zuletzt gesehene Kennung mit; die Kennungen sind UUIDv7 und
// damit zeitlich sortiert, ein Vergleich darauf reicht.
const seit = request.headers.get('last-event-id') ?? url.searchParams.get('since')
if (seit) await nachliefern(user, erlaubt, seit, send)
send('ready', { topics: erlaubt })
heartbeat = setInterval(() => {
if (!offen) return
try {
controller.enqueue(encoder.encode(': ping\n\n'))
} catch {
offen = false
}
}, HEARTBEAT_MS)
unsubscribe = subscribe((event) => {
if (!erlaubt.includes(event.t)) return
void zustellen(user, event, send)
})
request.signal.addEventListener('abort', () => {
offen = false
try {
controller.close()
} catch {
// schon zu
}
})
},
cancel() {
offen = false
if (heartbeat) clearInterval(heartbeat)
unsubscribe?.()
},
})
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
// Der Tunnel darf nicht puffern, sonst kommt alles am Stück.
'X-Accel-Buffering': 'no',
},
})
}
/**
* Filtert die angefragten Themen auf die, die der Nutzer sehen darf.
*
* Das eigene Nutzerthema ist immer dabei, alles andere muss belegt werden.
*/
async function erlaubteThemen(user: CurrentUser, gewuenscht: string[]): Promise<Topic[]> {
const themen: Topic[] = [`user:${user.id}`]
const fallIds = gewuenscht
.filter((t) => t.startsWith('case:'))
.map((t) => t.slice(5))
.filter(Boolean)
const personalIds = gewuenscht
.filter((t) => t.startsWith('personnel:'))
.map((t) => t.slice(10))
.filter(Boolean)
if (fallIds.length > 0) {
const sichtbar = await db.case.findMany({
where: { AND: [visibleCaseWhere(user.permissions), { id: { in: fallIds } }] },
select: { id: true },
})
themen.push(...sichtbar.map((akte) => `case:${akte.id}` as Topic))
}
// Personalakten setzen neben der Stufe ein eigenes Recht voraus.
const darfPersonal =
user.permissions.isAdmin || user.permissions.permissions.has('personnel.read')
if (personalIds.length > 0 && darfPersonal) {
const sichtbar = await db.personnelFile.findMany({
where: { AND: [visiblePersonnelWhere(user.permissions), { id: { in: personalIds } }] },
select: { id: true },
})
themen.push(...sichtbar.map((akte) => `personnel:${akte.id}` as Topic))
}
const abteilungIds = gewuenscht
.filter((t) => t.startsWith('department:'))
.map((t) => t.slice(11))
.filter(Boolean)
if (abteilungIds.length > 0) {
const sichtbar = await db.department.findMany({
where: { AND: [visibleDepartmentWhere(user.permissions), { id: { in: abteilungIds } }] },
select: { id: true },
})
themen.push(...sichtbar.map((abteilung) => `department:${abteilung.id}` as Topic))
}
return themen
}
type Send = (event: string, data: unknown, id?: string) => void
/** Was seit `seit` in den abonnierten Themen passiert ist. */
async function nachliefern(
user: CurrentUser,
themen: Topic[],
seit: string,
send: Send,
): Promise<void> {
const fallIds = themen.filter((t) => t.startsWith('case:')).map((t) => t.slice(5))
const personalIds = themen.filter((t) => t.startsWith('personnel:')).map((t) => t.slice(10))
const abteilungIds = themen.filter((t) => t.startsWith('department:')).map((t) => t.slice(11))
if (fallIds.length === 0 && personalIds.length === 0 && abteilungIds.length === 0) return
try {
const verpasst = await db.chatMessage.findMany({
where: {
AND: [
visibleMessageWhere(user.permissions),
{ id: { gt: seit } },
{
OR: [
{ caseId: { in: fallIds } },
{ personnelFileId: { in: personalIds } },
{ departmentId: { in: abteilungIds } },
],
},
],
},
orderBy: { id: 'asc' },
take: 200,
select: NACHRICHT,
})
for (const angereichert of await anreichern(user, verpasst)) {
send('chat.new', angereichert, angereichert.id)
}
} catch (error) {
console.error('[stream] Nachlieferung fehlgeschlagen', error)
}
}
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,
} as const
/**
* Ergänzt, was die Anzeige braucht und was **je Empfänger verschieden** ist:
* den Namen des Autors und die Vorschaukarten zu erwähnten Aktenzeichen. Die
* Karten werden mit den Rechten des Empfängers aufgelöst, deshalb hier und
* nicht beim Absender.
*/
function vorschauenFuer(
body: string,
alle: Map<string, CasePreview>,
): Record<string, CasePreview> {
const treffer: Record<string, CasePreview> = {}
for (const nummer of extractCaseNumbers(body)) {
const vorschau = alle.get(nummer)
if (vorschau) treffer[nummer] = vorschau
}
return treffer
}
async function anreichern(
user: CurrentUser,
nachrichten: {
id: string
body: string
authorId: string
replyToId: string | null
editedAt: Date | null
withdrawnAt: Date | null
adoptedEntryId: string | null
mentions: string[]
createdAt: Date
}[],
): Promise<ChatMessageView[]> {
if (nachrichten.length === 0) return []
const [autoren, vorschauen] = await Promise.all([
db.user.findMany({
where: { id: { in: [...new Set(nachrichten.map((n) => n.authorId))] } },
select: { id: true, displayName: true, username: true },
}),
resolveCasePreviews(
user.permissions,
nachrichten.flatMap((n) => extractCaseNumbers(n.body)),
),
])
const nameVon = new Map(autoren.map((a) => [a.id, a.displayName]))
return nachrichten.map((nachricht) => ({
id: nachricht.id,
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: vorschauenFuer(nachricht.body, vorschauen),
}))
}
/**
* Lädt den Inhalt zu einem Ereignis **mit den Rechten des Empfängers**.
*
* Findet die Abfrage nichts, wird nichts geschickt: weder Inhalt noch die
* Kennung. Sonst verriete schon die Kennung, dass es etwas gibt.
*/
async function zustellen(user: CurrentUser, event: StreamEvent, send: Send): Promise<void> {
try {
if (event.k.startsWith('chat.')) {
const nachricht = await db.chatMessage.findFirst({
where: { AND: [visibleMessageWhere(user.permissions), { id: event.i }] },
select: NACHRICHT,
})
if (!nachricht) return
const [angereichert] = await anreichern(user, [nachricht])
if (angereichert) send(event.k, angereichert, angereichert.id)
return
}
if (event.k === 'attachment.scanned') {
const anhang = await db.attachment.findFirst({
where: { AND: [visibleAttachmentWhere(user.permissions), { id: event.i }] },
select: {
id: true,
scanStatus: true,
scanMessage: true,
mimeType: true,
sizeBytes: true,
thumbKey: true,
blurhash: true,
width: true,
height: true,
},
})
if (anhang) {
send('attachment.scanned', { ...anhang, hasThumb: anhang.thumbKey !== null, thumbKey: undefined })
}
return
}
// Verlaufseinträge und Kopfdaten: nur die Kennung. Die Ansicht lädt
// daraufhin nach, statt dass hier ein zweiter Renderpfad entsteht.
send(event.k, { id: event.i })
} catch (error) {
console.error('[stream] Ereignis nicht zugestellt', error)
}
}