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) + }) +})