diff --git a/src/app/(dashboard)/analyse/[id]/analyse-detail.tsx b/src/app/(dashboard)/analyse/[id]/analyse-detail.tsx new file mode 100644 index 0000000..7971786 --- /dev/null +++ b/src/app/(dashboard)/analyse/[id]/analyse-detail.tsx @@ -0,0 +1,37 @@ +'use client'; + +interface Props { + analysisId: string; + result: string | null; + title: string; +} + +export default function AnalyseDetail({ analysisId, result, title }: Props) { + return ( +
+
+ {result && ( + + Als Text exportieren + + )} +
+ + {result ? ( +
+

Ergebnis

+
+ {result} +
+
+ ) : ( +
+

Noch kein Ergebnis verfuegbar.

+
+ )} +
+ ); +} diff --git a/src/app/(dashboard)/analyse/[id]/page.tsx b/src/app/(dashboard)/analyse/[id]/page.tsx new file mode 100644 index 0000000..f8eb7b7 --- /dev/null +++ b/src/app/(dashboard)/analyse/[id]/page.tsx @@ -0,0 +1,88 @@ +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { withTenantDb } from '@/lib/db'; +import { analyses } from '@/lib/db/schema'; +import { eq } from 'drizzle-orm'; +import { notFound } from 'next/navigation'; +import Link from 'next/link'; +import AnalyseDetail from './analyse-detail'; + +const MODE_LABELS: Record = { + gutachten: 'Gutachten', + entscheidung: 'Entscheidungsprognose', + vergleich: 'Vergleichsanalyse', + risiko: 'Risikoanalyse', +}; + +const STATUS_LABELS: Record = { + draft: 'Entwurf', + in_progress: 'In Bearbeitung', + completed: 'Abgeschlossen', + archived: 'Archiviert', +}; + +export default async function AnalyseDetailPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const session = await getServerSession(authOptions); + const tenantId = session!.user.tenantId; + const { id } = await params; + + const analysis = await withTenantDb(tenantId, async (tdb) => { + const [a] = await tdb + .select() + .from(analyses) + .where(eq(analyses.id, id)) + .limit(1); + return a ?? null; + }); + + if (!analysis) notFound(); + + return ( +
+
+ + ← Zurueck + +
+ +
+
+

{analysis.title}

+

+ {MODE_LABELS[analysis.mode] ?? analysis.mode} + {' · '} + {STATUS_LABELS[analysis.status] ?? analysis.status} + {' · '} + {new Date(analysis.createdAt).toLocaleDateString('de-DE', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + })} +

+
+
+ + {analysis.query && ( +
+

Fragestellung

+

{analysis.query}

+
+ )} + + +
+ ); +} diff --git a/src/app/(dashboard)/analyse/page.tsx b/src/app/(dashboard)/analyse/page.tsx index 03b37a2..b1b5823 100644 --- a/src/app/(dashboard)/analyse/page.tsx +++ b/src/app/(dashboard)/analyse/page.tsx @@ -89,7 +89,7 @@ export default async function AnalysePage() {

Bisherige Analysen

{recentAnalyses.map((a) => ( -
+

{a.title || 'Ohne Titel'}

@@ -99,7 +99,7 @@ export default async function AnalysePage() { {new Date(a.createdAt).toLocaleDateString('de-DE')} -

+ ))}
diff --git a/src/app/(dashboard)/dokumente/dokumente-archiv.tsx b/src/app/(dashboard)/dokumente/dokumente-archiv.tsx new file mode 100644 index 0000000..14bffc3 --- /dev/null +++ b/src/app/(dashboard)/dokumente/dokumente-archiv.tsx @@ -0,0 +1,235 @@ +'use client'; + +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; + +interface DocumentItem { + id: string; + filename: string; + mimeType: string; + fileSizeBytes: number; + category: string; + sourceScope: string; + status: string; + errorMessage: string | null; + caseId: string | null; + createdAt: Date | string; + updatedAt: Date | string; +} + +interface Props { + documents: DocumentItem[]; + currentCategory: string; + currentPage: number; + categoryLabels: Record; +} + +const STATUS_LABELS: Record = { + uploaded: 'Hochgeladen', + extracting: 'Extrahiere...', + extracted: 'Extrahiert', + failed: 'Fehlgeschlagen', +}; + +const STATUS_COLORS: Record = { + uploaded: 'bg-blue-500/10 text-blue-700', + extracting: 'bg-yellow-500/10 text-yellow-700', + extracted: 'bg-green-500/10 text-green-700', + failed: 'bg-red-500/10 text-red-700', +}; + +function formatFileSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`; + return `${(bytes / 1024 / 1024).toFixed(1)} MB`; +} + +export default function DokumenteArchiv({ documents, currentCategory, currentPage, categoryLabels }: Props) { + const router = useRouter(); + const [viewingDoc, setViewingDoc] = useState<{ id: string; filename: string; text: string } | null>(null); + const [loading, setLoading] = useState(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}

+
+ + Original herunterladen + + + Als Text exportieren + + +
+
+
+
+                {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 + + )}