Protokoll im Adminbereich, ein Knopf für das ganze Blatt
Die restlichen zwei Änderungswünsche. **Protokoll.** „Wer was wo macht" stand seit dem ersten Tag in AuditLog — harte Regel 3 sorgt dafür. Was fehlte, war die Ansicht darauf. /admin/protokoll ist genau das und keine zweite Aufzeichnung: filterbar nach Person, Aktion, Art und Zeitraum, rückwärts blätternd. Nur für die Leitung. Eine Zeile nennt Aktenzeichen und Namen, und schon die Existenz einer Zeile verrät, dass es einen Vorgang gibt; ein gefiltertes Protokoll für alle wäre ein Weg, die Sichtbarkeitsregeln von hinten aufzurollen. Aus demselben Grund werden Kennungen ohne Sichtbarkeitsfilter zu Aktenzeichen aufgelöst — ein Protokoll aus UUIDs liest niemand, und eines, das niemand liest, ist keine Kontrolle. **Speichern.** Jedes Feld trug seinen eigenen Knopf: ein Arbeitsvertrag mit zwölf Feldern hiess zwölfmal speichern. Der Knopf steht jetzt am Blatt. Geschrieben wird trotzdem je Feld und nicht als Transaktion, und das folgt aus dem Datenmodell: Abschnitte sind einzeln versioniert, weil mehrere Leute gleichzeitig an einer Akte arbeiten. Als Transaktion blockierte ein einziges zwischenzeitlich geändertes Feld das ganze Blatt — elf gute Änderungen verloren, weil jemand das zwölfte angefasst hat. So geht durch, was durchgeht, und die Rückmeldung sagt, was offen blieb und wo. Lichtbilder bleiben ausgenommen: ein Upload mit Prüfkette ist keine Feldänderung. lib/audit.ts liegt jetzt als lib/audit/write.ts neben lib/audit/query.ts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
ef377f0123
commit
5f9f5d99fb
@@ -0,0 +1,242 @@
|
||||
import type { Metadata } from 'next'
|
||||
import Link from 'next/link'
|
||||
import { requirePermission } from '@/lib/auth/current'
|
||||
import { auditActions, auditActors, queryAuditLog, type AuditRow } from '@/lib/audit/query'
|
||||
import { de } from '@/lib/i18n/de'
|
||||
|
||||
export const metadata: Metadata = { title: de.audit.heading }
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
type Params = Record<string, string | string[] | undefined>
|
||||
|
||||
/**
|
||||
* Das Zugriffsprotokoll.
|
||||
*
|
||||
* Es beantwortet die eine Frage, die eine Abteilungsleitung an ein Aktensystem
|
||||
* hat: **wer hat was wo getan.** Nach harter Regel 3 aus CLAUDE.md entsteht
|
||||
* die Spur bei jeder schreibenden Aktion und bei jedem Lesezugriff ab Stufe 3,
|
||||
* ohne Ausnahme und ohne Möglichkeit, sie zu unterdrücken.
|
||||
*
|
||||
* Nur für die Leitung. Das folgt aus dem, was drinsteht: eine Zeile nennt
|
||||
* Aktenzeichen und Namen, und schon die Existenz einer Zeile verrät, dass es
|
||||
* einen Vorgang gibt. Ein „gefiltertes Protokoll für alle" wäre ein Weg, die
|
||||
* Sichtbarkeitsregeln von hinten aufzurollen.
|
||||
*
|
||||
* Der Zustand steht in der Adresse, wie bei der Suche: teilbar, und der
|
||||
* Zurück-Knopf tut das Richtige.
|
||||
*/
|
||||
export default async function AuditLogPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<Params>
|
||||
}) {
|
||||
await requirePermission('admin')
|
||||
const params = await searchParams
|
||||
|
||||
const wert = (name: string): string | undefined => {
|
||||
const roh = params[name]
|
||||
const text = (Array.isArray(roh) ? roh[0] : roh)?.trim()
|
||||
return text ? text : undefined
|
||||
}
|
||||
|
||||
const filter = {
|
||||
actorId: wert('wer'),
|
||||
action: wert('was'),
|
||||
targetType: wert('art'),
|
||||
targetId: wert('ziel'),
|
||||
from: datum(wert('ab')),
|
||||
to: datum(wert('bis'), true),
|
||||
before: wert('vor') ? BigInt(wert('vor')!) : undefined,
|
||||
}
|
||||
|
||||
const [{ rows, more }, akteure, aktionen] = await Promise.all([
|
||||
queryAuditLog(filter),
|
||||
auditActors(),
|
||||
auditActions(),
|
||||
])
|
||||
|
||||
const arten = [...new Set(rows.map((zeile) => zeile.targetType))].sort()
|
||||
const gefiltert = Boolean(
|
||||
filter.actorId || filter.action || filter.targetType || filter.targetId || filter.from || filter.to,
|
||||
)
|
||||
|
||||
return (
|
||||
<section>
|
||||
<h2 className="font-condensed">{de.audit.heading}</h2>
|
||||
<p className="mt-1 max-w-(--container-measure) text-2xs text-text-dim">{de.audit.intro}</p>
|
||||
|
||||
<form method="get" className="mt-6 flex flex-wrap items-end gap-3">
|
||||
<label className="text-2xs text-text-dim">
|
||||
{de.audit.filterActor}
|
||||
<select name="wer" defaultValue={filter.actorId ?? ''} className="field mt-1 text-2xs">
|
||||
<option value="">{de.audit.all}</option>
|
||||
{akteure.map((akteur) => (
|
||||
<option key={akteur.id} value={akteur.id}>
|
||||
{akteur.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="text-2xs text-text-dim">
|
||||
{de.audit.filterAction}
|
||||
<select name="was" defaultValue={filter.action ?? ''} className="field mt-1 text-2xs">
|
||||
<option value="">{de.audit.all}</option>
|
||||
{aktionen.map((aktion) => (
|
||||
<option key={aktion} value={aktion}>
|
||||
{de.audit.actions[aktion] ?? aktion}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="text-2xs text-text-dim">
|
||||
{de.audit.filterFrom}
|
||||
<input
|
||||
type="date"
|
||||
name="ab"
|
||||
defaultValue={wert('ab') ?? ''}
|
||||
className="field mt-1 font-mono text-2xs"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="text-2xs text-text-dim">
|
||||
{de.audit.filterTo}
|
||||
<input
|
||||
type="date"
|
||||
name="bis"
|
||||
defaultValue={wert('bis') ?? ''}
|
||||
className="field mt-1 font-mono text-2xs"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{filter.targetType ? (
|
||||
<input type="hidden" name="art" value={filter.targetType} />
|
||||
) : null}
|
||||
{filter.targetId ? <input type="hidden" name="ziel" value={filter.targetId} /> : null}
|
||||
|
||||
<button type="submit" className="btn btn-sm btn-primary">
|
||||
{de.audit.apply}
|
||||
</button>
|
||||
{gefiltert ? (
|
||||
<Link href="/admin/protokoll" className="btn btn-sm">
|
||||
{de.audit.reset}
|
||||
</Link>
|
||||
) : null}
|
||||
</form>
|
||||
|
||||
{arten.length > 1 || filter.targetType ? (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<Link
|
||||
href={adresse(params, { art: undefined, vor: undefined })}
|
||||
aria-current={filter.targetType ? undefined : 'page'}
|
||||
className="btn btn-sm"
|
||||
>
|
||||
{de.audit.all}
|
||||
</Link>
|
||||
{arten.map((art) => (
|
||||
<Link
|
||||
key={art}
|
||||
href={adresse(params, { art, vor: undefined })}
|
||||
aria-current={filter.targetType === art ? 'page' : undefined}
|
||||
className="btn btn-sm"
|
||||
>
|
||||
{de.audit.targets[art] ?? art}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{rows.length === 0 ? (
|
||||
<p className="mt-8 text-text-dim">{de.audit.empty}</p>
|
||||
) : (
|
||||
<div className="mt-6 overflow-x-auto">
|
||||
<table className="w-full border-collapse text-2xs">
|
||||
<thead>
|
||||
<tr className="border-b border-line text-left">
|
||||
<th className="py-2 pr-4">{de.audit.when}</th>
|
||||
<th className="py-2 pr-4">{de.audit.who}</th>
|
||||
<th className="py-2 pr-4">{de.audit.what}</th>
|
||||
<th className="py-2 pr-4">{de.audit.where}</th>
|
||||
<th className="py-2">{de.audit.clearance}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((zeile) => (
|
||||
<Zeile key={zeile.id} zeile={zeile} params={params} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{more && rows.length > 0 ? (
|
||||
<div className="mt-6">
|
||||
<Link
|
||||
href={adresse(params, { vor: rows[rows.length - 1]!.id })}
|
||||
className="btn btn-sm"
|
||||
>
|
||||
{de.audit.older}
|
||||
</Link>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function Zeile({ zeile, params }: { zeile: AuditRow; params: Params }) {
|
||||
return (
|
||||
<tr className="border-b border-line align-baseline">
|
||||
<td className="py-2 pr-4 font-mono whitespace-nowrap text-text-dim">
|
||||
{zeile.at.toLocaleString('de-DE')}
|
||||
</td>
|
||||
<td className="py-2 pr-4">
|
||||
<Link
|
||||
href={adresse(params, { wer: zeile.actorId, vor: undefined })}
|
||||
title={de.audit.ofUser}
|
||||
className="no-underline"
|
||||
>
|
||||
{zeile.actorName}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="py-2 pr-4">{de.audit.actions[zeile.action] ?? zeile.action}</td>
|
||||
<td className="py-2 pr-4">
|
||||
<span className="text-text-dim">
|
||||
{de.audit.targets[zeile.targetType] ?? zeile.targetType}
|
||||
</span>{' '}
|
||||
{zeile.targetHref ? (
|
||||
<Link href={zeile.targetHref} className="font-mono">
|
||||
{zeile.targetLabel}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="font-mono text-text-dim">{zeile.targetLabel ?? de.audit.gone}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 font-mono text-text-dim">{zeile.clearanceAtTime}</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
/** Datum aus dem Formular. „Bis" meint den ganzen Tag, nicht Mitternacht. */
|
||||
function datum(text: string | undefined, endeDesTages = false): Date | undefined {
|
||||
if (!text) return undefined
|
||||
const wert = new Date(text)
|
||||
if (Number.isNaN(wert.getTime())) return undefined
|
||||
if (endeDesTages) wert.setHours(23, 59, 59, 999)
|
||||
return wert
|
||||
}
|
||||
|
||||
/** Die eigene Adresse mit geänderten Parametern. */
|
||||
function adresse(params: Params, aenderungen: Record<string, string | undefined>): string {
|
||||
const such = new URLSearchParams()
|
||||
for (const [name, roh] of Object.entries(params)) {
|
||||
const text = Array.isArray(roh) ? roh[0] : roh
|
||||
if (text) such.set(name, text)
|
||||
}
|
||||
for (const [name, wert] of Object.entries(aenderungen)) {
|
||||
if (wert === undefined) such.delete(name)
|
||||
else such.set(name, wert)
|
||||
}
|
||||
const rest = such.toString()
|
||||
return rest ? `/admin/protokoll?${rest}` : '/admin/protokoll'
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { revalidatePath } from 'next/cache'
|
||||
import { z } from 'zod'
|
||||
import { db } from '@/lib/db'
|
||||
import { requirePermissionForAction } from '@/lib/auth/current'
|
||||
import { writeAudit } from '@/lib/audit'
|
||||
import { writeAudit } from '@/lib/audit/write'
|
||||
import { de } from '@/lib/i18n/de'
|
||||
|
||||
const schema = z.object({
|
||||
|
||||
@@ -4,7 +4,7 @@ import { revalidatePath } from 'next/cache'
|
||||
import { z } from 'zod'
|
||||
import { db } from '@/lib/db'
|
||||
import { requirePermissionForAction } from '@/lib/auth/current'
|
||||
import { writeAudit } from '@/lib/audit'
|
||||
import { writeAudit } from '@/lib/audit/write'
|
||||
import { templateFieldsSchema } from '@/lib/templates/schema'
|
||||
import { de } from '@/lib/i18n/de'
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import Link from 'next/link'
|
||||
import { notFound } from 'next/navigation'
|
||||
import { db } from '@/lib/db'
|
||||
import { requireUser } from '@/lib/auth/current'
|
||||
import { recordRead, READ_AUDIT_FROM_LEVEL } from '@/lib/audit'
|
||||
import { recordRead, READ_AUDIT_FROM_LEVEL } from '@/lib/audit/write'
|
||||
import { loadDocumentSections, loadVisibleDocument } from '@/lib/documents/service'
|
||||
import {
|
||||
canEditDocument,
|
||||
@@ -17,7 +17,7 @@ import { SkeletonLine } from '@/components/Skeleton'
|
||||
import { Sections } from '@/components/sections/Sections'
|
||||
import { Sheet } from '@/components/documents/Sheet'
|
||||
import { SheetSwitch } from '@/components/documents/SheetSwitch'
|
||||
import { saveDocumentSectionAction } from '../actions'
|
||||
import { saveDocumentSectionAction, saveDocumentSectionsAction } from '../actions'
|
||||
import { DocumentActions } from './DocumentActions'
|
||||
import { Attachments } from '@/components/files/Attachments'
|
||||
import { listAttachments, resolvePhotoUrls } from '@/lib/files/service'
|
||||
@@ -68,6 +68,8 @@ async function Body({
|
||||
<Sections
|
||||
scope={`dokument:${document.documentNumber}`}
|
||||
save={saveDocumentSectionAction.bind(null, document.documentNumber)}
|
||||
saveAll={saveDocumentSectionsAction.bind(null, document.documentNumber)}
|
||||
saveAllLabel={de.document.saveAll}
|
||||
sections={sections}
|
||||
canEdit={canEdit}
|
||||
documentNumber={document.documentNumber}
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
type Owner,
|
||||
} from '@/lib/documents/service'
|
||||
import { de } from '@/lib/i18n/de'
|
||||
import type { SaveSectionResult } from '@/components/sections/types'
|
||||
import type { SaveSectionResult, SaveSectionsInput } from '@/components/sections/types'
|
||||
|
||||
export type ActionResult = { ok: true } | { ok: false; error: string }
|
||||
|
||||
@@ -154,3 +154,18 @@ export async function reclassifyDocumentAction(input: unknown): Promise<ActionRe
|
||||
return { ok: false, error: toMessage(error) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Dasselbe für ein Dokument: ein Knopf, ein Ergebnis je Feld. */
|
||||
export async function saveDocumentSectionsAction(
|
||||
documentNumber: string,
|
||||
input: unknown,
|
||||
): Promise<Record<string, SaveSectionResult>> {
|
||||
const parsed = z.array(sectionSchema).max(200).safeParse(input)
|
||||
if (!parsed.success) return {}
|
||||
|
||||
const ergebnisse: Record<string, SaveSectionResult> = {}
|
||||
for (const feld of parsed.data as SaveSectionsInput) {
|
||||
ergebnisse[feld.fieldKey] = await saveDocumentSectionAction(documentNumber, feld)
|
||||
}
|
||||
return ergebnisse
|
||||
}
|
||||
|
||||
@@ -6,12 +6,12 @@ import { db } from '@/lib/db'
|
||||
import { requireUser } from '@/lib/auth/current'
|
||||
import { visibleEntryWhere } from '@/lib/access/caseFilter'
|
||||
import { loadVisibleCase } from '@/lib/cases/service'
|
||||
import { recordRead, READ_AUDIT_FROM_LEVEL } from '@/lib/audit'
|
||||
import { recordRead, READ_AUDIT_FROM_LEVEL } from '@/lib/audit/write'
|
||||
import { de } from '@/lib/i18n/de'
|
||||
import { SkeletonLine } from '@/components/Skeleton'
|
||||
import { EntryComposer } from './EntryComposer'
|
||||
import { Sections } from '@/components/sections/Sections'
|
||||
import { saveSectionAction } from './sectionActions'
|
||||
import { saveSectionAction, saveSectionsAction } from './sectionActions'
|
||||
import { loadSections } from '@/lib/cases/sectionService'
|
||||
import { canEditCase } from '@/lib/access/caseWrite'
|
||||
import { EntryActions } from './EntryActions'
|
||||
@@ -145,6 +145,7 @@ async function SectionBlock({
|
||||
<Sections
|
||||
scope={`fall:${caseNumber}`}
|
||||
save={saveSectionAction.bind(null, caseNumber)}
|
||||
saveAll={saveSectionsAction.bind(null, caseNumber)}
|
||||
sections={sections}
|
||||
canEdit={canEdit}
|
||||
/>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { requireUser } from '@/lib/auth/current'
|
||||
import { AccessError, ConflictError } from '@/lib/errors'
|
||||
import { saveSection } from '@/lib/cases/sectionService'
|
||||
import { de } from '@/lib/i18n/de'
|
||||
import type { SaveSectionResult } from '@/components/sections/types'
|
||||
import type { SaveSectionResult, SaveSectionsInput } from '@/components/sections/types'
|
||||
|
||||
const schema = z.object({
|
||||
fieldKey: z.string().min(1),
|
||||
@@ -47,3 +47,27 @@ export async function saveSectionAction(
|
||||
return { ok: false, conflict: false, error: de.actionErrors.default! }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mehrere Abschnitte in einem Zug.
|
||||
*
|
||||
* Jedes Feld wird einzeln geschrieben und einzeln beantwortet — Abschnitte
|
||||
* sind einzeln versioniert, und ein zwischenzeitlich geändertes Feld soll die
|
||||
* übrigen nicht aufhalten. Wäre das eine Transaktion, blockierte ein einziger
|
||||
* fremder Eingriff das ganze Blatt.
|
||||
*/
|
||||
export async function saveSectionsAction(
|
||||
caseNumber: string,
|
||||
input: unknown,
|
||||
): Promise<Record<string, SaveSectionResult>> {
|
||||
const parsed = batchSchema.safeParse(input)
|
||||
if (!parsed.success) return {}
|
||||
|
||||
const ergebnisse: Record<string, SaveSectionResult> = {}
|
||||
for (const feld of parsed.data as SaveSectionsInput) {
|
||||
ergebnisse[feld.fieldKey] = await saveSectionAction(caseNumber, feld)
|
||||
}
|
||||
return ergebnisse
|
||||
}
|
||||
|
||||
const batchSchema = z.array(schema).max(200)
|
||||
|
||||
@@ -4,7 +4,7 @@ import Link from 'next/link'
|
||||
import { notFound } from 'next/navigation'
|
||||
import { db } from '@/lib/db'
|
||||
import { requirePermission } from '@/lib/auth/current'
|
||||
import { recordRead, READ_AUDIT_FROM_LEVEL } from '@/lib/audit'
|
||||
import { recordRead, READ_AUDIT_FROM_LEVEL } from '@/lib/audit/write'
|
||||
import {
|
||||
listAssignments,
|
||||
listStatusTags,
|
||||
|
||||
@@ -6,7 +6,7 @@ import { exportDatabase } from '@/lib/export/database'
|
||||
import { createZip } from '@/lib/export/zip'
|
||||
import { absolutePath } from '@/lib/files/paths'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { writeAudit } from '@/lib/audit'
|
||||
import { writeAudit } from '@/lib/audit/write'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Readable } from 'node:stream'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { db } from '@/lib/db'
|
||||
import { getCurrentUser } from '@/lib/auth/current'
|
||||
import { recordRead } from '@/lib/audit'
|
||||
import { recordRead } from '@/lib/audit/write'
|
||||
import { absolutePath } from '@/lib/files/paths'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Readable } from 'node:stream'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { db } from '@/lib/db'
|
||||
import { getCurrentUser } from '@/lib/auth/current'
|
||||
import { recordRead, READ_AUDIT_FROM_LEVEL } from '@/lib/audit'
|
||||
import { recordRead, READ_AUDIT_FROM_LEVEL } from '@/lib/audit/write'
|
||||
import { loadDeliverable } from '@/lib/files/service'
|
||||
import { absolutePath } from '@/lib/files/paths'
|
||||
import { deliveryFileName } from '@/lib/files/delivery'
|
||||
|
||||
Reference in New Issue
Block a user