import { db } from '@/lib/db' import { visibleCaseWhere } from '@/lib/access/caseFilter' import { visibleDocumentWhere, visiblePersonnelWhere } from '@/lib/access/personnelFilter' import type { EffectivePermissions } from '@/lib/auth/permissions' import type { RecentTarget } from '@afis/db' /** * „Zuletzt geöffnet" - die persönliche Sprungliste. * * Sie beantwortet eine Frage, die man oft hat: *woran war ich gerade dran?* * Und sie beantwortet ausdrücklich **keine** andere. Drei Dinge halten sie auf * dieser Seite der Grenze, siehe docs/13-entscheidungen.md: * * 1. **Kein Verlauf.** Je Nutzer und Datensatz gibt es eine Zeile, die beim * nächsten Öffnen überschrieben wird. Aus dieser Tabelle lässt sich nicht * ablesen, wie oft oder in welcher Reihenfolge jemand gearbeitet hat. * 2. **Nur die eigene.** Es gibt in der Anwendung keinen Weg, die Liste eines * anderen zu lesen - keine Ansicht, kein Adminbereich, keine Funktion mit * fremder Kennung als Parameter. Jede Abfrage hier nimmt `p.userId`. * 3. **Jederzeit löschbar**, ohne Rückfrage und ohne Spur. * * Wer wann welche Akte gelesen hat, beantwortet allein das Zugriffsprotokoll * ab Stufe 3. Das ist der Ort, an dem diese Auskunft hingehört, weil sie dort * geregelt, begründet und selbst wieder protokolliert ist. */ /** So viele Einträge behält die Liste. Älteres fällt hinten heraus. */ export const RECENT_MAX = 20 /** * Einen Sprung vormerken. * * Nur aufrufen, wenn der Nutzer den Datensatz auch wirklich sehen durfte - * die Aufrufer stehen hinter `loadVisible…` und 404. */ export async function noteOpened( userId: string, targetType: RecentTarget, targetId: string, ): Promise { const jetzt = new Date() await db.recentView.upsert({ where: { userId_targetType_targetId: { userId, targetType, targetId } }, create: { userId, targetType, targetId, openedAt: jetzt }, update: { openedAt: jetzt }, }) // Hinten abschneiden. Die Grenze ist der erste Eintrag jenseits von // RECENT_MAX; alles ab dort abwärts fliegt raus. const grenze = await db.recentView.findMany({ where: { userId }, orderBy: { openedAt: 'desc' }, skip: RECENT_MAX, take: 1, select: { openedAt: true }, }) if (grenze[0]) { await db.recentView.deleteMany({ where: { userId, openedAt: { lte: grenze[0].openedAt } }, }) } } /** Die Liste leeren. Gibt zurück, wie viele Einträge verschwunden sind. */ export async function clearRecent(userId: string): Promise { const { count } = await db.recentView.deleteMany({ where: { userId } }) return count } export type RecentEntry = { targetType: RecentTarget targetId: string href: string /** Aktenzeichen, Dokumentnummer oder Name - das, was man wiedererkennt. */ label: string /** Titel der Akte, Dienstgrad der Person. Kann fehlen. */ secondary?: string classification: number openedAt: Date } /** * Was dieser Nutzer zuletzt offen hatte. * * Die vorgemerkten Kennungen sind für sich genommen wertlos: jede wird durch * dieselbe Sichtbarkeitsprüfung geholt wie überall sonst. Steigt eine Akte * nachträglich über die Freigabestufe des Nutzers, verschwindet sie aus der * Liste, ohne dass jemand aufräumen müsste - genau wie bei der * Beobachtungsliste. */ export async function listRecent(p: EffectivePermissions, take = 8): Promise { const zeilen = await db.recentView.findMany({ where: { userId: p.userId }, orderBy: { openedAt: 'desc' }, take: RECENT_MAX, select: { targetType: true, targetId: true, openedAt: true }, }) if (zeilen.length === 0) return [] const ids = (art: RecentTarget): string[] => zeilen.filter((zeile) => zeile.targetType === art).map((zeile) => zeile.targetId) const fallIds = ids('CASE') const personIds = ids('PERSONNEL') const dokumentIds = ids('DOCUMENT') // Personalakten sieht nur, wer sie überhaupt aufrufen darf. Die Freigabestufe // allein reicht dafür nicht, siehe docs/03-auth-und-rechte.md. const darfPersonal = p.isAdmin || p.permissions.has('personnel.read') const [faelle, personen, dokumente] = await Promise.all([ fallIds.length > 0 ? db.case.findMany({ where: { AND: [visibleCaseWhere(p), { id: { in: fallIds } }] }, select: { id: true, caseNumber: true, title: true, classification: true }, }) : Promise.resolve([]), personIds.length > 0 && darfPersonal ? db.personnelFile.findMany({ where: { AND: [visiblePersonnelWhere(p), { id: { in: personIds } }] }, select: { id: true, displayName: true, rank: true, classification: true }, }) : Promise.resolve([]), dokumentIds.length > 0 ? db.document.findMany({ where: { AND: [visibleDocumentWhere(p), { id: { in: dokumentIds } }] }, select: { id: true, documentNumber: true, title: true, classification: true }, }) : Promise.resolve([]), ]) const treffer = new Map>() for (const akte of faelle) { treffer.set(`CASE:${akte.id}`, { targetType: 'CASE', targetId: akte.id, href: `/faelle/${akte.caseNumber}`, label: akte.caseNumber, secondary: akte.title, classification: akte.classification, }) } for (const person of personen) { treffer.set(`PERSONNEL:${person.id}`, { targetType: 'PERSONNEL', targetId: person.id, href: `/personalakten/${person.id}`, label: person.displayName, secondary: person.rank ?? undefined, classification: person.classification, }) } for (const dokument of dokumente) { treffer.set(`DOCUMENT:${dokument.id}`, { targetType: 'DOCUMENT', targetId: dokument.id, href: `/dokumente/${dokument.documentNumber}`, label: dokument.documentNumber, secondary: dokument.title, classification: dokument.classification, }) } const liste: RecentEntry[] = [] for (const zeile of zeilen) { const gefunden = treffer.get(`${zeile.targetType}:${zeile.targetId}`) // Nicht gefunden heisst: nicht mehr sichtbar. Kein Platzhalter, keine // Lücke, kein Hinweis - die Existenz ist selbst eine Information. if (!gefunden) continue liste.push({ ...gefunden, openedAt: zeile.openedAt }) if (liste.length >= take) break } return liste }