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.
348 lines
12 KiB
TypeScript
348 lines
12 KiB
TypeScript
'use client'
|
|
|
|
import { useCallback, useRef, useState, useTransition } from 'react'
|
|
import { attachmentText } from '@/lib/i18n/documents'
|
|
import { MAX_FILE_BYTES, formatBytes } from '@/lib/files/limits'
|
|
import {
|
|
releaseAttachmentAction,
|
|
restoreAttachmentAction,
|
|
retractAttachmentAction,
|
|
} from '@/app/(app)/dateien/actions'
|
|
import { useStream, type ScanEvent } from '@/components/realtime/useStream'
|
|
|
|
export type AttachmentView = {
|
|
id: string
|
|
originalName: string
|
|
mimeType: string
|
|
sizeBytes: number
|
|
scanStatus: string
|
|
scanMessage: string | null
|
|
blurhash: string | null
|
|
width: number | null
|
|
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. */
|
|
export type UploadTarget = {
|
|
caseNumber?: string
|
|
entryId?: string
|
|
documentNumber?: string
|
|
}
|
|
|
|
type Laufend = { key: string; name: string; progress: number; error?: string }
|
|
|
|
/**
|
|
* Anhänge einer Akte, eines Eintrags oder eines Dokuments.
|
|
*
|
|
* Der Ablauf folgt docs/09-performance-und-ux.md: die Datei erscheint sofort
|
|
* in der Liste, der Fortschrittsbalken zeigt nur die **Übertragung**, und
|
|
* danach steht „Wird geprüft", bis das Ereignis aus `/api/events` kommt. Kein
|
|
* Balken wartet auf einen Virenscanner, und kein Spinner blockiert die Seite.
|
|
*/
|
|
export function Attachments({
|
|
target,
|
|
topic,
|
|
initial,
|
|
canUpload,
|
|
canRelease,
|
|
}: {
|
|
target: UploadTarget
|
|
/** Thema des Besitzers, damit die Prüfergebnisse ankommen. */
|
|
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()
|
|
const eingabe = useRef<HTMLInputElement>(null)
|
|
|
|
const uebernehmen = useCallback((event: ScanEvent) => {
|
|
setDateien((bisher) =>
|
|
bisher.map((datei) =>
|
|
datei.id === event.id
|
|
? {
|
|
...datei,
|
|
scanStatus: event.scanStatus,
|
|
scanMessage: event.scanMessage,
|
|
mimeType: event.mimeType,
|
|
sizeBytes: event.sizeBytes,
|
|
blurhash: event.blurhash,
|
|
width: event.width,
|
|
height: event.height,
|
|
hasThumb: event.hasThumb,
|
|
}
|
|
: datei,
|
|
),
|
|
)
|
|
}, [])
|
|
|
|
useStream([topic], { 'attachment.scanned': (data) => uebernehmen(data as ScanEvent) })
|
|
|
|
function hochladen(datei: File) {
|
|
const key = `${datei.name}-${Date.now()}-${Math.random()}`
|
|
if (datei.size > MAX_FILE_BYTES) {
|
|
setLaufend((l) => [
|
|
...l,
|
|
{ key, name: datei.name, progress: 0, error: attachmentText.errors['file-too-large'] },
|
|
])
|
|
return
|
|
}
|
|
|
|
setLaufend((l) => [...l, { key, name: datei.name, progress: 0 }])
|
|
|
|
const form = new FormData()
|
|
form.set('file', datei)
|
|
for (const [feld, wert] of Object.entries(target)) {
|
|
if (wert) form.set(feld, wert)
|
|
}
|
|
|
|
// XMLHttpRequest und nicht fetch: nur so gibt es einen echten
|
|
// Fortschritt beim Senden. `fetch` meldet ihn bis heute nicht.
|
|
const anfrage = new XMLHttpRequest()
|
|
anfrage.open('POST', '/api/files')
|
|
|
|
anfrage.upload.addEventListener('progress', (event) => {
|
|
if (!event.lengthComputable) return
|
|
const anteil = Math.round((event.loaded / event.total) * 100)
|
|
setLaufend((l) => l.map((e) => (e.key === key ? { ...e, progress: anteil } : e)))
|
|
})
|
|
|
|
anfrage.addEventListener('load', () => {
|
|
const fertig = () => setLaufend((l) => l.filter((e) => e.key !== key))
|
|
if (anfrage.status === 201) {
|
|
const { id } = JSON.parse(anfrage.responseText) as { id: string }
|
|
setDateien((bisher) => [
|
|
...bisher,
|
|
{
|
|
id,
|
|
originalName: datei.name,
|
|
mimeType: datei.type || 'application/octet-stream',
|
|
sizeBytes: datei.size,
|
|
scanStatus: 'PENDING',
|
|
scanMessage: null,
|
|
blurhash: null,
|
|
width: null,
|
|
height: null,
|
|
hasThumb: false,
|
|
createdAt: new Date().toISOString(),
|
|
retractedAt: null,
|
|
retractionReason: null,
|
|
},
|
|
])
|
|
fertig()
|
|
return
|
|
}
|
|
|
|
let meldung = attachmentText.errors.transfer!
|
|
try {
|
|
meldung = (JSON.parse(anfrage.responseText) as { error?: string }).error ?? meldung
|
|
} catch {
|
|
// Antwort ohne JSON, dann bleibt die allgemeine Meldung.
|
|
}
|
|
setLaufend((l) => l.map((e) => (e.key === key ? { ...e, error: meldung } : e)))
|
|
})
|
|
|
|
anfrage.addEventListener('error', () => {
|
|
setLaufend((l) =>
|
|
l.map((e) => (e.key === key ? { ...e, error: attachmentText.errors.transfer! } : e)),
|
|
)
|
|
})
|
|
|
|
anfrage.send(form)
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
{dateien.length === 0 && laufend.length === 0 ? (
|
|
<p className="text-text-dim">{attachmentText.empty}</p>
|
|
) : (
|
|
<ul className="border-t border-line">
|
|
{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.retractedAt ? (
|
|
// eslint-disable-next-line @next/next/no-img-element
|
|
<img
|
|
src={`/api/files/${datei.id}?variant=thumb`}
|
|
alt=""
|
|
className="h-10 w-10 border border-line object-cover"
|
|
/>
|
|
) : null}
|
|
<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>
|
|
<span
|
|
className={`font-condensed text-2xs ${
|
|
datei.scanStatus === 'BLOCKED' ? 'text-stamp-4' : 'text-text-dim'
|
|
}`}
|
|
>
|
|
{attachmentText.statusNames[datei.scanStatus] ?? datei.scanStatus}
|
|
</span>
|
|
{datei.scanStatus === 'CLEAN' && !datei.retractedAt ? (
|
|
<a href={`/api/files/${datei.id}`} className="font-condensed text-2xs">
|
|
{attachmentText.download}
|
|
</a>
|
|
) : null}
|
|
{datei.scanStatus === 'FAILED' && canRelease ? (
|
|
<button
|
|
type="button"
|
|
disabled={pending}
|
|
title={attachmentText.releaseHint}
|
|
onClick={() =>
|
|
startTransition(async () => {
|
|
await releaseAttachmentAction({ id: datei.id })
|
|
})
|
|
}
|
|
className="btn btn-sm text-stamp-3"
|
|
>
|
|
{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}
|
|
</li>
|
|
))}
|
|
|
|
{laufend.map((eintrag) => (
|
|
<li key={eintrag.key} className="border-b border-line py-2">
|
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-1">
|
|
<span className="grow text-sm text-text-dim">{eintrag.name}</span>
|
|
<span className="font-condensed text-2xs text-text-dim">
|
|
{eintrag.error ?? `${attachmentText.uploading} ${eintrag.progress} %`}
|
|
</span>
|
|
</div>
|
|
{!eintrag.error ? (
|
|
<div className="mt-1 h-0.5 w-full bg-raised">
|
|
<div
|
|
className="h-full bg-accent transition-[width] duration-150"
|
|
style={{ width: `${eintrag.progress}%` }}
|
|
/>
|
|
</div>
|
|
) : null}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
|
|
{fehler ? <p className="mt-2 text-2xs text-stamp-4">{fehler}</p> : null}
|
|
|
|
{canUpload ? (
|
|
<div className="mt-4">
|
|
<input
|
|
ref={eingabe}
|
|
type="file"
|
|
multiple
|
|
accept="image/png,image/jpeg,image/webp,application/pdf,text/plain"
|
|
onChange={(event) => {
|
|
for (const datei of event.target.files ?? []) hochladen(datei)
|
|
event.target.value = ''
|
|
}}
|
|
className="hidden"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => eingabe.current?.click()}
|
|
className="btn btn-sm btn-primary"
|
|
>
|
|
{attachmentText.add}
|
|
</button>
|
|
<p className="mt-1 text-2xs text-text-dim">{attachmentText.allowed}</p>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
)
|
|
}
|