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:
Harlekin
2026-09-03 10:52:23 +02:00
parent addef20e16
commit da4a067850
18 changed files with 621 additions and 10 deletions
+168
View File
@@ -0,0 +1,168 @@
// @vitest-environment node
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { db } from '@/lib/db'
import { clearRecent, listRecent, noteOpened, RECENT_MAX } from '@/lib/recent/service'
import type { EffectivePermissions, Permission } from '@/lib/auth/permissions'
/**
* Die Sprungliste ist eine Bequemlichkeit — und genau deshalb der Ort, an dem
* eine Abkürzung besonders verlockend wäre: die Kennungen liegen ja schon da,
* warum sie nochmal prüfen? Weil eine vorgemerkte Kennung keine Berechtigung
* ist. Diese Datei prüft vor allem, dass die Liste nichts durchlässt.
*/
const url = process.env.DIRECT_DATABASE_URL ?? process.env.DATABASE_URL
const suite = url ? describe : describe.skip
const marke = `recent-${Date.now()}`
function perms(
userId: string,
clearanceLevel: number,
options: { isAdmin?: boolean; permissions?: Permission[] } = {},
): EffectivePermissions {
return {
userId,
clearanceLevel,
permissions: new Set<Permission>(options.permissions ?? []),
isAdmin: options.isAdmin ?? false,
hasMembership: true,
}
}
suite('Zuletzt geöffnet', () => {
const nutzer = { a: `${marke}-a`, b: `${marke}-b` }
let offenId = ''
let offenNummer = ''
let geheimId = ''
let personId = ''
beforeAll(async () => {
const vorlage = await db.documentTemplate.create({
data: { key: `${marke}-vorlage`, version: 1, name: 'Prüfvorlage', fields: [] },
})
for (const id of Object.values(nutzer)) {
await db.user.create({ data: { id, username: id, displayName: id } })
}
const gemeinsam = {
templateId: vorlage.id,
templateKey: vorlage.key,
createdById: nutzer.a,
}
const offen = await db.case.create({
data: {
...gemeinsam,
caseNumber: `Rez-2026-${String(Date.now()).slice(-4)}`,
title: 'Offener Vorgang',
classification: 1,
status: 'OPEN',
},
})
const geheim = await db.case.create({
data: {
...gemeinsam,
caseNumber: `Rgh-2026-${String(Date.now()).slice(-4)}`,
title: 'Streng vertraulich',
classification: 4,
status: 'OPEN',
},
})
const person = await db.personnelFile.create({
data: { displayName: `${marke}-Person`, classification: 1, createdById: nutzer.a },
})
offenId = offen.id
offenNummer = offen.caseNumber
geheimId = geheim.id
personId = person.id
})
afterAll(async () => {
await db.$disconnect()
})
it('merkt sich einen Sprung und zeigt ihn wieder', async () => {
await noteOpened(nutzer.a, 'CASE', offenId)
const liste = await listRecent(perms(nutzer.a, 1))
expect(liste.map((e) => e.targetId)).toContain(offenId)
expect(liste[0]?.href).toBe(`/faelle/${offenNummer}`)
expect(liste[0]?.label).toBe(offenNummer)
})
it('führt keinen Verlauf: zweimal öffnen bleibt eine Zeile', async () => {
await noteOpened(nutzer.a, 'CASE', offenId)
await noteOpened(nutzer.a, 'CASE', offenId)
const zeilen = await db.recentView.count({
where: { userId: nutzer.a, targetType: 'CASE', targetId: offenId },
})
expect(zeilen).toBe(1)
})
it('zeigt eine zu hoch eingestufte Akte nicht, auch wenn sie vorgemerkt ist', async () => {
// Der Fall, der zählt: die Kennung liegt in der Liste, die Freigabe fehlt.
// Etwa weil die Akte nach dem Öffnen hochgestuft wurde.
await noteOpened(nutzer.a, 'CASE', geheimId)
const hoch = await listRecent(perms(nutzer.a, 4))
expect(hoch.map((e) => e.targetId)).toContain(geheimId)
const niedrig = await listRecent(perms(nutzer.a, 1))
expect(niedrig.map((e) => e.targetId)).not.toContain(geheimId)
// Und keine Lücke, kein Platzhalter: die Zeile fehlt einfach.
expect(niedrig.every((e) => e.label.length > 0)).toBe(true)
})
it('zeigt Personalakten nur, wer sie überhaupt aufrufen darf', async () => {
await noteOpened(nutzer.a, 'PERSONNEL', personId)
const ohneRecht = await listRecent(perms(nutzer.a, 4))
expect(ohneRecht.map((e) => e.targetId)).not.toContain(personId)
const mitRecht = await listRecent(perms(nutzer.a, 4, { permissions: ['personnel.read'] }))
expect(mitRecht.map((e) => e.targetId)).toContain(personId)
})
it('zeigt niemandem die Liste eines anderen', async () => {
await noteOpened(nutzer.b, 'CASE', offenId)
// Nutzer a hat die Akte auch geöffnet — aber b bekommt seine eigene Zeile,
// und ein Administrator bekommt seine eigene, nicht die von b.
const admin = await listRecent(perms(`${marke}-admin`, 5, { isAdmin: true }))
expect(admin).toHaveLength(0)
})
it('behält nur die jüngsten Einträge', async () => {
const vorlage = await db.documentTemplate.findFirstOrThrow({
where: { key: `${marke}-vorlage` },
})
const viele = nutzer.b
for (let i = 0; i < RECENT_MAX + 3; i++) {
const akte = await db.case.create({
data: {
templateId: vorlage.id,
templateKey: vorlage.key,
createdById: viele,
caseNumber: `Rvi-${Date.now()}-${i}`,
title: `Vorgang ${i}`,
classification: 1,
status: 'OPEN',
},
})
await noteOpened(viele, 'CASE', akte.id)
}
const zeilen = await db.recentView.count({ where: { userId: viele } })
expect(zeilen).toBeLessThanOrEqual(RECENT_MAX)
// Die zuerst geöffnete Akte ist hinten herausgefallen.
const liste = await listRecent(perms(viele, 1), RECENT_MAX)
expect(liste.map((e) => e.targetId)).not.toContain(offenId)
})
it('lässt sich leeren', async () => {
await noteOpened(nutzer.a, 'CASE', offenId)
expect(await clearRecent(nutzer.a)).toBeGreaterThan(0)
expect(await listRecent(perms(nutzer.a, 5, { isAdmin: true }))).toHaveLength(0)
})
})