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.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Harlekin
2026-09-03 10:52:23 +02:00
co-authored by Claude Opus 5
parent 3466215e2c
commit bbae8546a0
21 changed files with 665 additions and 15 deletions
+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'
)
}