feat(dokumente): DOC-001 Duplikat-Erkennung + Fix Online-Ansicht (Popup-Blocker)
CI / backend-tests (push) Failing after 2m11s
CI / frontend-build (push) Successful in 20s

Duplikat-Erkennung: SHA-256-Hash je Dokument (Migration 0024), Upload wird
mit 409 abgelehnt, wenn dieselbe Datei bereits an derselben Entität liegt
(Vergleich bewusst pro Entität, nicht global - gleiche Datei an zwei
Objekten ist kein Duplikat). Frontend zeigt die Backend-Fehlermeldung
(Dateiname/Datum des bestehenden Dokuments) statt generischem Text.

Nebenbei gefunden+gefixt: "Ansehen" (PDF/JPG/PNG online statt Download)
funktionierte nicht zuverlässig, weil window.open() erst nach einem await
fetch() aufgerufen wurde - Popup-Blocker werten das nicht mehr als direkte
Nutzeraktion. Jetzt öffnet der Tab sofort synchron, die Blob-URL wird
nachgeladen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV
This commit is contained in:
2026-09-08 00:59:15 +02:00
co-authored by Claude Sonnet 5
parent 307914ccf7
commit 707701d897
7 changed files with 155 additions and 11 deletions
+26 -8
View File
@@ -111,17 +111,35 @@ export async function ladeDokumentHerunter(dokumentId: string, dateiname: string
// speichern). Auch hier: Auth-Header nötig, daher Blob statt einfachem
// <a href> auf den Download-Endpunkt. Object-URL wird bewusst NICHT sofort
// revoked (Tab braucht sie noch); der Browser räumt sie beim Tab-Schließen auf.
//
// window.open() MUSS synchron im Klick-Handler passieren, sonst greift die
// Popup-Blocker-Heuristik (window.open nach einem await gilt nicht mehr als
// direkte Nutzeraktion) - Tab wird also SOFORT leer geöffnet, die Blob-URL
// erst danach nachgeladen. Kein "noopener", weil sonst keine Referenz zum
// nachträglichen Setzen von location.href zurückkäme; unkritisch, da das
// Ziel eine selbst erzeugte same-origin blob:-URL ist, keine fremde Seite.
export async function ladeDokumentAnsehen(dokumentId: string): Promise<void> {
const headers: Record<string, string> = {};
if (authToken) headers["Authorization"] = `Bearer ${authToken}`;
const tab = window.open("", "_blank");
try {
const headers: Record<string, string> = {};
if (authToken) headers["Authorization"] = `Bearer ${authToken}`;
const response = await fetch(`${BASE_URL}/dokumente/${dokumentId}/download`, { headers });
if (!response.ok) {
throw new ApiError(response.status, null);
const response = await fetch(`${BASE_URL}/dokumente/${dokumentId}/download`, { headers });
if (!response.ok) {
throw new ApiError(response.status, null);
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
if (tab) {
tab.location.href = url;
} else {
// Popup wurde trotzdem blockiert - Fallback auf denselben Tab.
window.location.href = url;
}
} catch (fehler) {
tab?.close();
throw fehler;
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
window.open(url, "_blank", "noopener");
}
export async function login(username: string, password: string): Promise<string> {
+8 -3
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
import { apiRequest, ladeDokumentAnsehen, ladeDokumentHerunter, ladeDokumentHoch } from "../api/client";
import { ApiError, apiRequest, ladeDokumentAnsehen, ladeDokumentHerunter, ladeDokumentHoch } from "../api/client";
import { useAuth } from "../auth/AuthContext";
import type { Dokument } from "../api/types";
@@ -56,8 +56,13 @@ export function DokumentePanel({ entitaetTyp, entitaetId, onFehler }: Props) {
setBeschreibung("");
e.target.value = "";
await laden();
} catch {
onFehler("Datei konnte nicht hochgeladen werden (Typ erlaubt: PDF/JPEG/PNG/WebP, max. 25 MB).");
} catch (fehler) {
if (fehler instanceof ApiError && fehler.status === 409) {
const detail = (fehler.detail as { detail?: string } | null)?.detail;
onFehler(detail ?? "Diese Datei wurde hier bereits hochgeladen.");
} else {
onFehler("Datei konnte nicht hochgeladen werden (Typ erlaubt: PDF/JPEG/PNG/WebP, max. 25 MB).");
}
} finally {
setWirdHochgeladen(false);
}