Anhaenge zurueckziehen statt sie liegen zu lassen
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
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
Bisher gab es ueberhaupt keinen Weg, einen Anhang wieder loszuwerden. Wer die falsche Datei hochlud, musste damit leben. Das ist keine Strenge, sondern eine Luecke: die Regel des Hauses lautet nicht "nichts geht wieder weg", sondern "nichts geht unbemerkt weg". Also derselbe Weg wie beim Verlaufseintrag und beim Dokument. Zurueckziehen mit Pflichtbegruendung, der Anhang bleibt als Grabstein an seiner Stelle stehen - durchgestrichen, mit Zeitpunkt und Grund, fuer jeden sichtbar, der ihn sehen darf. Aufheben laesst es sich, beides steht im Protokoll. Aus dem Umlauf heisst wirklich aus dem Umlauf: die Auslieferung verweigert die Datei, sie verschwindet aus Lichtbildfeldern, und das Vorschaubild faellt weg. Ein Grabstein, unter dem der Download weiterlaeuft, waere eine Beschriftung und keine Ruecknahme. Fuer `audit.read` bleibt sie abrufbar - wie der Text eines zurueckgezogenen Eintrags, sonst waere es doch ein Loeschen. Im Ausdruck steht der Anhang weiter im Verzeichnis, mit dem Grund daneben. Ihn dort wegzulassen hiesse, den Ausdruck sauberer zu machen als die Akte. Die Datei auf der Platte bleibt liegen. Sie zu entfernen waere das Loeschen, das hier gerade nicht stattfindet - und ein Anhang, dessen Pruefsumme nicht mehr nachrechenbar ist, waere als Beweismittel wertlos. Dazu eine Regel in der Datenbank, die DELETE auf Attachment verbietet, und eine Bedingung, die einen Grabstein ohne Begruendung ausschliesst. Zuruecknehmen darf, wer die Datei hochgeladen hat - der haeufige Fall, und ohne das bliebe jeder Fehlgriff liegen, weil dafuer die Leitung zu rufen waere - sowie jeder, der am Besitzer schreiben darf.
This commit is contained in:
@@ -43,6 +43,8 @@ export type AuditAction =
|
||||
| 'attachment.upload'
|
||||
| 'attachment.read'
|
||||
| 'attachment.release'
|
||||
| 'attachment.retract'
|
||||
| 'attachment.restore'
|
||||
| 'chat.adopt'
|
||||
| 'department.create'
|
||||
| 'department.update'
|
||||
|
||||
+178
-2
@@ -7,7 +7,7 @@ import { canEditCase } from '@/lib/access/caseWrite'
|
||||
import { canEditDocument } from '@/lib/access/documentWrite'
|
||||
import { loadVisibleCase } from '@/lib/cases/service'
|
||||
import { loadVisibleDocument } from '@/lib/documents/service'
|
||||
import { visibleEntryWhere } from '@/lib/access/caseFilter'
|
||||
import { visibleCaseWhere, visibleEntryWhere } from '@/lib/access/caseFilter'
|
||||
import {
|
||||
MAX_FILE_BYTES,
|
||||
MAX_UPLOADS_PER_MINUTE,
|
||||
@@ -48,6 +48,9 @@ const KOPFDATEN = {
|
||||
caseId: true,
|
||||
entryId: true,
|
||||
documentId: true,
|
||||
retractedAt: true,
|
||||
retractedById: true,
|
||||
retractionReason: true,
|
||||
} satisfies Prisma.AttachmentSelect
|
||||
|
||||
/**
|
||||
@@ -187,8 +190,21 @@ export async function listAttachments(
|
||||
* nicht mit Recht `admin`, denn die Datei liegt dann noch in der Quarantäne.
|
||||
*/
|
||||
export async function loadDeliverable(p: EffectivePermissions, id: string) {
|
||||
// Ein zurückgezogener Anhang wird nicht mehr ausgeliefert - ausser an
|
||||
// jemanden mit `audit.read`. Das ist dieselbe Regel wie beim Text eines
|
||||
// zurückgezogenen Verlaufseintrags: die Sache bleibt nachprüfbar, ist aber
|
||||
// aus dem Umlauf. Siehe docs/05-akten-und-vorlagen.md.
|
||||
const darfNachpruefen = p.isAdmin || p.permissions.has('audit.read')
|
||||
|
||||
const anhang = await db.attachment.findFirst({
|
||||
where: { AND: [visibleAttachmentWhere(p), { id }, { scanStatus: 'CLEAN' }] },
|
||||
where: {
|
||||
AND: [
|
||||
visibleAttachmentWhere(p),
|
||||
{ id },
|
||||
{ scanStatus: 'CLEAN' },
|
||||
...(darfNachpruefen ? [] : [{ retractedAt: null }]),
|
||||
],
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
originalName: true,
|
||||
@@ -268,6 +284,9 @@ export async function resolvePhotoUrls(
|
||||
visibleAttachmentWhere(p),
|
||||
{ id: { in: gesucht.map((g) => g.id) } },
|
||||
{ scanStatus: 'CLEAN' },
|
||||
// Ein zurückgezogenes Lichtbild verschwindet aus dem Feld. Bliebe es
|
||||
// stehen, wäre die Rücknahme eine Beschriftung und keine.
|
||||
{ retractedAt: null },
|
||||
],
|
||||
},
|
||||
select: { id: true, thumbKey: true },
|
||||
@@ -282,3 +301,160 @@ export async function resolvePhotoUrls(
|
||||
}
|
||||
return ergebnis
|
||||
}
|
||||
|
||||
/**
|
||||
* Wer darf die Rücknahme eines Anhangs aussprechen?
|
||||
*
|
||||
* Zwei Wege, und der erste ist der häufige: **wer die Datei hochgeladen hat**,
|
||||
* nimmt seinen eigenen Fehlgriff zurück. Sonst muss für jede versehentlich
|
||||
* angehängte Datei die Leitung gerufen werden, und das führt dazu, dass sie
|
||||
* eben liegen bleibt.
|
||||
*
|
||||
* Der zweite Weg ist, wer am Besitzer schreiben darf - dieselbe Prüfung wie
|
||||
* beim Anhängen. Wer eine Datei hinzufügen darf, darf sie auch zurücknehmen.
|
||||
*/
|
||||
async function darfZuruecknehmen(
|
||||
p: EffectivePermissions,
|
||||
anhang: {
|
||||
uploadedById: string
|
||||
caseId: string | null
|
||||
entryId: string | null
|
||||
documentId: string | null
|
||||
},
|
||||
): Promise<void> {
|
||||
if (p.isAdmin || anhang.uploadedById === p.userId) return
|
||||
|
||||
if (anhang.documentId) {
|
||||
const doc = await db.document.findUnique({
|
||||
where: { id: anhang.documentId },
|
||||
select: { status: true, classification: true, retractedAt: true },
|
||||
})
|
||||
if (!doc) throw new AccessError('not-found')
|
||||
must(canEditDocument(p, doc))
|
||||
return
|
||||
}
|
||||
|
||||
const caseId =
|
||||
anhang.caseId ??
|
||||
(
|
||||
await db.caseEntry.findUnique({
|
||||
where: { id: anhang.entryId ?? '' },
|
||||
select: { caseId: true },
|
||||
})
|
||||
)?.caseId
|
||||
|
||||
if (!caseId) throw new AccessError('not-found')
|
||||
|
||||
const akte = await db.case.findFirst({
|
||||
where: { AND: [visibleCaseWhere(p), { id: caseId }] },
|
||||
select: { status: true, classification: true },
|
||||
})
|
||||
if (!akte) throw new AccessError('not-found')
|
||||
must(canEditCase(p, akte, 'entry.create'))
|
||||
}
|
||||
|
||||
/** Kürzer als das ist keine Begründung, sondern ein Achselzucken. */
|
||||
const MIN_BEGRUENDUNG = 10
|
||||
|
||||
/**
|
||||
* Anhang zurückziehen.
|
||||
*
|
||||
* Kein Löschen: der Anhang bleibt als Grabstein an seiner Stelle stehen, mit
|
||||
* Zeitpunkt, Urheber und Begründung. Die Datei wird nicht mehr ausgeliefert
|
||||
* und verschwindet aus Lichtbildfeldern; für `audit.read` bleibt sie
|
||||
* abrufbar. Genau wie beim Verlaufseintrag, siehe README, Zusicherung 2.
|
||||
*
|
||||
* Die Datei auf der Platte bleibt liegen. Sie zu entfernen wäre das Löschen,
|
||||
* das hier gerade nicht stattfindet - und ein zurückgezogener Anhang, dessen
|
||||
* Prüfsumme nicht mehr nachrechenbar ist, wäre als Beweismittel wertlos.
|
||||
*/
|
||||
export async function retractAttachment(
|
||||
p: EffectivePermissions,
|
||||
id: string,
|
||||
reason: string,
|
||||
): Promise<void> {
|
||||
const begruendung = reason.trim()
|
||||
if (begruendung.length < MIN_BEGRUENDUNG) throw new AccessError('reason-required')
|
||||
|
||||
const anhang = await db.attachment.findFirst({
|
||||
where: { AND: [visibleAttachmentWhere(p), { id }] },
|
||||
select: {
|
||||
id: true,
|
||||
originalName: true,
|
||||
uploadedById: true,
|
||||
caseId: true,
|
||||
entryId: true,
|
||||
documentId: true,
|
||||
retractedAt: true,
|
||||
},
|
||||
})
|
||||
if (!anhang) throw new AccessError('not-found')
|
||||
if (anhang.retractedAt) throw new AccessError('already-retracted')
|
||||
|
||||
await darfZuruecknehmen(p, anhang)
|
||||
|
||||
await db.$transaction(async (tx) => {
|
||||
await tx.attachment.update({
|
||||
where: { id },
|
||||
data: {
|
||||
retractedAt: new Date(),
|
||||
retractedById: p.userId,
|
||||
retractionReason: begruendung.slice(0, 500),
|
||||
},
|
||||
})
|
||||
|
||||
await writeAudit(tx, {
|
||||
actorId: p.userId,
|
||||
action: 'attachment.retract',
|
||||
targetType: 'Attachment',
|
||||
targetId: id,
|
||||
clearanceAtTime: p.clearanceLevel,
|
||||
meta: { originalName: anhang.originalName, reason: begruendung.slice(0, 500) },
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Rücknahme aufheben.
|
||||
*
|
||||
* Möglich, weil ein Irrtum auch beim Zurückziehen vorkommt - und weil beides
|
||||
* im Protokoll steht, ist daran nichts still. Der Grabstein verschwindet aus
|
||||
* der Ansicht, die beiden Protokolleinträge bleiben.
|
||||
*/
|
||||
export async function restoreAttachment(
|
||||
p: EffectivePermissions,
|
||||
id: string,
|
||||
): Promise<void> {
|
||||
const anhang = await db.attachment.findFirst({
|
||||
where: { AND: [visibleAttachmentWhere(p), { id }] },
|
||||
select: {
|
||||
id: true,
|
||||
originalName: true,
|
||||
uploadedById: true,
|
||||
caseId: true,
|
||||
entryId: true,
|
||||
documentId: true,
|
||||
retractedAt: true,
|
||||
},
|
||||
})
|
||||
if (!anhang) throw new AccessError('not-found')
|
||||
if (!anhang.retractedAt) throw new AccessError('release-not-possible')
|
||||
|
||||
await darfZuruecknehmen(p, anhang)
|
||||
|
||||
await db.$transaction(async (tx) => {
|
||||
await tx.attachment.update({
|
||||
where: { id },
|
||||
data: { retractedAt: null, retractedById: null, retractionReason: null },
|
||||
})
|
||||
|
||||
await writeAudit(tx, {
|
||||
actorId: p.userId,
|
||||
action: 'attachment.restore',
|
||||
targetType: 'Attachment',
|
||||
targetId: id,
|
||||
clearanceAtTime: p.clearanceLevel,
|
||||
meta: { originalName: anhang.originalName },
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@ export function toAttachmentViews(
|
||||
width: number | null
|
||||
height: number | null
|
||||
createdAt: Date
|
||||
retractedAt: Date | null
|
||||
retractionReason: string | null
|
||||
}[],
|
||||
): AttachmentView[] {
|
||||
return rows.map((row) => ({
|
||||
@@ -35,5 +37,7 @@ export function toAttachmentViews(
|
||||
height: row.height,
|
||||
hasThumb: row.thumbKey !== null,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
retractedAt: row.retractedAt?.toISOString() ?? null,
|
||||
retractionReason: row.retractionReason,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -47,6 +47,8 @@ export const auditText = {
|
||||
'document.retract': 'Dokument zurückgezogen',
|
||||
'document.reclassify': 'Dokument umklassifiziert',
|
||||
'attachment.upload': 'Datei hochgeladen',
|
||||
'attachment.retract': 'Anhang zurückgezogen',
|
||||
'attachment.restore': 'Rücknahme aufgehoben',
|
||||
'attachment.read': 'Datei abgerufen',
|
||||
'attachment.release': 'Datei freigegeben',
|
||||
'chat.adopt': 'Nachricht als Vermerk übernommen',
|
||||
|
||||
@@ -69,6 +69,15 @@ export const attachmentText = {
|
||||
checking: 'Wird geprüft',
|
||||
download: 'Herunterladen',
|
||||
release: 'Trotzdem freigeben',
|
||||
retract: 'Zurückziehen',
|
||||
retracting: 'Wird zurückgezogen',
|
||||
restore: 'Rücknahme aufheben',
|
||||
retractPrompt:
|
||||
'Warum wird dieser Anhang zurückgezogen? Die Begründung steht für alle an der Datei, mindestens 10 Zeichen.',
|
||||
retractedBy: (datum: string, grund: string) =>
|
||||
`Zurückgezogen am ${datum} · Begründung: ${grund}`,
|
||||
retractedHint:
|
||||
'Die Datei wird nicht mehr ausgeliefert und verschwindet aus Lichtbildfeldern. Gelöscht ist sie nicht.',
|
||||
releaseHint:
|
||||
'Die Datei wurde nicht auf Schadsoftware geprüft. Sie wird beim Freigeben neu kodiert, soweit das geht.',
|
||||
quotaUsed: (used: string, total: string) => `${used} von ${total} belegt`,
|
||||
@@ -84,6 +93,8 @@ export const attachmentText = {
|
||||
'quota-exceeded': 'Dein Kontingent von 500 MB ist ausgeschöpft.',
|
||||
'rate-limited': 'Zu viele Uploads. In einer Minute erneut versuchen.',
|
||||
'release-not-possible': 'Nur eine unvollständige Prüfung lässt sich freigeben.',
|
||||
'already-retracted': 'Dieser Anhang ist bereits zurückgezogen.',
|
||||
'reason-required': 'Begründung nötig, mindestens 10 Zeichen.',
|
||||
transfer: 'Übertragung fehlgeschlagen. Erneut versuchen.',
|
||||
} as Record<string, string>,
|
||||
} as const
|
||||
|
||||
Reference in New Issue
Block a user