Files
AfiS-Aktensystem/web/lib/recent/service.ts
T
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

175 lines
6.3 KiB
TypeScript

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<void> {
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<number> {
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<RecentEntry[]> {
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<string, Omit<RecentEntry, 'openedAt'>>()
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
}