From da4a0678501eff5b108a574f40d5c79a2e5d7ca4 Mon Sep 17 00:00:00 2001 From: Harlekin Date: Thu, 3 Sep 2026 10:52:23 +0200 Subject: [PATCH] =?UTF-8?q?Zuletzt=20ge=C3=B6ffnet=20als=20Sprungliste,=20?= =?UTF-8?q?ohne=20Verlauf?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .gitignore | 2 + .../migration.sql | 26 +++ packages/db/prisma/schema.prisma | 30 +++ scripts/check-bundle-budget.mjs | 7 +- .../(app)/dokumente/[documentNumber]/page.tsx | 3 + web/app/(app)/faelle/[caseNumber]/layout.tsx | 5 + web/app/(app)/personalakten/[id]/page.tsx | 3 + web/app/(app)/uebersicht/actions.ts | 21 +++ web/app/(app)/uebersicht/page.tsx | 64 +++++++ web/app/api/search/quick/route.ts | 24 ++- web/components/palette/CommandPalette.tsx | 27 ++- web/lib/dashboard/load.ts | 10 +- web/lib/i18n/shell.ts | 5 + web/lib/recent/service.ts | 174 ++++++++++++++++++ web/lib/recent/track.ts | 50 +++++ web/next.config.ts | 7 + web/playwright.config.ts | 5 +- web/tests/zuletzt-geoeffnet.test.ts | 168 +++++++++++++++++ 18 files changed, 621 insertions(+), 10 deletions(-) create mode 100644 packages/db/prisma/migrations/20260903220000_zuletzt_geoeffnet/migration.sql create mode 100644 web/app/(app)/uebersicht/actions.ts create mode 100644 web/lib/recent/service.ts create mode 100644 web/lib/recent/track.ts create mode 100644 web/tests/zuletzt-geoeffnet.test.ts diff --git a/.gitignore b/.gitignore index 12e6bb4..91a221a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ node_modules/ .pnpm-store/ .next/ +# Build neben einem laufenden Entwicklungsserver, siehe NEXT_DIST_DIR +.next-*/ out/ dist/ build/ diff --git a/packages/db/prisma/migrations/20260903220000_zuletzt_geoeffnet/migration.sql b/packages/db/prisma/migrations/20260903220000_zuletzt_geoeffnet/migration.sql new file mode 100644 index 0000000..27732af --- /dev/null +++ b/packages/db/prisma/migrations/20260903220000_zuletzt_geoeffnet/migration.sql @@ -0,0 +1,26 @@ +-- Nachtrag zu Phase 7: „Zuletzt geöffnet" als eigene Spur. +-- +-- Eine persönliche Sprungliste, kein Verlauf: je Nutzer und Datensatz genau +-- eine Zeile, die beim nächsten Öffnen überschrieben wird. Der Primärschlüssel +-- erzwingt das — es gibt keine Form, in der diese Tabelle sagen könnte, wie oft +-- oder in welcher Reihenfolge jemand etwas geöffnet hat. +-- +-- Wer wann was gelesen hat, beantwortet weiterhin allein das Zugriffsprotokoll +-- ab Stufe 3. Siehe docs/13-entscheidungen.md. + +CREATE TYPE "RecentTarget" AS ENUM ('CASE', 'PERSONNEL', 'DOCUMENT'); + +CREATE TABLE "RecentView" ( + "userId" TEXT NOT NULL, + "targetType" "RecentTarget" NOT NULL, + "targetId" TEXT NOT NULL, + "openedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "RecentView_pkey" PRIMARY KEY ("userId", "targetType", "targetId") +); + +ALTER TABLE "RecentView" + ADD CONSTRAINT "RecentView_userId_fkey" + FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +CREATE INDEX "RecentView_userId_openedAt_idx" ON "RecentView"("userId", "openedAt" DESC); diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 9f6c7cc..f743662 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -55,6 +55,7 @@ model User { exclusions CaseExclusion[] watches Watch[] readMarkers ReadMarker[] + recentViews RecentView[] @@index([isActive, displayName]) } @@ -778,6 +779,35 @@ model Watch { @@index([caseId]) } +/// Was eine Sprungliste zeigen kann. +enum RecentTarget { + CASE + PERSONNEL + DOCUMENT +} + +/// Die persoenliche Sprungliste: was dieser Nutzer zuletzt geoeffnet hat. +/// +/// Bewusst **kein** Verlauf. Je Nutzer und Datensatz gibt es eine einzige +/// Zeile, die beim naechsten Oeffnen ueberschrieben wird, und aelteres faellt +/// hinten heraus. Damit steht hier, was jemand zuletzt gesehen hat, aber +/// weder wie oft noch in welcher Reihenfolge er gearbeitet hat. +/// +/// Gelesen wird sie nur vom Nutzer selbst. Wer wann was gelesen hat, +/// beantwortet das Zugriffsprotokoll ab Stufe 3, und das bleibt die einzige +/// Auskunft dieser Art. Siehe docs/13-entscheidungen.md. +model RecentView { + userId String + targetType RecentTarget + targetId String + openedAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@id([userId, targetType, targetId]) + @@index([userId, openedAt(sort: Desc)]) +} + /// Was die Web-App dem Worker zum Verschicken uebergibt. /// diff --git a/scripts/check-bundle-budget.mjs b/scripts/check-bundle-budget.mjs index 6dac165..f5a84aa 100644 --- a/scripts/check-bundle-budget.mjs +++ b/scripts/check-bundle-budget.mjs @@ -14,7 +14,10 @@ import { gzipSync } from 'node:zlib' const BUDGET_BYTES = 120 * 1024 const appDir = process.cwd() -const manifestPath = path.join(appDir, '.next', 'app-build-manifest.json') +// Dasselbe Verzeichnis wie next.config.ts, damit auch ein Build neben einem +// laufenden Entwicklungsserver gemessen werden kann. +const distDir = process.env.NEXT_DIST_DIR ?? '.next' +const manifestPath = path.join(appDir, distDir, 'app-build-manifest.json') if (!existsSync(manifestPath)) { console.error('Kein Build gefunden. Zuerst `pnpm --filter @afis/web run build` laufen lassen.') @@ -34,7 +37,7 @@ const measured = routes let bytes = 0 for (const file of new Set(files)) { if (!file.endsWith('.js')) continue - const full = path.join(appDir, '.next', file) + const full = path.join(appDir, distDir, file) if (!existsSync(full)) continue bytes += gzipSync(readFileSync(full)).byteLength } diff --git a/web/app/(app)/dokumente/[documentNumber]/page.tsx b/web/app/(app)/dokumente/[documentNumber]/page.tsx index 5769b59..964f0a1 100644 --- a/web/app/(app)/dokumente/[documentNumber]/page.tsx +++ b/web/app/(app)/dokumente/[documentNumber]/page.tsx @@ -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 diff --git a/web/app/(app)/faelle/[caseNumber]/layout.tsx b/web/app/(app)/faelle/[caseNumber]/layout.tsx index a60317f..8139ed9 100644 --- a/web/app/(app)/faelle/[caseNumber]/layout.tsx +++ b/web/app/(app)/faelle/[caseNumber]/layout.tsx @@ -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), diff --git a/web/app/(app)/personalakten/[id]/page.tsx b/web/app/(app)/personalakten/[id]/page.tsx index 8be217c..44b76cb 100644 --- a/web/app/(app)/personalakten/[id]/page.tsx +++ b/web/app/(app)/personalakten/[id]/page.tsx @@ -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(), diff --git a/web/app/(app)/uebersicht/actions.ts b/web/app/(app)/uebersicht/actions.ts new file mode 100644 index 0000000..ba14f59 --- /dev/null +++ b/web/app/(app)/uebersicht/actions.ts @@ -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 { + const user = await requireUser() + await clearRecent(user.id) + revalidatePath('/uebersicht') +} diff --git a/web/app/(app)/uebersicht/page.tsx b/web/app/(app)/uebersicht/page.tsx index 2efecb1..c7637c8 100644 --- a/web/app/(app)/uebersicht/page.tsx +++ b/web/app/(app)/uebersicht/page.tsx @@ -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}
+
+
+

{de.dashboard.opened}

+ {daten.geoeffnet.length > 0 ? ( +
+ +
+ ) : null} +
+

{de.dashboard.openedHint}

+ {daten.geoeffnet.length === 0 ? ( +

{de.dashboard.openedEmpty}

+ ) : ( +
    + {daten.geoeffnet.map((eintrag) => ( +
  • + +
  • + ))} +
+ )} +
+ {(akte) => } @@ -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 +}) { + return ( + + {eintrag.label} + {eintrag.secondary} + + + ) +} + function Akte({ akte, levelName, diff --git a/web/app/api/search/quick/route.ts b/web/app/api/search/quick/route.ts index 3614224..792cce4 100644 --- a/web/app/api/search/quick/route.ts +++ b/web/app/api/search/quick/route.ts @@ -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 { 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 } diff --git a/web/components/palette/CommandPalette.tsx b/web/components/palette/CommandPalette.tsx index cb7799f..8baf870 100644 --- a/web/components/palette/CommandPalette.tsx +++ b/web/components/palette/CommandPalette.tsx @@ -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([]) const [auswahl, setAuswahl] = useState(0) const [laeuft, setLaeuft] = useState(false) const feld = useRef(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) diff --git a/web/lib/dashboard/load.ts b/web/lib/dashboard/load.ts index 8432d58..db50114 100644 --- a/web/lib/dashboard/load.ts +++ b/web/lib/dashboard/load.ts @@ -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 = { diff --git a/web/lib/i18n/shell.ts b/web/lib/i18n/shell.ts index 19768fe..2306fb6 100644 --- a/web/lib/i18n/shell.ts +++ b/web/lib/i18n/shell.ts @@ -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, /** * Die Sprungziele der Palette. Sie stehen hier und nicht in der Komponente, diff --git a/web/lib/recent/service.ts b/web/lib/recent/service.ts new file mode 100644 index 0000000..d650c7a --- /dev/null +++ b/web/lib/recent/service.ts @@ -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 { + 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 { + 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 { + 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>() + 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 +} diff --git a/web/lib/recent/track.ts b/web/lib/recent/track.ts new file mode 100644 index 0000000..571c85d --- /dev/null +++ b/web/lib/recent/track.ts @@ -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 { + 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 { + 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' + ) +} diff --git a/web/next.config.ts b/web/next.config.ts index 08f83af..2d3e9ac 100644 --- a/web/next.config.ts +++ b/web/next.config.ts @@ -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, diff --git a/web/playwright.config.ts b/web/playwright.config.ts index 227c313..ba9c328 100644 --- a/web/playwright.config.ts +++ b/web/playwright.config.ts @@ -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, diff --git a/web/tests/zuletzt-geoeffnet.test.ts b/web/tests/zuletzt-geoeffnet.test.ts new file mode 100644 index 0000000..c76c973 --- /dev/null +++ b/web/tests/zuletzt-geoeffnet.test.ts @@ -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(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) + }) +})