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

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:
Harlekin
2026-09-04 16:18:33 +02:00
parent ad3bf48d2c
commit 42dbf0f6b1
11 changed files with 566 additions and 10 deletions
+98 -4
View File
@@ -3,7 +3,11 @@
import { useCallback, useRef, useState, useTransition } from 'react'
import { attachmentText } from '@/lib/i18n/documents'
import { MAX_FILE_BYTES, formatBytes } from '@/lib/files/limits'
import { releaseAttachmentAction } from '@/app/(app)/dateien/actions'
import {
releaseAttachmentAction,
restoreAttachmentAction,
retractAttachmentAction,
} from '@/app/(app)/dateien/actions'
import { useStream, type ScanEvent } from '@/components/realtime/useStream'
export type AttachmentView = {
@@ -18,6 +22,8 @@ export type AttachmentView = {
height: number | null
hasThumb: boolean
createdAt: string
retractedAt: string | null
retractionReason: string | null
}
/** Welchem Besitzer die Datei angehängt wird. Genau eines der drei Felder. */
@@ -49,8 +55,14 @@ export function Attachments({
topic: string
initial: AttachmentView[]
canUpload: boolean
/**
* Wer anhängen darf, darf auch zurücknehmen - und die eigene Datei ohnehin.
* Die endgültige Prüfung steht im Dienst; hier entscheidet sie nur, ob der
* Knopf sichtbar ist.
*/
canRelease: boolean
}) {
const [fehler, setFehler] = useState<string | null>(null)
const [dateien, setDateien] = useState<AttachmentView[]>(initial)
const [laufend, setLaufend] = useState<Laufend[]>([])
const [pending, startTransition] = useTransition()
@@ -125,6 +137,8 @@ export function Attachments({
height: null,
hasThumb: false,
createdAt: new Date().toISOString(),
retractedAt: null,
retractionReason: null,
},
])
fertig()
@@ -158,7 +172,7 @@ export function Attachments({
{dateien.map((datei) => (
<li key={datei.id} className="border-b border-line py-2">
<div className="flex flex-wrap items-center gap-x-4 gap-y-1">
{datei.hasThumb ? (
{datei.hasThumb && !datei.retractedAt ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={`/api/files/${datei.id}?variant=thumb`}
@@ -166,7 +180,13 @@ export function Attachments({
className="h-10 w-10 border border-line object-cover"
/>
) : null}
<span className="grow text-sm">{datei.originalName}</span>
<span
className={`grow text-sm ${
datei.retractedAt ? 'text-text-dim line-through' : ''
}`}
>
{datei.originalName}
</span>
<span className="font-mono text-2xs text-text-dim">
{formatBytes(datei.sizeBytes)}
</span>
@@ -177,7 +197,7 @@ export function Attachments({
>
{attachmentText.statusNames[datei.scanStatus] ?? datei.scanStatus}
</span>
{datei.scanStatus === 'CLEAN' ? (
{datei.scanStatus === 'CLEAN' && !datei.retractedAt ? (
<a href={`/api/files/${datei.id}`} className="font-condensed text-2xs">
{attachmentText.download}
</a>
@@ -197,7 +217,79 @@ export function Attachments({
{attachmentText.release}
</button>
) : null}
{/* Zurückziehen statt Löschen: die Datei bleibt an ihrer
Stelle stehen, mit Begründung. Siehe README, Zusicherung 2. */}
{datei.retractedAt ? (
<button
type="button"
disabled={pending}
onClick={() =>
startTransition(async () => {
setFehler(null)
const ergebnis = await restoreAttachmentAction({ id: datei.id })
if (ergebnis.ok) {
setDateien((bisher) =>
bisher.map((d) =>
d.id === datei.id
? { ...d, retractedAt: null, retractionReason: null }
: d,
),
)
} else {
setFehler(ergebnis.error)
}
})
}
className="btn btn-sm"
>
{attachmentText.restore}
</button>
) : (
<button
type="button"
disabled={pending}
onClick={() => {
const grund = prompt(attachmentText.retractPrompt)?.trim()
if (!grund) return
startTransition(async () => {
setFehler(null)
const ergebnis = await retractAttachmentAction({
id: datei.id,
reason: grund,
})
if (ergebnis.ok) {
setDateien((bisher) =>
bisher.map((d) =>
d.id === datei.id
? {
...d,
retractedAt: new Date().toISOString(),
retractionReason: grund,
}
: d,
),
)
} else {
setFehler(ergebnis.error)
}
})
}}
className="btn btn-sm"
>
{pending ? attachmentText.retracting : attachmentText.retract}
</button>
)}
</div>
{datei.retractedAt ? (
<p className="mt-1 text-2xs text-text-dim">
{attachmentText.retractedBy(
new Date(datei.retractedAt).toLocaleDateString('de-DE'),
datei.retractionReason ?? '',
)}
</p>
) : null}
{datei.scanMessage ? (
<p className="mt-1 text-2xs text-text-dim">{datei.scanMessage}</p>
) : null}
@@ -225,6 +317,8 @@ export function Attachments({
</ul>
)}
{fehler ? <p className="mt-2 text-2xs text-stamp-4">{fehler}</p> : null}
{canUpload ? (
<div className="mt-4">
<input
+24 -2
View File
@@ -68,7 +68,15 @@ export async function CaseSheet({
db.attachment.findMany({
where: { AND: [visibleAttachmentWhere(permissions), { caseId: akte.id }] },
orderBy: { createdAt: 'asc' },
select: { id: true, originalName: true, sha256: true, sizeBytes: true, scanStatus: true },
select: {
id: true,
originalName: true,
sha256: true,
sizeBytes: true,
scanStatus: true,
retractedAt: true,
retractionReason: true,
},
}),
db.clearanceLevelConfig.findMany({ orderBy: { level: 'asc' } }),
authors ? [] : db.user.findMany({ select: { id: true, displayName: true } }),
@@ -171,7 +179,21 @@ export async function CaseSheet({
<tbody>
{anhaenge.map((anhang) => (
<tr key={anhang.id}>
<td>{anhang.originalName}</td>
{/* Ein zurückgezogener Anhang bleibt im Verzeichnis stehen,
mit Grund. Ihn wegzulassen hiesse, den Ausdruck sauberer zu
machen als die Akte - siehe docs/08. */}
<td>
{anhang.originalName}
{anhang.retractedAt ? (
<span className="print-note">
{' '}
{de.attachment.retractedBy(
anhang.retractedAt.toLocaleDateString('de-DE'),
anhang.retractionReason ?? '',
)}
</span>
) : null}
</td>
<td>{anhang.sizeBytes}</td>
{/* Der Hash macht nachprüfbar, dass die Datei zum Ausdruck
gehört - deshalb steht er im Verzeichnis. */}