Files
Harlekin e0b03ea336
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
Gedankenstriche durch Bindestriche ersetzt
375 Vorkommen in 210 Dateien. Betrifft Kommentare, Oberflaechentexte und die
README; an keiner Stelle Bedeutung, nur Zeichensetzung.
2026-09-04 08:54:52 +02:00

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,
}))
}