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.
163 lines
4.8 KiB
TypeScript
163 lines
4.8 KiB
TypeScript
import { db } from '@/lib/db'
|
|
import { writeAudit } from '@/lib/audit/write'
|
|
import { canEditCase } from '@/lib/access/caseWrite'
|
|
import { loadVisibleCase } from '@/lib/cases/service'
|
|
import { AccessError } from '@/lib/errors'
|
|
import { parseTemplateFields } from '@/lib/templates/schema'
|
|
import {
|
|
asJson,
|
|
planSectionSave,
|
|
renderSections,
|
|
type RenderedSection,
|
|
} from '@/lib/sections/render'
|
|
import type { TemplateField } from '@/lib/templates/fields'
|
|
import type { EffectivePermissions } from '@/lib/auth/permissions'
|
|
|
|
/**
|
|
* Abschnitte einer Fallakte: der aktuelle Sachstand, aus der Vorlage erzeugt
|
|
* und je Feld versioniert. Siehe docs/05-akten-und-vorlagen.md.
|
|
*
|
|
* Die Regeln stehen in `lib/sections/render.ts`, weil Dokumente dieselben
|
|
* haben. Hier steht nur, welche Tabelle gelesen und geschrieben wird.
|
|
*/
|
|
|
|
export type { RenderedSection, Placeholder } from '@/lib/sections/render'
|
|
export { ConflictError } from '@/lib/errors'
|
|
|
|
export function templateFields(raw: unknown): TemplateField[] {
|
|
return parseTemplateFields(raw)
|
|
}
|
|
|
|
export async function loadSections(
|
|
p: EffectivePermissions,
|
|
caseId: string,
|
|
templateId: string,
|
|
): Promise<RenderedSection[]> {
|
|
const [template, rows] = await Promise.all([
|
|
db.documentTemplate.findUnique({ where: { id: templateId }, select: { fields: true } }),
|
|
db.caseSection.findMany({ where: { caseId } }),
|
|
])
|
|
|
|
return renderSections(templateFields(template?.fields), rows, p)
|
|
}
|
|
|
|
/**
|
|
* Speichert einen Abschnitt.
|
|
*
|
|
* Der Client schickt die Version mit, die er geladen hat. Weicht sie ab, gibt
|
|
* es kein stilles Überschreiben, sondern einen Konflikt mit beiden Fassungen.
|
|
*/
|
|
export async function saveSection(
|
|
p: EffectivePermissions,
|
|
input: {
|
|
caseNumber: string
|
|
fieldKey: string
|
|
value: unknown
|
|
/** Version, die der Client geladen hat. 0 heisst: Feld war noch leer. */
|
|
baseVersion: number
|
|
minClearance?: number
|
|
},
|
|
): Promise<{ version: number }> {
|
|
const target = await loadVisibleCase(p, input.caseNumber)
|
|
if (!target) throw new AccessError('not-found')
|
|
|
|
const check = canEditCase(p, target)
|
|
if (!check.ok) throw new AccessError(check.reason)
|
|
|
|
const template = await db.documentTemplate.findUnique({
|
|
where: { id: target.templateId },
|
|
select: { fields: true },
|
|
})
|
|
const field = templateFields(template?.fields).find((f) => f.key === input.fieldKey)
|
|
if (!field) throw new AccessError('field-unknown')
|
|
|
|
const existing = await db.caseSection.findUnique({
|
|
where: { caseId_fieldKey: { caseId: target.id, fieldKey: input.fieldKey } },
|
|
})
|
|
|
|
const { nextClearance, nextVersion } = planSectionSave({
|
|
p,
|
|
field,
|
|
existing,
|
|
baseVersion: input.baseVersion,
|
|
minClearance: input.minClearance,
|
|
})
|
|
|
|
await db.$transaction(async (tx) => {
|
|
if (existing) {
|
|
// Der vorherige Stand wandert in die Versionstabelle, bevor überschrieben
|
|
// wird. Die Historie ist damit lückenlos.
|
|
await tx.caseSectionVersion.create({
|
|
data: {
|
|
sectionId: existing.id,
|
|
version: existing.currentVersion,
|
|
value: asJson(existing.value),
|
|
authorId: existing.updatedById,
|
|
},
|
|
})
|
|
await tx.caseSection.update({
|
|
where: { id: existing.id },
|
|
data: {
|
|
value: asJson(input.value),
|
|
currentVersion: nextVersion,
|
|
updatedById: p.userId,
|
|
minClearance: nextClearance,
|
|
},
|
|
})
|
|
} else {
|
|
await tx.caseSection.create({
|
|
data: {
|
|
caseId: target.id,
|
|
fieldKey: input.fieldKey,
|
|
value: asJson(input.value),
|
|
currentVersion: 1,
|
|
updatedById: p.userId,
|
|
minClearance: nextClearance,
|
|
},
|
|
})
|
|
}
|
|
|
|
await writeAudit(tx, {
|
|
actorId: p.userId,
|
|
action: 'case.update',
|
|
targetType: 'CaseSection',
|
|
targetId: `${target.id}:${input.fieldKey}`,
|
|
clearanceAtTime: p.clearanceLevel,
|
|
meta: {
|
|
caseNumber: target.caseNumber,
|
|
fieldKey: input.fieldKey,
|
|
version: nextVersion,
|
|
minClearance: nextClearance,
|
|
},
|
|
})
|
|
})
|
|
|
|
return { version: nextVersion }
|
|
}
|
|
|
|
/** Die Versionen eines Abschnitts, für die Diff-Ansicht. */
|
|
export async function loadSectionHistory(
|
|
p: EffectivePermissions,
|
|
caseNumber: string,
|
|
fieldKey: string,
|
|
) {
|
|
const target = await loadVisibleCase(p, caseNumber)
|
|
if (!target) throw new AccessError('not-found')
|
|
|
|
const section = await db.caseSection.findUnique({
|
|
where: { caseId_fieldKey: { caseId: target.id, fieldKey } },
|
|
select: { id: true, minClearance: true, value: true, currentVersion: true, updatedById: true },
|
|
})
|
|
if (!section) return null
|
|
|
|
if (!p.isAdmin && section.minClearance > p.clearanceLevel) throw new AccessError('clearance-too-low')
|
|
|
|
const versions = await db.caseSectionVersion.findMany({
|
|
where: { sectionId: section.id },
|
|
orderBy: { version: 'desc' },
|
|
take: 50,
|
|
})
|
|
|
|
return { section, versions }
|
|
}
|