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
375 Vorkommen in 210 Dateien. Betrifft Kommentare, Oberflaechentexte und die README; an keiner Stelle Bedeutung, nur Zeichensetzung.
175 lines
6.3 KiB
TypeScript
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
|
|
}
|