Files
AfiS-Aktensystem/web/lib/dashboard/load.ts
T
Harlekin db40c5d3b2 Zuletzt geöffnet als Sprungliste, ohne Verlauf
Die letzte offene Zeile aus TASKS.md. Sie blieb offen, weil eine Spur über
jeden Aktenaufruf schnell etwas anderes wird als eine Bequemlichkeit.

Vier Festlegungen halten sie auf der richtigen Seite:

  - Kein Verlauf. Der Primärschlüssel (userId, targetType, targetId) lässt je
    Nutzer und Datensatz genau eine Zeile zu, die überschrieben wird. Die
    Tabelle kann nicht sagen, wie oft oder in welcher Reihenfolge gearbeitet
    wurde. Zwanzig Einträge, älteres fällt hinten heraus.
  - Nur die eigene. Keine Ansicht, kein Adminbereich, keine Funktion mit
    fremder Kennung. Jede Abfrage nimmt p.userId.
  - Jederzeit löschbar, ohne Rückfrage und ohne Grabstein.
  - Vorabrufe zählen nicht. Ohne die Prüfung auf next-router-prefetch stünde
    dort, worüber jemand mit der Maus gefahren ist.

Gelesen wird über dieselben Sichtbarkeitsfragmente wie überall: eine
vorgemerkte Kennung ist keine Berechtigung. Steigt eine Akte über die
Freigabestufe, verschwindet sie ohne Lücke und ohne Platzhalter; Personalakten
brauchen zusätzlich personnel.read. Das Zugriffsprotokoll bleibt unberührt und
bleibt die einzige Auskunft darüber, wer wann was gelesen hat.

Sie steht auf der Übersicht und im leeren Feld der Kommandopalette. Der zweite
Ort ist der eigentliche.

Nebenbei: NEXT_DIST_DIR, damit ein Build neben einem laufenden
Entwicklungsserver nicht dasselbe .next-Verzeichnis abräumt.
2026-09-03 10:52:23 +02:00

172 lines
4.7 KiB
TypeScript

import { db } from '@/lib/db'
import { visibleCaseWhere } from '@/lib/access/caseFilter'
import { visibleMessageWhere } from '@/lib/access/chatFilter'
import { listWatched } from '@/lib/cases/watch'
import { listRecent } from '@/lib/recent/service'
import type { EffectivePermissions } from '@/lib/auth/permissions'
import type { Prisma } from '@afis/db'
/**
* Was auf der Übersicht steht.
*
* Fünf Fragen, die man morgens hat: woran war ich dran, woran arbeite ich, was
* ist fällig, wo hat jemand geschrieben, was hat sich getan. Jede läuft durch
* `visibleCaseWhere` — die Übersicht ist der Ort, an dem eine unachtsame
* Abfrage am ehesten eine Akte verrät, die man nicht sehen darf.
*/
const AKTE = {
id: true,
caseNumber: true,
title: true,
status: true,
classification: true,
updatedAt: true,
dueAt: true,
} satisfies Prisma.CaseSelect
/** Fristen innerhalb dieser Anzahl Tage gelten als „steht an". */
const FRIST_TAGE = 14
export async function loadDashboard(p: EffectivePermissions) {
const sichtbar = visibleCaseWhere(p)
const jetzt = new Date()
const frist = new Date(jetzt.getTime() + FRIST_TAGE * 86_400_000)
const [meine, fristen, zuletzt, beobachtet, ungelesen, geoeffnet] = await Promise.all([
db.case.findMany({
where: {
AND: [
sichtbar,
{ participants: { some: { userId: p.userId } } },
{ status: { in: ['DRAFT', 'OPEN', 'REVIEW'] } },
],
},
orderBy: { updatedAt: 'desc' },
take: 10,
select: AKTE,
}),
db.case.findMany({
where: {
AND: [
sichtbar,
{ dueAt: { not: null, lte: frist } },
{ status: { notIn: ['CLOSED', 'ARCHIVED', 'SEALED'] } },
],
},
orderBy: { dueAt: 'asc' },
take: 10,
select: AKTE,
}),
db.case.findMany({
where: sichtbar,
orderBy: { updatedAt: 'desc' },
take: 8,
select: AKTE,
}),
listWatched(p, 10),
ungeleseneChats(p),
listRecent(p, 8),
])
return { meine, fristen, zuletzt, beobachtet, ungelesen, geoeffnet }
}
export type UnreadChat = {
scopeType: 'case' | 'channel'
scopeId: string
label: string
href: string
count: number
}
/**
* Wo seit dem letzten Besuch geschrieben wurde.
*
* Gezählt wird über `ReadMarker`. Nur an Orten, an denen der Nutzer beteiligt
* ist, die er beobachtet, oder in Kanälen, die er sehen darf — eine Zählung
* über alle sichtbaren Akten wäre auf der Übersicht nur Lärm. Eigene
* Nachrichten zählen nicht mit.
*/
async function ungeleseneChats(p: EffectivePermissions): Promise<UnreadChat[]> {
const marken = await db.readMarker.findMany({
where: { userId: p.userId },
select: { scopeType: true, scopeId: true, lastReadAt: true },
})
const markeVon = new Map(marken.map((m) => [`${m.scopeType}:${m.scopeId}`, m.lastReadAt]))
const [meineAkten, kanaele] = await Promise.all([
db.case.findMany({
where: {
AND: [
visibleCaseWhere(p),
{
OR: [
{ participants: { some: { userId: p.userId } } },
{ watches: { some: { userId: p.userId } } },
],
},
],
},
select: { id: true, caseNumber: true },
take: 50,
}),
db.channel.findMany({
where: {
isArchived: false,
...(p.isAdmin ? {} : { minClearance: { lte: p.clearanceLevel } }),
},
select: { id: true, key: true },
take: 50,
}),
])
const orte = [
...meineAkten.map((akte) => ({
scopeType: 'case' as const,
scopeId: akte.id,
label: akte.caseNumber,
href: `/faelle/${akte.caseNumber}`,
wo: { caseId: akte.id } as Prisma.ChatMessageWhereInput,
})),
...kanaele.map((kanal) => ({
scopeType: 'channel' as const,
scopeId: kanal.id,
label: `#${kanal.key}`,
href: `/kanaele/${kanal.key}`,
wo: { channelId: kanal.id } as Prisma.ChatMessageWhereInput,
})),
]
if (orte.length === 0) return []
const zaehler = await Promise.all(
orte.map(async (ort) => {
const seit = markeVon.get(`${ort.scopeType}:${ort.scopeId}`)
const count = await db.chatMessage.count({
where: {
AND: [
visibleMessageWhere(p),
ort.wo,
{ withdrawnAt: null },
{ authorId: { not: p.userId } },
...(seit ? [{ createdAt: { gt: seit } }] : []),
],
},
})
return { ort, count }
}),
)
return zaehler
.filter((eintrag) => eintrag.count > 0)
.sort((a, b) => b.count - a.count)
.slice(0, 10)
.map(({ ort, count }) => ({
scopeType: ort.scopeType,
scopeId: ort.scopeId,
label: ort.label,
href: ort.href,
count,
}))
}