(null);
+
+ async function handleView(doc: DocumentItem) {
+ if (doc.status !== 'extracted') return;
+ setLoading(doc.id);
+ try {
+ const res = await fetch(`/api/documents/${doc.id}?action=view`);
+ if (!res.ok) throw new Error('Laden fehlgeschlagen');
+ const data = await res.json();
+ setViewingDoc({
+ id: doc.id,
+ filename: doc.filename,
+ text: data.extractedText || 'Kein Text verfuegbar.',
+ });
+ } catch {
+ alert('Dokument konnte nicht geladen werden.');
+ } finally {
+ setLoading(null);
+ }
+ }
+
+ function handleCategoryChange(cat: string) {
+ const params = new URLSearchParams();
+ if (cat !== 'all') params.set('category', cat);
+ router.push(`/dokumente?${params}`);
+ }
+
+ return (
+ <>
+ {/* Category filter */}
+
+ {['all', ...Object.keys(categoryLabels)].map((cat) => (
+
+ ))}
+
+
+ {/* Document list */}
+ {documents.length === 0 ? (
+
+
Keine Dokumente gefunden.
+
+ ) : (
+
+ {documents.map((doc) => (
+
+
+
+
{doc.filename}
+
+ {formatFileSize(doc.fileSizeBytes)}
+ {' · '}
+ {categoryLabels[doc.category] ?? doc.category}
+ {' · '}
+ {new Date(doc.createdAt).toLocaleDateString('de-DE', {
+ day: '2-digit',
+ month: '2-digit',
+ year: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit',
+ })}
+
+
+
+
+ {STATUS_LABELS[doc.status] ?? doc.status}
+
+
+
+ {doc.status === 'extracted' && (
+
+ )}
+
+ Herunterladen
+
+ {doc.status === 'extracted' && (
+
+ Als Text
+
+ )}
+
+
+
+ {doc.status === 'failed' && doc.errorMessage && (
+
+ {doc.errorMessage}
+
+ )}
+
+ ))}
+
+ )}
+
+ {/* Pagination */}
+
+ {currentPage > 1 && (
+
+ )}
+ {documents.length === 20 && (
+
+ )}
+
+
+ {/* Document viewer modal */}
+ {viewingDoc && (
+
+
+
+
{viewingDoc.filename}
+
+
+
+
+ {viewingDoc.text}
+
+
+
+
+ )}
+ >
+ );
+}
diff --git a/src/app/(dashboard)/dokumente/page.tsx b/src/app/(dashboard)/dokumente/page.tsx
new file mode 100644
index 0000000..d6e9400
--- /dev/null
+++ b/src/app/(dashboard)/dokumente/page.tsx
@@ -0,0 +1,69 @@
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth';
+import { db } from '@/lib/db';
+import { documents } from '@/lib/db/schema';
+import { desc, eq, and, ilike } from 'drizzle-orm';
+import DokumenteArchiv from './dokumente-archiv';
+
+const CATEGORY_LABELS: Record = {
+ entscheidung: 'Entscheidung',
+ norm: 'Norm',
+ falldokument: 'Falldokument',
+ sonstiges: 'Sonstiges',
+};
+
+export default async function DokumentePage({
+ searchParams,
+}: {
+ searchParams: Promise<{ category?: string; page?: string }>;
+}) {
+ const session = await getServerSession(authOptions);
+ const tenantId = session!.user.tenantId;
+ const { category, page } = await searchParams;
+ const currentPage = parseInt(page ?? '1', 10);
+ const pageSize = 20;
+ const offset = (currentPage - 1) * pageSize;
+
+ const conditions = [eq(documents.tenantId, tenantId)];
+ if (category && category !== 'all') {
+ conditions.push(eq(documents.category, category as any));
+ }
+
+ const docs = await db
+ .select({
+ id: documents.id,
+ filename: documents.filename,
+ mimeType: documents.mimeType,
+ fileSizeBytes: documents.fileSizeBytes,
+ category: documents.category,
+ sourceScope: documents.sourceScope,
+ status: documents.status,
+ errorMessage: documents.errorMessage,
+ caseId: documents.caseId,
+ createdAt: documents.createdAt,
+ updatedAt: documents.updatedAt,
+ })
+ .from(documents)
+ .where(and(...conditions))
+ .orderBy(desc(documents.createdAt))
+ .limit(pageSize)
+ .offset(offset);
+
+ return (
+
+
+
Dokumentenarchiv
+
+ Alle hochgeladenen Dokumente einsehen, herunterladen und exportieren.
+
+
+
+
+
+ );
+}
diff --git a/src/app/api/analyses/[id]/route.ts b/src/app/api/analyses/[id]/route.ts
index 73a3cd0..43a5356 100644
--- a/src/app/api/analyses/[id]/route.ts
+++ b/src/app/api/analyses/[id]/route.ts
@@ -1,25 +1,25 @@
// GET /api/analyses/:id — Retrieve a single analysis with its sources
+// Supports ?action=export-txt for plain-text export
import { type NextRequest } from 'next/server';
import { withTenantDb } from '@/lib/db';
import { analyses, norms, normInstruments, decisions } from '@/lib/db/schema';
import { eq, inArray } from 'drizzle-orm';
import { logAuditEvent } from '@/lib/auth/audit';
+import { requirePermission } from '@/lib/auth/rbac';
import type { TenantContext } from '@/lib/auth';
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
+ const auth = await requirePermission('analyses:read');
+ if ('response' in auth) return auth.response;
+ const { ctx } = auth;
const { id } = await params;
- const tenantId = request.headers.get('x-tenant-id');
- if (!tenantId) {
- return Response.json({ error: 'Missing x-tenant-id header' }, { status: 401 });
- }
-
// RLS enforces tenant isolation — only rows matching app.tenant_id are visible.
- const result = await withTenantDb(tenantId, async (tdb) => {
+ const result = await withTenantDb(ctx.tenantId, async (tdb) => {
const [analysis] = await tdb
.select()
.from(analyses)
@@ -72,14 +72,33 @@ export async function GET(
return Response.json({ error: 'Analysis not found' }, { status: 404 });
}
- const userId = request.headers.get('x-user-id') ?? 'unknown';
const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
?? request.headers.get('x-real-ip')
?? undefined;
- await logAuditEvent(
- { tenantId, userId } as TenantContext,
- 'read', 'analysis', id, undefined, ip,
- );
+
+ const action = request.nextUrl.searchParams.get('action');
+
+ // Export analysis as plain text
+ if (action === 'export-txt') {
+ if (!result.analysis.result) {
+ return Response.json(
+ { error: 'Analyse hat noch kein Ergebnis.' },
+ { status: 400 },
+ );
+ }
+
+ await logAuditEvent(ctx, 'export', 'analysis', id, { format: 'txt' }, ip);
+
+ const safeTitle = (result.analysis.title || 'analyse').replace(/[^a-zA-Z0-9äöüÄÖÜß_-]/g, '_');
+ return new Response(result.analysis.result, {
+ headers: {
+ 'Content-Type': 'text/plain; charset=utf-8',
+ 'Content-Disposition': `attachment; filename="${encodeURIComponent(safeTitle)}.txt"`,
+ },
+ });
+ }
+
+ await logAuditEvent(ctx, 'read', 'analysis', id, undefined, ip);
return Response.json({
...result.analysis,
diff --git a/src/app/api/documents/[id]/route.ts b/src/app/api/documents/[id]/route.ts
index bbd0aa5..289dbeb 100644
--- a/src/app/api/documents/[id]/route.ts
+++ b/src/app/api/documents/[id]/route.ts
@@ -1,4 +1,4 @@
-// GET /api/documents/:id — get document status and metadata (used for polling)
+// GET /api/documents/:id — get document metadata, or download/export the file
// DELETE /api/documents/:id — delete a document and its stored file
import { type NextRequest } from 'next/server';
@@ -22,6 +22,72 @@ export async function GET(
return Response.json({ error: 'Dokument nicht gefunden.' }, { status: 404 });
}
+ const action = request.nextUrl.searchParams.get('action');
+
+ // Download the original file
+ if (action === 'download') {
+ const fs = await import('node:fs/promises');
+ try {
+ await fs.access(doc.storagePath);
+ } catch {
+ return Response.json({ error: 'Datei nicht gefunden.' }, { status: 404 });
+ }
+ const fileBuffer = await fs.readFile(doc.storagePath);
+
+ const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
+ ?? request.headers.get('x-real-ip')
+ ?? undefined;
+ await logAuditEvent(ctx, 'download', 'document', id, { filename: doc.filename }, ip);
+
+ return new Response(fileBuffer, {
+ headers: {
+ 'Content-Type': doc.mimeType,
+ 'Content-Disposition': `attachment; filename="${encodeURIComponent(doc.filename)}"`,
+ 'Content-Length': String(fileBuffer.length),
+ },
+ });
+ }
+
+ // Export as plain text (extracted text)
+ if (action === 'export-txt') {
+ if (!doc.extractedText) {
+ return Response.json(
+ { error: 'Kein extrahierter Text verfuegbar. Status: ' + doc.status },
+ { status: 400 },
+ );
+ }
+
+ const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
+ ?? request.headers.get('x-real-ip')
+ ?? undefined;
+ await logAuditEvent(ctx, 'export', 'document', id, { format: 'txt', filename: doc.filename }, ip);
+
+ const baseName = doc.filename.replace(/\.[^.]+$/, '');
+ return new Response(doc.extractedText, {
+ headers: {
+ 'Content-Type': 'text/plain; charset=utf-8',
+ 'Content-Disposition': `attachment; filename="${encodeURIComponent(baseName)}.txt"`,
+ },
+ });
+ }
+
+ // View extracted text (inline, for the UI viewer)
+ if (action === 'view') {
+ return Response.json({
+ id: doc.id,
+ filename: doc.filename,
+ mimeType: doc.mimeType,
+ fileSizeBytes: doc.fileSizeBytes,
+ category: doc.category,
+ status: doc.status,
+ errorMessage: doc.errorMessage,
+ extractedText: doc.extractedText ?? null,
+ createdAt: doc.createdAt,
+ updatedAt: doc.updatedAt,
+ });
+ }
+
+ // Default: metadata only
return Response.json({
id: doc.id,
filename: doc.filename,
diff --git a/src/components/documents/dokument-upload.tsx b/src/components/documents/dokument-upload.tsx
index 63f0fb3..d685b71 100644
--- a/src/components/documents/dokument-upload.tsx
+++ b/src/components/documents/dokument-upload.tsx
@@ -332,6 +332,22 @@ export default function DokumentUpload({
>
{STATUS_LABELS[doc.status] ?? doc.status}
+
+ ↓
+
+ {doc.status === 'extracted' && (
+
+ TXT
+
+ )}