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
@@ -22,6 +22,7 @@ import { DocumentActions } from './DocumentActions'
import { Attachments } from '@/components/files/Attachments'
import { listAttachments, resolvePhotoUrls } from '@/lib/files/service'
import { toAttachmentViews } from '@/lib/files/view'
import { trackOpen } from '@/lib/recent/track'
import type { EffectivePermissions } from '@/lib/auth/permissions'
export const dynamic = 'force-dynamic'
@@ -162,6 +163,8 @@ export default async function DocumentPage({
})
}
await trackOpen(user.id, 'DOCUMENT', doc.id)
const [levels, besitzer, verfasser] = await Promise.all([
db.clearanceLevelConfig.findMany({ orderBy: { level: 'asc' } }),
doc.caseId
@@ -10,6 +10,7 @@ import { allowedTargets, type CaseStatusName } from '@/lib/cases/lifecycle'
import { CaseActions } from './CaseActions'
import { WatchToggle } from './WatchToggle'
import { isWatching } from '@/lib/cases/watch'
import { trackOpen } from '@/lib/recent/track'
export const dynamic = 'force-dynamic'
@@ -41,6 +42,10 @@ export default async function CaseLayout({
// Kein Zugriff heisst 404, nicht 403: die Existenz ist selbst eine Information.
if (!akte) notFound()
// Für die eigene Sprungliste vormerken. Steht im Layout und nicht auf einem
// Reiter: wer die Beteiligten aufruft, hat die Akte geöffnet.
await trackOpen(user.id, 'CASE', akte.id)
const [levels, beobachtet] = await Promise.all([
db.clearanceLevelConfig.findMany({ orderBy: { level: 'asc' } }),
isWatching(user.permissions, akte.id),
@@ -13,6 +13,7 @@ import {
} from '@/lib/personnel/service'
import { listDocuments, listTemplatesFor } from '@/lib/documents/service'
import { canWritePersonnel } from '@/lib/access/documentWrite'
import { trackOpen } from '@/lib/recent/track'
import { de } from '@/lib/i18n/de'
import { StampMark, classificationEdgeStyle } from '@/components/StampMark'
import { SkeletonLine } from '@/components/Skeleton'
@@ -179,6 +180,8 @@ export default async function PersonnelPage({ params }: { params: Promise<{ id:
})
}
await trackOpen(user.id, 'PERSONNEL', akte.id)
const [levels, statusTags] = await Promise.all([
db.clearanceLevelConfig.findMany({ orderBy: { level: 'asc' } }),
listStatusTags(),
+21
View File
@@ -0,0 +1,21 @@
'use server'
import { revalidatePath } from 'next/cache'
import { requireUser } from '@/lib/auth/current'
import { clearRecent } from '@/lib/recent/service'
/**
* Die eigene Sprungliste leeren.
*
* Ohne Rückfrage, ohne Protokolleintrag, ohne Grabstein: die Liste ist eine
* Bequemlichkeit für den Nutzer und keine Aufzeichnung über ihn. Für die
* Nachvollziehbarkeit von Lesezugriffen ist das Zugriffsprotokoll zuständig,
* und das bleibt davon unberührt.
*
* Als Formular-Aktion gebaut, damit die Übersicht dafür kein Skript braucht.
*/
export async function clearRecentAction(): Promise<void> {
const user = await requireUser()
await clearRecent(user.id)
revalidatePath('/uebersicht')
}
+64
View File
@@ -7,6 +7,8 @@ import { pendingApprovals } from '@/lib/export/service'
import { de } from '@/lib/i18n/de'
import { StampMark, classificationEdgeStyle } from '@/components/StampMark'
import { Approvals } from '../export/Approvals'
import { clearRecentAction } from './actions'
import type { RecentEntry } from '@/lib/recent/service'
export const metadata: Metadata = { title: de.dashboard.heading }
export const dynamic = 'force-dynamic'
@@ -50,6 +52,34 @@ export default async function DashboardPage() {
) : null}
<div className="mt-10 grid gap-10 md:grid-cols-2">
<section className="md:col-span-2">
<div className="flex flex-wrap items-baseline justify-between gap-x-6 gap-y-1">
<h2 className="font-condensed">{de.dashboard.opened}</h2>
{daten.geoeffnet.length > 0 ? (
<form action={clearRecentAction}>
<button type="submit" className="font-condensed text-2xs text-text-dim">
{de.dashboard.openedClear}
</button>
</form>
) : null}
</div>
<p className="mt-1 text-2xs text-text-dim">{de.dashboard.openedHint}</p>
{daten.geoeffnet.length === 0 ? (
<p className="mt-4 text-text-dim">{de.dashboard.openedEmpty}</p>
) : (
<ul className="mt-4 border-t border-line">
{daten.geoeffnet.map((eintrag) => (
<li
key={`${eintrag.targetType}:${eintrag.targetId}`}
className="border-b border-line"
>
<Sprung eintrag={eintrag} levelName={levelName} />
</li>
))}
</ul>
)}
</section>
<Block titel={de.dashboard.mine} leer={de.dashboard.mineEmpty} eintraege={daten.meine}>
{(akte) => <Akte akte={akte} levelName={levelName} />}
</Block>
@@ -165,6 +195,40 @@ function Block({
)
}
/**
* Eine Zeile der Sprungliste. Sie kann eine Akte, eine Person oder ein
* Dokument sein — deshalb steht die Art nicht dabei, sondern nur das, woran
* man sie erkennt: Aktenzeichen, Name oder Dokumentnummer.
*
* Ohne Vorabruf, anders als die übrigen Listen: die Seiten dahinter merken
* sich, dass sie geöffnet wurden. Der Vorabruf wird zwar serverseitig erkannt
* und zählt nicht mit, aber ausgerechnet diese Liste sollte sich nicht selbst
* am Leben halten können, wenn diese Erkennung einmal nicht greift.
*/
function Sprung({
eintrag,
levelName,
}: {
eintrag: RecentEntry
levelName: Map<number, string>
}) {
return (
<Link
href={eintrag.href}
prefetch={false}
className="flex flex-wrap items-baseline gap-x-4 gap-y-1 py-2 pl-3 text-text no-underline"
style={classificationEdgeStyle(eintrag.classification)}
>
<span className="font-mono text-xs text-text-dim">{eintrag.label}</span>
<span className="grow">{eintrag.secondary}</span>
<StampMark
level={eintrag.classification}
name={levelName.get(eintrag.classification) ?? de.common.unknown}
/>
</Link>
)
}
function Akte({
akte,
levelName,
+23 -1
View File
@@ -3,6 +3,7 @@ import { db } from '@/lib/db'
import { getCurrentUser } from '@/lib/auth/current'
import { visibleCaseWhere } from '@/lib/access/caseFilter'
import { visiblePersonnelWhere } from '@/lib/access/personnelFilter'
import { listRecent } from '@/lib/recent/service'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
@@ -10,6 +11,9 @@ export const dynamic = 'force-dynamic'
/** So viele Treffer je Art zeigt die Palette. Mehr passt nicht auf den Schirm. */
const LIMIT = 5
/** So viele Sprünge zeigt die Palette, solange nichts im Feld steht. */
const RECENT_IN_PALETTE = 6
/**
* Schnellsuche für die Kommandopalette.
*
@@ -24,7 +28,25 @@ export async function GET(request: Request): Promise<Response> {
const user = await getCurrentUser()
if (!user) return NextResponse.json({ cases: [], personnel: [] }, { status: 404 })
const begriff = (new URL(request.url).searchParams.get('q') ?? '').trim().slice(0, 100)
const params = new URL(request.url).searchParams
// Leeres Feld: die eigene Sprungliste. Wer die Palette öffnet, will meistens
// dorthin zurück, wo er eben war — und nicht erst tippen.
if (params.get('recent') === '1') {
const zuletzt = await listRecent(user.permissions, RECENT_IN_PALETTE)
return NextResponse.json({
cases: [],
personnel: [],
recent: zuletzt.map((eintrag) => ({
href: eintrag.href,
primary: eintrag.label,
secondary: eintrag.secondary,
classification: eintrag.classification,
})),
})
}
const begriff = (params.get('q') ?? '').trim().slice(0, 100)
if (begriff.length < 2) return NextResponse.json({ cases: [], personnel: [] })
const enthaelt = { contains: begriff, mode: 'insensitive' as const }
+25 -2
View File
@@ -26,6 +26,7 @@ export function CommandPalette({ onClose }: { onClose: () => void }) {
const router = useRouter()
const [text, setText] = useState('')
const [treffer, setTreffer] = useState<{ label: string; eintraege: Treffer[] }[]>([])
const [zuletzt, setZuletzt] = useState<Treffer[]>([])
const [auswahl, setAuswahl] = useState(0)
const [laeuft, setLaeuft] = useState(false)
const feld = useRef<HTMLInputElement>(null)
@@ -34,6 +35,23 @@ export function CommandPalette({ onClose }: { onClose: () => void }) {
feld.current?.focus()
}, [])
// Die eigene Sprungliste, einmal beim Öffnen. Sie steht im leeren Feld über
// den Seiten: wer die Palette aufmacht, will meistens dorthin zurück, wo er
// eben war.
useEffect(() => {
const controller = new AbortController()
void (async () => {
try {
const antwort = await fetch('/api/search/quick?recent=1', { signal: controller.signal })
const daten = (await antwort.json()) as { recent?: Treffer[] }
setZuletzt(daten.recent ?? [])
} catch {
// Ohne Sprungliste tut die Palette dasselbe wie vorher.
}
})()
return () => controller.abort()
}, [])
useEffect(() => {
const begriff = text.trim()
const seiten = SEITEN.filter((seite) =>
@@ -41,7 +59,12 @@ export function CommandPalette({ onClose }: { onClose: () => void }) {
)
if (begriff.length < 2) {
setTreffer([{ label: paletteText.sections.pages!, eintraege: SEITEN }])
setTreffer([
...(zuletzt.length > 0
? [{ label: paletteText.sections.recent!, eintraege: zuletzt }]
: []),
{ label: paletteText.sections.pages!, eintraege: SEITEN },
])
setAuswahl(0)
return
}
@@ -98,7 +121,7 @@ export function CommandPalette({ onClose }: { onClose: () => void }) {
clearTimeout(timer)
controller.abort()
}
}, [text])
}, [text, zuletzt])
const flach = treffer.flatMap((gruppe) => gruppe.eintraege)
+6 -4
View File
@@ -2,14 +2,15 @@ import { db } from '@/lib/db'
import { visibleCaseWhere } from '@/lib/access/caseFilter'
import { visibleMessageWhere } from '@/lib/access/chatFilter'
import { listWatched } from '@/lib/cases/watch'
import { listRecent } from '@/lib/recent/service'
import type { EffectivePermissions } from '@/lib/auth/permissions'
import type { Prisma } from '@afis/db'
/**
* Was auf der Übersicht steht.
*
* Vier Fragen, die man morgens hat: woran arbeite ich, was ist fällig, wo hat
* jemand geschrieben, was hat sich getan. Jede läuft durch
* Fünf Fragen, die man morgens hat: woran war ich dran, woran arbeite ich, was
* ist fällig, wo hat jemand geschrieben, was hat sich getan. Jede läuft durch
* `visibleCaseWhere` — die Übersicht ist der Ort, an dem eine unachtsame
* Abfrage am ehesten eine Akte verrät, die man nicht sehen darf.
*/
@@ -32,7 +33,7 @@ export async function loadDashboard(p: EffectivePermissions) {
const jetzt = new Date()
const frist = new Date(jetzt.getTime() + FRIST_TAGE * 86_400_000)
const [meine, fristen, zuletzt, beobachtet, ungelesen] = await Promise.all([
const [meine, fristen, zuletzt, beobachtet, ungelesen, geoeffnet] = await Promise.all([
db.case.findMany({
where: {
AND: [
@@ -65,9 +66,10 @@ export async function loadDashboard(p: EffectivePermissions) {
}),
listWatched(p, 10),
ungeleseneChats(p),
listRecent(p, 8),
])
return { meine, fristen, zuletzt, beobachtet, ungelesen }
return { meine, fristen, zuletzt, beobachtet, ungelesen, geoeffnet }
}
export type UnreadChat = {
+5
View File
@@ -71,6 +71,10 @@ export const dashboardText = {
watchedEmpty: 'Keine Akte beobachtet.',
recent: 'Zuletzt geändert',
recentEmpty: 'Noch keine Akte.',
opened: 'Zuletzt geöffnet',
openedEmpty: 'Noch nichts geöffnet. Was du aufrufst, findest du hier wieder.',
openedHint: 'Nur für dich. Niemand sonst sieht diese Liste.',
openedClear: 'Liste leeren',
access: 'Dein Zugang',
} as const
@@ -163,6 +167,7 @@ export const paletteText = {
cases: 'Fallakten',
personnel: 'Personalakten',
pages: 'Seiten',
recent: 'Zuletzt geöffnet',
} as Record<string, string>,
/**
* Die Sprungziele der Palette. Sie stehen hier und nicht in der Komponente,
+174
View File
@@ -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
}
+50
View File
@@ -0,0 +1,50 @@
import { after } from 'next/server'
import { headers } from 'next/headers'
import { noteOpened } from '@/lib/recent/service'
import type { RecentTarget } from '@afis/db'
/**
* Einen Sprung vormerken, aus einer Seite heraus.
*
* Zwei Dinge macht diese Schicht, die die Datenschicht nicht machen kann:
*
* **Vorabrufe zählen nicht.** Next holt die Seiten hinter sichtbaren Links im
* Voraus. Ohne diese Prüfung stünde in der Liste, worüber jemand mit der Maus
* gefahren ist — die Übersicht allein würde sie mit zehn Akten füllen, die
* niemand geöffnet hat. Das wäre nicht nur falsch, es wäre die unangenehmere
* Sorte falsch: eine Aufzeichnung von Aufmerksamkeit statt von Handlung.
*
* **Es kostet die Seite nichts.** Der Schreibvorgang läuft in `after()`, also
* nachdem die Antwort raus ist. Eine Merkliste darf keine Akte langsamer
* machen; geht sie verloren, ist nichts verloren.
*/
export async function trackOpen(
userId: string,
targetType: RecentTarget,
targetId: string,
): Promise<void> {
if (await istVorabruf()) return
after(async () => {
try {
await noteOpened(userId, targetType, targetId)
} catch {
// Eine Merkliste ist kein Grund, eine Aktenansicht scheitern zu lassen.
}
})
}
/**
* Die Kopfzeilen, mit denen Next und die Browser einen Vorabruf ankündigen.
* Lieber eine zu viel geprüft als einen Sprung erfunden.
*/
async function istVorabruf(): Promise<boolean> {
const kopf = await headers()
return (
kopf.get('next-router-prefetch') === '1' ||
kopf.has('next-router-segment-prefetch') ||
kopf.get('purpose') === 'prefetch' ||
kopf.get('x-purpose') === 'prefetch' ||
kopf.get('x-moz') === 'prefetch'
)
}
+7
View File
@@ -13,6 +13,13 @@ const nextConfig: NextConfig = {
// einem falsch benannten Unterordner.
outputFileTracingRoot: path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'),
// Ein Build neben einem laufenden `next dev` teilt sich sonst dasselbe
// .next-Verzeichnis, und beide räumen einander die Dateien weg. NEXT_DIST_DIR
// gibt dem Build ein eigenes, ohne den Entwicklungsserver zu stören.
// Achtung: Next schreibt danach den Verweis in next-env.d.ts auf dieses
// Verzeichnis um. Die Datei gehört anschliessend zurückgesetzt.
distDir: process.env.NEXT_DIST_DIR ?? '.next',
reactStrictMode: true,
poweredByHeader: false,
+4 -1
View File
@@ -1,6 +1,9 @@
import { defineConfig, devices } from '@playwright/test'
const PORT = Number(process.env.E2E_PORT ?? 3100)
// Dasselbe Verzeichnis wie next.config.ts: neben einem laufenden `next dev`
// braucht der Testlauf ein eigenes, sonst räumen sich beide gegenseitig ab.
const distDir = process.env.NEXT_DIST_DIR ?? '.next'
const baseURL = process.env.E2E_BASE_URL ?? `http://127.0.0.1:${PORT}`
export default defineConfig({
@@ -23,7 +26,7 @@ export default defineConfig({
? undefined
: {
// Derselbe Standalone-Server, den auch das Container-Image startet.
command: `pnpm build && node .next/standalone/web/server.js`,
command: `pnpm build && node ${distDir}/standalone/web/server.js`,
url: `${baseURL}/api/health`,
reuseExistingServer: !process.env.CI,
timeout: 180_000,
+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)
})
})