Prüfung / Lint, Typen, Tests (push) Canceled after 0s
Prüfung / Images bauen (pdf) (push) Canceled after 0s
Prüfung / Images bauen (web) (push) Canceled after 0s
Prüfung / Images bauen (worker) (push) Canceled after 0s
Images / bauen (pdf, docker/pdf.Dockerfile) (push) Canceled after 0s
Images / bauen (web, docker/web.Dockerfile) (push) Canceled after 0s
Images / bauen (worker, docker/worker.Dockerfile) (push) Canceled after 0s
375 Vorkommen in 210 Dateien. Betrifft Kommentare, Oberflaechentexte und die README; an keiner Stelle Bedeutung, nur Zeichensetzung.
187 lines
6.0 KiB
TypeScript
187 lines
6.0 KiB
TypeScript
import { db } from '@/lib/db'
|
|
import { writeAudit } from '@/lib/audit/write'
|
|
import { AccessError } from '@/lib/errors'
|
|
import {
|
|
visibleDepartmentEntryWhere,
|
|
visibleDepartmentWhere,
|
|
} from '@/lib/access/departmentFilter'
|
|
import type { EffectivePermissions } from '@/lib/auth/permissions'
|
|
|
|
/**
|
|
* Vermerke an einer Abteilung.
|
|
*
|
|
* Zusicherung 2 der README gilt hier genauso wie im Verlauf einer Akte:
|
|
* **es gibt kein Löschen.** Jede Änderung legt den vorherigen Stand als
|
|
* Version ab, jede Zurücknahme lässt einen Grabstein stehen, und beides sieht
|
|
* jeder, der den Vermerk sehen darf. Die Regeln stehen in der Datenbank
|
|
* (`dept_entry_no_delete`, `dept_entry_immutable_guard`) und nicht nur hier.
|
|
*
|
|
* Schmaler als der Aktenverlauf ist er trotzdem: keine Korrekturkette, keine
|
|
* Anhänge, keine Karenzzeit. Ein Abteilungsvermerk ist ein laufendes
|
|
* Protokoll, kein Beweismittel - die Ermittlung gehört in eine Fallakte.
|
|
*/
|
|
|
|
const AUSWAHL = {
|
|
id: true,
|
|
body: true,
|
|
minClearance: true,
|
|
authorId: true,
|
|
createdAt: true,
|
|
version: true,
|
|
editedAt: true,
|
|
editedById: true,
|
|
retractedAt: true,
|
|
retractedById: true,
|
|
retractionReason: true,
|
|
} as const
|
|
|
|
export async function listEntries(p: EffectivePermissions, departmentId: string, take = 50) {
|
|
return db.departmentEntry.findMany({
|
|
where: { AND: [visibleDepartmentEntryWhere(p), { departmentId }] },
|
|
orderBy: { createdAt: 'desc' },
|
|
take,
|
|
select: AUSWAHL,
|
|
})
|
|
}
|
|
|
|
/** Wer Vermerke schreiben darf. Dasselbe Recht wie für den Aktenverlauf. */
|
|
function darfSchreiben(p: EffectivePermissions): boolean {
|
|
return p.isAdmin || p.permissions.has('entry.create')
|
|
}
|
|
|
|
export async function createEntry(
|
|
p: EffectivePermissions,
|
|
input: { id: string; departmentKey: string; body: string; minClearance: number },
|
|
): Promise<{ id: string }> {
|
|
if (!darfSchreiben(p)) throw new AccessError('no-permission')
|
|
|
|
const abteilung = await db.department.findFirst({
|
|
where: { AND: [visibleDepartmentWhere(p), { key: input.departmentKey }] },
|
|
select: { id: true, key: true },
|
|
})
|
|
if (!abteilung) throw new AccessError('not-found')
|
|
|
|
const text = input.body.trim()
|
|
if (text.length === 0) throw new AccessError('entry-empty')
|
|
|
|
// Niemand schreibt über seiner eigenen Stufe: sonst legte man einen Vermerk
|
|
// an, den man selbst nicht mehr lesen kann. Dieselbe Regel wie bei Akten.
|
|
if (!p.isAdmin && input.minClearance > p.clearanceLevel) {
|
|
throw new AccessError('clearance-too-low')
|
|
}
|
|
|
|
// Dieselbe Kennung zweimal erzeugt keinen zweiten Vermerk.
|
|
const vorhanden = await db.departmentEntry.findUnique({
|
|
where: { id: input.id },
|
|
select: { id: true },
|
|
})
|
|
if (vorhanden) return vorhanden
|
|
|
|
await db.$transaction(async (tx) => {
|
|
await tx.departmentEntry.create({
|
|
data: {
|
|
id: input.id,
|
|
departmentId: abteilung.id,
|
|
body: text,
|
|
minClearance: input.minClearance,
|
|
authorId: p.userId,
|
|
},
|
|
})
|
|
await writeAudit(tx, {
|
|
actorId: p.userId,
|
|
action: 'department.entry.create',
|
|
targetType: 'DepartmentEntry',
|
|
targetId: input.id,
|
|
clearanceAtTime: p.clearanceLevel,
|
|
meta: { department: abteilung.key, minClearance: input.minClearance },
|
|
})
|
|
})
|
|
|
|
return { id: input.id }
|
|
}
|
|
|
|
/**
|
|
* Ändern. Der vorherige Stand wird zur Version - in derselben Transaktion,
|
|
* sonst gäbe es einen Moment, in dem der alte Text nirgends mehr steht.
|
|
*/
|
|
export async function editEntry(
|
|
p: EffectivePermissions,
|
|
input: { id: string; body: string },
|
|
): Promise<void> {
|
|
const vermerk = await db.departmentEntry.findFirst({
|
|
where: { AND: [visibleDepartmentEntryWhere(p), { id: input.id }] },
|
|
select: { id: true, body: true, minClearance: true, authorId: true, version: true },
|
|
})
|
|
if (!vermerk) throw new AccessError('not-found')
|
|
|
|
const eigener = vermerk.authorId === p.userId
|
|
const darf =
|
|
p.isAdmin ||
|
|
(eigener && p.permissions.has('entry.edit_own')) ||
|
|
p.permissions.has('entry.edit_any')
|
|
if (!darf) throw new AccessError('no-permission')
|
|
|
|
const text = input.body.trim()
|
|
if (text.length === 0) throw new AccessError('entry-empty')
|
|
if (text === vermerk.body) return
|
|
|
|
await db.$transaction(async (tx) => {
|
|
await tx.departmentEntryVersion.create({
|
|
data: {
|
|
entryId: vermerk.id,
|
|
version: vermerk.version,
|
|
body: vermerk.body,
|
|
minClearance: vermerk.minClearance,
|
|
editedById: p.userId,
|
|
},
|
|
})
|
|
await tx.departmentEntry.update({
|
|
where: { id: vermerk.id },
|
|
data: { body: text, version: vermerk.version + 1, editedAt: new Date(), editedById: p.userId },
|
|
})
|
|
await writeAudit(tx, {
|
|
actorId: p.userId,
|
|
action: 'department.entry.edit',
|
|
targetType: 'DepartmentEntry',
|
|
targetId: vermerk.id,
|
|
clearanceAtTime: p.clearanceLevel,
|
|
meta: { version: vermerk.version + 1 },
|
|
})
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Zurückziehen. Kein Löschen: der Vermerk bleibt als Grabstein stehen, mit
|
|
* Grund und Namen. Wer etwas anderes sagen will, schreibt einen neuen.
|
|
*/
|
|
export async function retractEntry(
|
|
p: EffectivePermissions,
|
|
input: { id: string; reason: string },
|
|
): Promise<void> {
|
|
const vermerk = await db.departmentEntry.findFirst({
|
|
where: { AND: [visibleDepartmentEntryWhere(p), { id: input.id }] },
|
|
select: { id: true, retractedAt: true },
|
|
})
|
|
if (!vermerk) throw new AccessError('not-found')
|
|
if (!p.isAdmin && !p.permissions.has('entry.retract')) throw new AccessError('no-permission')
|
|
if (vermerk.retractedAt) return
|
|
|
|
const grund = input.reason.trim()
|
|
if (grund.length === 0) throw new AccessError('reason-required')
|
|
|
|
await db.$transaction(async (tx) => {
|
|
await tx.departmentEntry.update({
|
|
where: { id: vermerk.id },
|
|
data: { retractedAt: new Date(), retractedById: p.userId, retractionReason: grund },
|
|
})
|
|
await writeAudit(tx, {
|
|
actorId: p.userId,
|
|
action: 'department.entry.retract',
|
|
targetType: 'DepartmentEntry',
|
|
targetId: vermerk.id,
|
|
clearanceAtTime: p.clearanceLevel,
|
|
meta: { reason: grund },
|
|
})
|
|
})
|
|
}
|