import type { Prisma } from '@afis/db' import { db } from '@/lib/db' import { visibleCaseWhere } from '@/lib/access/caseFilter' import { visibleDocumentWhere, visiblePersonnelWhere } from '@/lib/access/personnelFilter' import { visibleDepartmentEntryWhere, visibleDepartmentWhere, } from '@/lib/access/departmentFilter' import type { EffectivePermissions } from '@/lib/auth/permissions' import type { CaseForPrint } from '@/components/print/CaseSheet' import type { DocumentForPrint } from '@/components/print/DocumentSheet' /** * Was in ein Dossier gehört. * * Ein Dossier ist die Personalakte einer Person, dahinter alle ihre Dokumente * und dahinter alle Fallakten, in denen sie vorkommt - alles in derselben * Druckdarstellung wie einzeln. Für eine Abteilung dasselbe, je Mitglied * getrennt. * * Zwei Grenzen wirken hier, und sie sind **nicht** dasselbe: * * 1. **Die Freigabestufe des Abrufers.** Was darüber liegt, existiert für ihn * nicht: es steht in keiner Liste, in keiner Zählung und in keinem * Hinweis. Das ist harte Regel 6, und sie gilt hier wie überall. * 2. **Die gewählte Höchststufe des Exports.** Das ist eine Entscheidung des * Abrufers, kein Schutz vor ihm. Was er dadurch weglässt, **muss im PDF * stehen** - sonst hält der Empfänger eine gekürzte Mappe für eine * vollständige. Deshalb zählt `omitted` mit, und deshalb zählt es nur, * was der Abrufer selbst sehen dürfte. * * Beide laufen in der Datenbankabfrage, nicht danach in JavaScript. */ /** * Obergrenze je Abschnitt. Bei dieser Nutzerzahl unerreichbar; sie steht hier, * damit ein Abzug nicht unbemerkt ins Unendliche wächst. Wird sie erreicht, * sagt das PDF es - abgeschnitten und stillschweigend wäre das Schlimmste. */ export const DOSSIER_LIMIT = 250 export type OmittedCount = { level: number; documents: number; cases: number } export type Dossier = { documents: DocumentForPrint[] cases: CaseForPrint[] /** Innerhalb der eigenen Stufe, aber über der gewählten Höchststufe. */ omitted: OmittedCount[] omittedTotal: number truncated: boolean } const DOKUMENT_FELDER = { id: true, documentNumber: true, title: true, templateId: true, templateKey: true, layout: true, status: true, classification: true, createdAt: true, updatedAt: true, retractedAt: true, } satisfies Prisma.DocumentSelect const AKTE_FELDER = { id: true, caseNumber: true, title: true, status: true, templateId: true, templateKey: true, classification: true, createdById: true, dueAt: true, updatedAt: true, } satisfies Prisma.CaseSelect /** Die gewählte Höchststufe, gedeckelt auf das, was der Abrufer selbst darf. */ export function effectiveCap(p: EffectivePermissions, requested: number): number { const gewuenscht = Number.isInteger(requested) ? requested : p.clearanceLevel return Math.max(0, Math.min(gewuenscht, p.clearanceLevel)) } /** Dokumente an dieser Personalakte, und Fallakten, in denen sie vorkommt. */ function bedingungen(fileId: string, userId: string | null) { const wenDieAkteMeint: Prisma.CaseParticipantWhereInput[] = [{ personnelFileId: fileId }] // Dieselbe Person kann über ihre Personalakte oder über ihr Discord-Konto // eingetragen sein. Beides zählt, sonst fehlen Fälle - wie bei den // Rückverweisen in lib/personnel/service.ts. if (userId) wenDieAkteMeint.push({ userId }) return { dokumente: { personnelFileId: fileId } satisfies Prisma.DocumentWhereInput, faelle: { participants: { some: { OR: wenDieAkteMeint } }, } satisfies Prisma.CaseWhereInput, } } /** * Alles, was zu einer Person ins Dossier gehört. * * `cap` ist bereits gedeckelt zu übergeben (siehe `effectiveCap`) - diese * Funktion deckelt nicht noch einmal, damit klar bleibt, wo das passiert. */ export async function collectPersonnelDossier( p: EffectivePermissions, file: { id: string; userId: string | null }, cap: number, ): Promise { const wo = bedingungen(file.id, file.userId) const [dokumente, faelle, dokumenteDrueber, faelleDrueber] = await Promise.all([ db.document.findMany({ where: { AND: [visibleDocumentWhere(p), wo.dokumente, { classification: { lte: cap } }], }, orderBy: [{ createdAt: 'asc' }, { id: 'asc' }], take: DOSSIER_LIMIT, select: DOKUMENT_FELDER, }), db.case.findMany({ where: { AND: [visibleCaseWhere(p), wo.faelle, { classification: { lte: cap } }] }, orderBy: [{ caseNumber: 'asc' }], take: DOSSIER_LIMIT, select: AKTE_FELDER, }), // Was der Abrufer sehen dürfte, aber selbst ausgeschlossen hat. Gezählt // wird in der Datenbank und **innerhalb** der Sichtbarkeitsbedingung: was // über seiner eigenen Stufe liegt, taucht auch hier nicht auf. db.document.groupBy({ by: ['classification'], where: { AND: [visibleDocumentWhere(p), wo.dokumente, { classification: { gt: cap } }], }, _count: { _all: true }, }), db.case.groupBy({ by: ['classification'], where: { AND: [visibleCaseWhere(p), wo.faelle, { classification: { gt: cap } }] }, _count: { _all: true }, }), ]) return { documents: dokumente, cases: faelle, ...zusammenfassen(dokumenteDrueber, faelleDrueber), truncated: dokumente.length === DOSSIER_LIMIT || faelle.length === DOSSIER_LIMIT, } } function zusammenfassen( dokumente: { classification: number; _count: { _all: number } }[], faelle: { classification: number; _count: { _all: number } }[], ): { omitted: OmittedCount[]; omittedTotal: number } { const nachStufe = new Map() const hinein = (stufe: number, art: 'documents' | 'cases', anzahl: number) => { const zeile = nachStufe.get(stufe) ?? { level: stufe, documents: 0, cases: 0 } zeile[art] += anzahl nachStufe.set(stufe, zeile) } for (const zeile of dokumente) hinein(zeile.classification, 'documents', zeile._count._all) for (const zeile of faelle) hinein(zeile.classification, 'cases', zeile._count._all) const omitted = [...nachStufe.values()].sort((a, b) => a.level - b.level) return { omitted, omittedTotal: omitted.reduce((summe, zeile) => summe + zeile.documents + zeile.cases, 0), } } /** * Ein Mitglied mit **allen** Kopfdaten, die der Druckbogen zeigt. * * Nicht nur Name und Dienstgrad: würde der Bogen im Abteilungsabzug mit * leeren Feldern gerendert, stünde dort "Kein Status gesetzt" bei einer * Person, die sehr wohl einen trägt. Ein Export darf nie sauberer aussehen, * als die Akte ist. */ export type DepartmentMember = { id: string userId: string | null displayName: string rank: string | null classification: number statusTags: string[] note: string | null createdAt: Date department: { key: string; name: string } | null } /** * Die Mitglieder einer Abteilung, soweit sie der Abrufer sehen darf. * * Eine Personalakte über seiner Stufe fehlt hier vollständig und wird auch * nicht gezählt - sonst verriete die Mitgliederzahl ihre Existenz. */ export async function departmentMembers( p: EffectivePermissions, departmentId: string, cap: number, ): Promise<{ members: DepartmentMember[]; omittedMembers: number }> { const [members, drueber] = await Promise.all([ db.personnelFile.findMany({ where: { AND: [ visiblePersonnelWhere(p), { departmentId }, { classification: { lte: cap } }, ], }, orderBy: [{ displayName: 'asc' }], take: DOSSIER_LIMIT, select: { id: true, userId: true, displayName: true, rank: true, classification: true, statusTags: true, note: true, createdAt: true, department: { select: { key: true, name: true } }, }, }), db.personnelFile.count({ where: { AND: [visiblePersonnelWhere(p), { departmentId }, { classification: { gt: cap } }], }, }), ]) return { members, omittedMembers: drueber } } /** * Der Stand eines Dossiers für den Zwischenspeicherschlüssel. * * Nicht das `updatedAt` der Personalakte allein: ein Dossier ändert sich auch * dann, wenn eines seiner Dokumente oder eine seiner Fallakten sich ändert. * Ohne das gäbe der Zwischenspeicher einen veralteten Abzug heraus, und ein * Export darf nie einen älteren Stand zeigen, als die Akten haben. */ export async function personnelDossierStand( p: EffectivePermissions, file: { id: string; userId: string | null; updatedAt: Date }, cap: number, ): Promise { const wo = bedingungen(file.id, file.userId) const [dokument, akte] = await Promise.all([ db.document.aggregate({ where: { AND: [visibleDocumentWhere(p), wo.dokumente, { classification: { lte: cap } }] }, _max: { updatedAt: true }, }), db.case.aggregate({ where: { AND: [visibleCaseWhere(p), wo.faelle, { classification: { lte: cap } }] }, _max: { updatedAt: true }, }), ]) return neuestes([file.updatedAt, dokument._max.updatedAt, akte._max.updatedAt]) } /** Derselbe Gedanke für eine Abteilung, über alle sichtbaren Mitglieder. */ export async function departmentDossierStand( p: EffectivePermissions, department: { id: string; updatedAt: Date }, cap: number, ): Promise { const { members } = await departmentMembers(p, department.id, cap) const staende = await Promise.all( members.map((mitglied) => personnelDossierStand( p, { id: mitglied.id, userId: mitglied.userId, updatedAt: department.updatedAt }, cap, ), ), ) // Vermerke tragen kein `updatedAt`: sie werden nicht überschrieben, sondern // bekommen eine Fassung oder einen Grabstein. Der Stand ist deshalb der // späteste dieser drei Zeitpunkte. const vermerk = await db.departmentEntry.aggregate({ where: { AND: [visibleDepartmentEntryWhere(p), { departmentId: department.id }] }, _max: { createdAt: true, editedAt: true, retractedAt: true }, }) return neuestes([ department.updatedAt, vermerk._max?.createdAt, vermerk._max?.editedAt, vermerk._max?.retractedAt, ...staende, ]) } function neuestes(werte: (Date | null | undefined)[]): Date { let neueste = new Date(0) for (const wert of werte) { if (wert && wert.getTime() > neueste.getTime()) neueste = wert } return neueste } /** Die Abteilung, sofern der Abrufer sie sehen darf. */ export async function loadVisibleDepartmentForPrint(p: EffectivePermissions, key: string) { return db.department.findFirst({ where: { AND: [visibleDepartmentWhere(p), { key }] }, select: { id: true, key: true, name: true, description: true, minClearance: true, isArchived: true, updatedAt: true, }, }) }