Zuletzt geöffnet als Sprungliste, ohne Verlauf
Die letzte offene Zeile aus TASKS.md. Sie blieb offen, weil eine Spur über
jeden Aktenaufruf schnell etwas anderes wird als eine Bequemlichkeit.
Vier Festlegungen halten sie auf der richtigen Seite:
- Kein Verlauf. Der Primärschlüssel (userId, targetType, targetId) lässt je
Nutzer und Datensatz genau eine Zeile zu, die überschrieben wird. Die
Tabelle kann nicht sagen, wie oft oder in welcher Reihenfolge gearbeitet
wurde. Zwanzig Einträge, älteres fällt hinten heraus.
- Nur die eigene. Keine Ansicht, kein Adminbereich, keine Funktion mit
fremder Kennung. Jede Abfrage nimmt p.userId.
- Jederzeit löschbar, ohne Rückfrage und ohne Grabstein.
- Vorabrufe zählen nicht. Ohne die Prüfung auf next-router-prefetch stünde
dort, worüber jemand mit der Maus gefahren ist.
Gelesen wird über dieselben Sichtbarkeitsfragmente wie überall: eine
vorgemerkte Kennung ist keine Berechtigung. Steigt eine Akte über die
Freigabestufe, verschwindet sie ohne Lücke und ohne Platzhalter; Personalakten
brauchen zusätzlich personnel.read. Das Zugriffsprotokoll bleibt unberührt und
bleibt die einzige Auskunft darüber, wer wann was gelesen hat.
Sie steht auf der Übersicht und im leeren Feld der Kommandopalette. Der zweite
Ort ist der eigentliche.
Nebenbei: NEXT_DIST_DIR, damit ein Build neben einem laufenden
Entwicklungsserver nicht dasselbe .next-Verzeichnis abräumt.
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user