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>
291 lines
8.7 KiB
TypeScript
291 lines
8.7 KiB
TypeScript
import type { Metadata } from 'next'
|
|
import { Suspense } from 'react'
|
|
import { notFound } from 'next/navigation'
|
|
import Link from 'next/link'
|
|
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/write'
|
|
import { de } from '@/lib/i18n/de'
|
|
import { SkeletonLine } from '@/components/Skeleton'
|
|
import { EntryComposer } from './EntryComposer'
|
|
import { Sections } from '@/components/sections/Sections'
|
|
import { saveSectionAction, saveSectionsAction } from './sectionActions'
|
|
import { loadSections } from '@/lib/cases/sectionService'
|
|
import { canEditCase } from '@/lib/access/caseWrite'
|
|
import { EntryActions } from './EntryActions'
|
|
import { ChatLazy } from '@/components/chat/ChatLazy'
|
|
import { loadChatView } from '@/lib/chat/load'
|
|
import { evaluateEntryEdit, canRetract } from '@/lib/cases/entryPolicy'
|
|
import type { EffectivePermissions } from '@/lib/auth/permissions'
|
|
|
|
export const dynamic = 'force-dynamic'
|
|
|
|
export async function generateMetadata({
|
|
params,
|
|
}: {
|
|
params: Promise<{ caseNumber: string }>
|
|
}): Promise<Metadata> {
|
|
const { caseNumber } = await params
|
|
return { title: caseNumber }
|
|
}
|
|
|
|
async function Timeline({
|
|
caseId,
|
|
caseNumber,
|
|
permissions,
|
|
}: {
|
|
caseId: string
|
|
caseNumber: string
|
|
permissions: EffectivePermissions
|
|
}) {
|
|
const settings = await db.appSetting.findUnique({ where: { id: 1 } })
|
|
const policy = {
|
|
entryEditPolicy: (settings?.entryEditPolicy ?? 'GRACE_WINDOW') as
|
|
| 'APPEND_ONLY'
|
|
| 'GRACE_WINDOW'
|
|
| 'OPEN',
|
|
entryEditGraceMinutes: settings?.entryEditGraceMinutes ?? 15,
|
|
entryEditMarkGrace: settings?.entryEditMarkGrace ?? false,
|
|
}
|
|
|
|
const entries = await db.caseEntry.findMany({
|
|
where: { AND: [visibleEntryWhere(permissions), { caseId }] },
|
|
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
|
|
take: 100,
|
|
select: {
|
|
id: true,
|
|
type: true,
|
|
body: true,
|
|
minClearance: true,
|
|
authorId: true,
|
|
createdAt: true,
|
|
version: true,
|
|
editedAt: true,
|
|
editVisible: true,
|
|
retractedAt: true,
|
|
retractionReason: true,
|
|
},
|
|
})
|
|
|
|
if (entries.length === 0) {
|
|
return <p className="text-text-dim">{de.entry.empty}</p>
|
|
}
|
|
|
|
const authors = await db.user.findMany({
|
|
where: { id: { in: [...new Set(entries.map((e) => e.authorId))] } },
|
|
select: { id: true, displayName: true },
|
|
})
|
|
const nameOf = new Map(authors.map((a) => [a.id, a.displayName]))
|
|
|
|
return (
|
|
<ol className="timeline">
|
|
{entries.map((entry) => (
|
|
<li key={entry.id} className="pb-6">
|
|
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1 text-2xs text-text-dim">
|
|
<span className="font-condensed">{de.entry.types[entry.type]}</span>
|
|
<span className="font-mono">{nameOf.get(entry.authorId) ?? entry.authorId}</span>
|
|
<time dateTime={entry.createdAt.toISOString()}>
|
|
{entry.createdAt.toLocaleString('de-DE')}
|
|
</time>
|
|
{entry.editVisible && entry.editedAt ? (
|
|
<Link
|
|
href={`/faelle/${caseNumber}/aenderungen`}
|
|
className="font-condensed"
|
|
>
|
|
{de.entry.edited(entry.version)}
|
|
</Link>
|
|
) : null}
|
|
</div>
|
|
|
|
{entry.retractedAt ? (
|
|
<p className="mt-1 border-l-(length:--spacing-edge) border-line pl-3 text-sm text-text-dim">
|
|
{de.entry.retracted(
|
|
entry.retractedAt.toLocaleDateString('de-DE'),
|
|
nameOf.get(entry.authorId) ?? entry.authorId,
|
|
entry.retractionReason ?? '',
|
|
)}
|
|
</p>
|
|
) : (
|
|
<p className="mt-1 max-w-(--container-measure) whitespace-pre-wrap text-sm">
|
|
{entry.body}
|
|
</p>
|
|
)}
|
|
|
|
<EntryActions
|
|
entryId={entry.id}
|
|
caseNumber={caseNumber}
|
|
body={entry.body}
|
|
retracted={entry.retractedAt !== null}
|
|
canEdit={evaluateEntryEdit(permissions, entry, policy).ok}
|
|
canRetract={canRetract(permissions, entry)}
|
|
/>
|
|
</li>
|
|
))}
|
|
</ol>
|
|
)
|
|
}
|
|
|
|
async function SectionBlock({
|
|
caseId,
|
|
caseNumber,
|
|
templateId,
|
|
permissions,
|
|
canEdit,
|
|
}: {
|
|
caseId: string
|
|
caseNumber: string
|
|
templateId: string
|
|
permissions: EffectivePermissions
|
|
canEdit: boolean
|
|
}) {
|
|
const sections = await loadSections(permissions, caseId, templateId)
|
|
return (
|
|
<Sections
|
|
scope={`fall:${caseNumber}`}
|
|
save={saveSectionAction.bind(null, caseNumber)}
|
|
saveAll={saveSectionsAction.bind(null, caseNumber)}
|
|
sections={sections}
|
|
canEdit={canEdit}
|
|
/>
|
|
)
|
|
}
|
|
|
|
|
|
/**
|
|
* Der Aktenchat. Eigene Suspense-Grenze, weil er die meisten Abfragen macht
|
|
* und der Rest der Seite nicht auf ihn warten soll.
|
|
*/
|
|
async function ChatBlock({
|
|
akte,
|
|
user,
|
|
}: {
|
|
akte: { id: string; caseNumber: string; status: string; classification: number }
|
|
user: { id: string; permissions: EffectivePermissions }
|
|
}) {
|
|
const ansicht = await loadChatView(user.permissions, {
|
|
kind: 'case',
|
|
caseNumber: akte.caseNumber,
|
|
})
|
|
|
|
return (
|
|
<ChatLazy
|
|
scope={{ kind: 'case', caseNumber: akte.caseNumber, caseId: akte.id }}
|
|
topic={`case:${akte.id}`}
|
|
initial={ansicht.messages}
|
|
initialReactions={ansicht.reactions}
|
|
currentUserId={user.id}
|
|
lastReadAt={ansicht.lastReadAt}
|
|
canAdopt={canEditCase(user.permissions, akte, 'entry.create').ok}
|
|
mentionNames={ansicht.mentionNames}
|
|
/>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Der Reiter „Akte": die drei Ebenen aus docs/05-akten-und-vorlagen.md, die
|
|
* zusammengehören — Abschnitte als Sachstand, Verlauf als Chronologie, dazu
|
|
* der formlose Aktenchat. Kopfdaten und Reiterleiste stehen im Layout.
|
|
*/
|
|
export default async function CasePage({
|
|
params,
|
|
}: {
|
|
params: Promise<{ caseNumber: string }>
|
|
}) {
|
|
const user = await requireUser()
|
|
const { caseNumber } = await params
|
|
|
|
const akte = await loadVisibleCase(user.permissions, caseNumber)
|
|
// Kein Zugriff heisst 404, nicht 403: die Existenz ist selbst eine Information.
|
|
if (!akte) notFound()
|
|
|
|
// Harte Regel 3: jeder Lesezugriff ab Stufe 3 wird protokolliert.
|
|
if (akte.classification >= READ_AUDIT_FROM_LEVEL) {
|
|
recordRead({
|
|
actorId: user.id,
|
|
action: 'case.read',
|
|
targetType: 'Case',
|
|
targetId: akte.id,
|
|
clearanceAtTime: user.permissions.clearanceLevel,
|
|
meta: { caseNumber: akte.caseNumber, classification: akte.classification },
|
|
})
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<section>
|
|
<h2 className="font-condensed">{de.section.heading}</h2>
|
|
<div className="mt-4">
|
|
<Suspense
|
|
fallback={
|
|
<div className="space-y-6">
|
|
<SkeletonLine width="10rem" />
|
|
<SkeletonLine />
|
|
<SkeletonLine width="12rem" />
|
|
<SkeletonLine />
|
|
</div>
|
|
}
|
|
>
|
|
<SectionBlock
|
|
caseId={akte.id}
|
|
caseNumber={akte.caseNumber}
|
|
templateId={akte.templateId}
|
|
permissions={user.permissions}
|
|
canEdit={canEditCase(user.permissions, akte).ok}
|
|
/>
|
|
</Suspense>
|
|
</div>
|
|
</section>
|
|
|
|
<section className="mt-12">
|
|
<div className="flex flex-wrap items-baseline justify-between gap-4">
|
|
<h2 className="font-condensed">{de.case.timeline}</h2>
|
|
<Link href={`/faelle/${akte.caseNumber}/aenderungen`} className="text-xs">
|
|
{de.case.changes}
|
|
</Link>
|
|
</div>
|
|
|
|
<div className="mt-4">
|
|
<EntryComposer caseNumber={akte.caseNumber} />
|
|
</div>
|
|
|
|
<div className="mt-8">
|
|
<Suspense
|
|
fallback={
|
|
<div className="timeline">
|
|
<SkeletonLine width="18rem" />
|
|
<SkeletonLine />
|
|
<SkeletonLine width="80%" />
|
|
<div className="h-4" />
|
|
<SkeletonLine width="16rem" />
|
|
<SkeletonLine width="70%" />
|
|
</div>
|
|
}
|
|
>
|
|
<Timeline
|
|
caseId={akte.id}
|
|
caseNumber={akte.caseNumber}
|
|
permissions={user.permissions}
|
|
/>
|
|
</Suspense>
|
|
</div>
|
|
</section>
|
|
|
|
<section className="mt-12">
|
|
<Suspense
|
|
fallback={
|
|
<div className="space-y-3 border border-line p-4">
|
|
<SkeletonLine width="8rem" />
|
|
<SkeletonLine width="70%" />
|
|
<SkeletonLine width="55%" />
|
|
</div>
|
|
}
|
|
>
|
|
<ChatBlock akte={akte} user={user} />
|
|
</Suspense>
|
|
</section>
|
|
</>
|
|
)
|
|
}
|