Gemessen statt vermutet: die Datenschicht war nie das Problem. resolvePermissions unter 1 ms, loadDashboard 11 ms, searchCases 5 ms, die Abteilungsabfragen 3–4 ms. Die Sekunden kamen aus zwei Ecken. **Fehlende Ladegrenzen.** Den meisten Routen fehlte loading.tsx - unter anderem der Übersicht, der Suche und dem gesamten Adminbereich. Ohne diese Datei hat der Router keine Grenze, wartet stumm auf die fertige Serverantwort, und bis dahin steht die alte Seite: man klickt, und scheinbar passiert nichts. Neunzehn Skelette nachgetragen, jede Route hat jetzt eine. Dazu quittiert der geklickte Link den Klick selbst, mit einem ruhig pulsierenden Punkt statt eines Kreisels. Regel 5 verbietet den Blockade-Spinner, der Inhalt ersetzt; eine Quittung im Link ersetzt nichts, und die eigentliche Antwort auf das Warten bleibt das Skelett. **Webpack.** Der Entwicklungsserver übersetzte jede Route beim ersten Aufruf neu: 8 s für die Übersicht, 2,8 s für die Fallliste. Mit Turbopack sind es 0,43 s beim ersten Aufruf und 0,1 s danach. Nur pnpm dev, der Produktionsbau bleibt unverändert. Gegengeprüft, dass die Anmeldeseite mit CSS und ohne Fehler im Log rendert. Nebenbei die eine Abfrage, die schlecht skalierte: die Übersicht zählte ungelesene Nachrichten mit einer Abfrage je Ort - bei hundert Akten hundert Hin- und Rückwege. Jetzt ein einziges groupBy mit der Lesemarke im ODER-Zweig des jeweiligen Ortes. Ein Test prüft, was dabei leicht verrutscht: die Marke gilt je Ort, und eigene Nachrichten zählen nie mit.
190 lines
5.7 KiB
TypeScript
190 lines
5.7 KiB
TypeScript
import { db } from '@/lib/db'
|
|
import { visibleCaseWhere } from '@/lib/access/caseFilter'
|
|
import { visibleMessageWhere } from '@/lib/access/chatFilter'
|
|
import { visiblePersonnelWhere } from '@/lib/access/personnelFilter'
|
|
import { listWatched } from '@/lib/cases/watch'
|
|
import { listRecent } from '@/lib/recent/service'
|
|
import { WORKING_ROLES } from '@/lib/cases/terms'
|
|
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, role: { in: [...WORKING_ROLES] } } } },
|
|
{ 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' | 'personnel'
|
|
scopeId: string
|
|
label: string
|
|
href: string
|
|
count: number
|
|
}
|
|
|
|
/**
|
|
* Wo seit dem letzten Besuch geschrieben wurde.
|
|
*
|
|
* Gezählt wird über `ReadMarker`. Nur an Orten, die den Nutzer angehen: Akten,
|
|
* an denen er beteiligt ist oder die er beobachtet, und Personalakten, denen
|
|
* er zugeordnet ist. Eine Zählung über alles Sichtbare 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 darfPersonal = p.isAdmin || p.permissions.has('personnel.read')
|
|
|
|
const [meineAkten, personalakten] = await Promise.all([
|
|
db.case.findMany({
|
|
where: {
|
|
AND: [
|
|
visibleCaseWhere(p),
|
|
{
|
|
OR: [
|
|
{ participants: { some: { userId: p.userId, role: { in: [...WORKING_ROLES] } } } },
|
|
{ watches: { some: { userId: p.userId } } },
|
|
],
|
|
},
|
|
],
|
|
},
|
|
select: { id: true, caseNumber: true },
|
|
take: 50,
|
|
}),
|
|
darfPersonal
|
|
? db.personnelFile.findMany({
|
|
where: {
|
|
AND: [
|
|
visiblePersonnelWhere(p),
|
|
{ assignments: { some: { userId: p.userId } } },
|
|
],
|
|
},
|
|
select: { id: true, displayName: true },
|
|
take: 50,
|
|
})
|
|
: Promise.resolve([]),
|
|
])
|
|
|
|
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,
|
|
})),
|
|
...personalakten.map((akte) => ({
|
|
scopeType: 'personnel' as const,
|
|
scopeId: akte.id,
|
|
label: akte.displayName,
|
|
href: `/personalakten/${akte.id}`,
|
|
wo: { personnelFileId: akte.id } as Prisma.ChatMessageWhereInput,
|
|
})),
|
|
]
|
|
if (orte.length === 0) return []
|
|
|
|
// **Eine** Abfrage statt einer je Ort. Vorher war das ein `count` pro Akte
|
|
// und pro Personalakte — bei hundert Orten hundert Hin- und Rückwege zur
|
|
// Datenbank, jeder für sich schnell und in der Summe die langsamste Stelle
|
|
// der Übersicht. Die Lesemarke steckt als Bedingung im ODER-Zweig des
|
|
// jeweiligen Ortes, gezählt wird über `groupBy`.
|
|
const zweige = orte.map((ort) => {
|
|
const seit = markeVon.get(`${ort.scopeType}:${ort.scopeId}`)
|
|
return seit ? { AND: [ort.wo, { createdAt: { gt: seit } }] } : ort.wo
|
|
})
|
|
|
|
const gruppen = await db.chatMessage.groupBy({
|
|
by: ['caseId', 'personnelFileId'],
|
|
where: {
|
|
AND: [
|
|
visibleMessageWhere(p),
|
|
{ withdrawnAt: null },
|
|
{ authorId: { not: p.userId } },
|
|
{ OR: zweige },
|
|
],
|
|
},
|
|
_count: { _all: true },
|
|
})
|
|
|
|
const anzahlVon = new Map(
|
|
gruppen.map((gruppe) => [gruppe.caseId ?? gruppe.personnelFileId ?? '', gruppe._count._all]),
|
|
)
|
|
|
|
return orte
|
|
.map((ort) => ({ ort, count: anzahlVon.get(ort.scopeId) ?? 0 }))
|
|
.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,
|
|
}))
|
|
}
|