feat: Generisches Dokument-Upload-System fuer Entscheidungen, Normen und Falldokumente
- Neues documents-Schema mit Mandantentrennung (tenantId), Kategorien (entscheidung/norm/falldokument/sonstiges) und optionaler Verknuepfung zu cases/decisions/normInstruments - Upload-Library (src/lib/documents/) mit Datei-Upload, PDF/DOCX-Textextraktion und gefilterten Listen - API-Route POST/GET /api/documents mit RBAC, Audit-Logging und asynchroner Textextraktion - Wiederverwendbare DokumentUpload-Komponente mit Drag-and-Drop, Fortschrittsanzeige und Dateiliste - Integration in Fall-Detailseite, Entscheidungs-Detailseite und Normen-Detailseite - Drizzle-Migration fuer documents-Tabelle mit RLS-konformer Mandantentrennung - DSGVO: 90-Tage Aufbewahrungsfrist fuer hochgeladene Dokumente Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
112
drizzle/0002_wide_grandmaster.sql
Normal file
112
drizzle/0002_wide_grandmaster.sql
Normal file
@@ -0,0 +1,112 @@
|
||||
CREATE TYPE "public"."compensation_component" AS ENUM('grundgage', 'ortszuschlag', 'kinderzuschlag', 'dienstalterszulage', 'funktionszulage', 'sonderzahlung', 'probenzuschlag');--> statement-breakpoint
|
||||
CREATE TYPE "public"."contract_status" AS ENUM('aktiv', 'gekuendigt', 'nichtverlaengert', 'ausgelaufen', 'ruhend');--> statement-breakpoint
|
||||
CREATE TYPE "public"."document_category" AS ENUM('entscheidung', 'norm', 'falldokument', 'sonstiges');--> statement-breakpoint
|
||||
CREATE TYPE "public"."document_status" AS ENUM('uploaded', 'extracting', 'extracted', 'failed');--> statement-breakpoint
|
||||
CREATE TABLE "compensation_rules" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"tenant_id" uuid,
|
||||
"fachgruppe_id" uuid,
|
||||
"component" "compensation_component" NOT NULL,
|
||||
"gagenklasse" varchar(50),
|
||||
"label" varchar(255) NOT NULL,
|
||||
"amount_cents" integer NOT NULL,
|
||||
"min_years_of_service" integer DEFAULT 0,
|
||||
"max_years_of_service" integer,
|
||||
"valid_from_spielzeit" varchar(20),
|
||||
"valid_to_spielzeit" varchar(20),
|
||||
"legal_basis" varchar(255),
|
||||
"description" text,
|
||||
"sort_order" integer DEFAULT 0,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "contracts" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"tenant_id" uuid NOT NULL,
|
||||
"case_id" uuid,
|
||||
"artist_name" varchar(255) NOT NULL,
|
||||
"theater_name" varchar(255) NOT NULL,
|
||||
"fachgruppe_id" uuid,
|
||||
"status" "contract_status" DEFAULT 'aktiv' NOT NULL,
|
||||
"contract_start" date NOT NULL,
|
||||
"contract_end" date NOT NULL,
|
||||
"years_of_service" integer DEFAULT 0 NOT NULL,
|
||||
"is_first_engagement" boolean DEFAULT false,
|
||||
"is_over_55" boolean DEFAULT false,
|
||||
"current_spielzeit" varchar(20),
|
||||
"gagenklasse" varchar(50),
|
||||
"monthly_gross_cents" integer,
|
||||
"notes" text,
|
||||
"metadata" jsonb,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "documents" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"tenant_id" uuid NOT NULL,
|
||||
"user_id" uuid NOT NULL,
|
||||
"category" "document_category" DEFAULT 'sonstiges' NOT NULL,
|
||||
"case_id" uuid,
|
||||
"decision_id" uuid,
|
||||
"norm_instrument_id" uuid,
|
||||
"filename" varchar(500) NOT NULL,
|
||||
"mime_type" varchar(100) NOT NULL,
|
||||
"file_size_bytes" integer NOT NULL,
|
||||
"storage_path" text NOT NULL,
|
||||
"extracted_text" text,
|
||||
"status" "document_status" DEFAULT 'uploaded' NOT NULL,
|
||||
"error_message" text,
|
||||
"metadata" jsonb,
|
||||
"delete_after" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "non_renewal_deadlines" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"contract_id" uuid NOT NULL,
|
||||
"spielzeit" varchar(20) NOT NULL,
|
||||
"deadline_date" date NOT NULL,
|
||||
"warning_date" date,
|
||||
"warning_days_before" integer DEFAULT 30,
|
||||
"notice_sent" boolean DEFAULT false,
|
||||
"notice_sent_date" date,
|
||||
"is_auto_renewed" boolean DEFAULT false,
|
||||
"legal_basis" varchar(255) NOT NULL,
|
||||
"calculation_basis" text,
|
||||
"protection_category" varchar(100),
|
||||
"notes" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "compensation_rules" ADD CONSTRAINT "compensation_rules_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "compensation_rules" ADD CONSTRAINT "compensation_rules_fachgruppe_id_nv_buehne_fachgruppen_id_fk" FOREIGN KEY ("fachgruppe_id") REFERENCES "public"."nv_buehne_fachgruppen"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "contracts" ADD CONSTRAINT "contracts_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "contracts" ADD CONSTRAINT "contracts_case_id_cases_id_fk" FOREIGN KEY ("case_id") REFERENCES "public"."cases"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "contracts" ADD CONSTRAINT "contracts_fachgruppe_id_nv_buehne_fachgruppen_id_fk" FOREIGN KEY ("fachgruppe_id") REFERENCES "public"."nv_buehne_fachgruppen"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "documents" ADD CONSTRAINT "documents_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "documents" ADD CONSTRAINT "documents_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "documents" ADD CONSTRAINT "documents_case_id_cases_id_fk" FOREIGN KEY ("case_id") REFERENCES "public"."cases"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "documents" ADD CONSTRAINT "documents_decision_id_decisions_id_fk" FOREIGN KEY ("decision_id") REFERENCES "public"."decisions"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "documents" ADD CONSTRAINT "documents_norm_instrument_id_norm_instruments_id_fk" FOREIGN KEY ("norm_instrument_id") REFERENCES "public"."norm_instruments"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "non_renewal_deadlines" ADD CONSTRAINT "non_renewal_deadlines_contract_id_contracts_id_fk" FOREIGN KEY ("contract_id") REFERENCES "public"."contracts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "compensation_rules_tenant_idx" ON "compensation_rules" USING btree ("tenant_id");--> statement-breakpoint
|
||||
CREATE INDEX "compensation_rules_fachgruppe_idx" ON "compensation_rules" USING btree ("fachgruppe_id");--> statement-breakpoint
|
||||
CREATE INDEX "compensation_rules_component_idx" ON "compensation_rules" USING btree ("component");--> statement-breakpoint
|
||||
CREATE INDEX "compensation_rules_gagenklasse_idx" ON "compensation_rules" USING btree ("gagenklasse");--> statement-breakpoint
|
||||
CREATE INDEX "contracts_tenant_idx" ON "contracts" USING btree ("tenant_id");--> statement-breakpoint
|
||||
CREATE INDEX "contracts_case_idx" ON "contracts" USING btree ("case_id");--> statement-breakpoint
|
||||
CREATE INDEX "contracts_fachgruppe_idx" ON "contracts" USING btree ("fachgruppe_id");--> statement-breakpoint
|
||||
CREATE INDEX "contracts_status_idx" ON "contracts" USING btree ("status");--> statement-breakpoint
|
||||
CREATE INDEX "contracts_end_idx" ON "contracts" USING btree ("contract_end");--> statement-breakpoint
|
||||
CREATE INDEX "documents_tenant_idx" ON "documents" USING btree ("tenant_id");--> statement-breakpoint
|
||||
CREATE INDEX "documents_case_idx" ON "documents" USING btree ("case_id");--> statement-breakpoint
|
||||
CREATE INDEX "documents_decision_idx" ON "documents" USING btree ("decision_id");--> statement-breakpoint
|
||||
CREATE INDEX "documents_norm_instrument_idx" ON "documents" USING btree ("norm_instrument_id");--> statement-breakpoint
|
||||
CREATE INDEX "documents_category_idx" ON "documents" USING btree ("category");--> statement-breakpoint
|
||||
CREATE INDEX "documents_status_idx" ON "documents" USING btree ("status");--> statement-breakpoint
|
||||
CREATE INDEX "documents_delete_after_idx" ON "documents" USING btree ("delete_after");--> statement-breakpoint
|
||||
CREATE INDEX "non_renewal_deadlines_contract_idx" ON "non_renewal_deadlines" USING btree ("contract_id");--> statement-breakpoint
|
||||
CREATE INDEX "non_renewal_deadlines_date_idx" ON "non_renewal_deadlines" USING btree ("deadline_date");--> statement-breakpoint
|
||||
CREATE INDEX "non_renewal_deadlines_spielzeit_idx" ON "non_renewal_deadlines" USING btree ("spielzeit");
|
||||
4023
drizzle/meta/0002_snapshot.json
Normal file
4023
drizzle/meta/0002_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,13 @@
|
||||
"when": 1775690117252,
|
||||
"tag": "0001_curved_fabian_cortez",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "7",
|
||||
"when": 1775729813628,
|
||||
"tag": "0002_wide_grandmaster",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { cases, analyses, proceedings } from '@/lib/db/schema';
|
||||
import { eq, desc } from 'drizzle-orm';
|
||||
import Link from 'next/link';
|
||||
import { notFound } from 'next/navigation';
|
||||
import DokumentUpload from '@/components/documents/dokument-upload';
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
active: 'Aktiv',
|
||||
@@ -126,6 +127,12 @@ export default async function CaseDetailPage({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DokumentUpload
|
||||
category="falldokument"
|
||||
caseId={id}
|
||||
label="Falldokument hochladen"
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div className="bg-card-bg border border-card-border rounded-xl p-6">
|
||||
<h2 className="text-sm font-semibold text-foreground mb-4">
|
||||
|
||||
@@ -3,6 +3,7 @@ import { decisions, decisionNorms, norms, normInstruments } from '@/lib/db/schem
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { notFound } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import DokumentUpload from '@/components/documents/dokument-upload';
|
||||
|
||||
export default async function EntscheidungDetailPage({
|
||||
params,
|
||||
@@ -71,6 +72,12 @@ export default async function EntscheidungDetailPage({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DokumentUpload
|
||||
category="entscheidung"
|
||||
decisionId={id}
|
||||
label="Entscheidungsdokument hochladen"
|
||||
/>
|
||||
|
||||
{appliedNorms.length > 0 && (
|
||||
<div className="bg-card-bg border border-card-border rounded-xl p-6">
|
||||
<h3 className="text-sm font-semibold text-foreground mb-3">Angewandte Normen</h3>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { normInstruments, norms } from '@/lib/db/schema';
|
||||
import { eq, asc } from 'drizzle-orm';
|
||||
import { notFound } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import DokumentUpload from '@/components/documents/dokument-upload';
|
||||
|
||||
const QUELLENRANG_LABELS: Record<string, string> = {
|
||||
gesetz: 'Gesetz',
|
||||
@@ -70,6 +71,12 @@ export default async function InstrumentDetailPage({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DokumentUpload
|
||||
category="norm"
|
||||
normInstrumentId={instrumentId}
|
||||
label="Normendokument hochladen"
|
||||
/>
|
||||
|
||||
{normList.length === 0 ? (
|
||||
<div className="bg-card-bg border border-card-border rounded-xl p-8 text-center">
|
||||
<p className="text-muted text-sm">Keine Vorschriften für dieses Regelwerk hinterlegt.</p>
|
||||
|
||||
97
src/app/api/documents/route.ts
Normal file
97
src/app/api/documents/route.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
// POST /api/documents — Upload a document (decision, norm, or case document)
|
||||
// GET /api/documents — List documents for the current tenant
|
||||
|
||||
import { type NextRequest } from 'next/server';
|
||||
import { uploadDocument, listDocuments, extractDocumentText } from '@/lib/documents';
|
||||
import { logAuditEvent } from '@/lib/auth/audit';
|
||||
import { requirePermission } from '@/lib/auth/rbac';
|
||||
|
||||
const VALID_CATEGORIES = new Set(['entscheidung', 'norm', 'falldokument', 'sonstiges']);
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const auth = await requirePermission('cases:edit');
|
||||
if ('response' in auth) return auth.response;
|
||||
const { ctx } = auth;
|
||||
|
||||
const formData = await request.formData();
|
||||
const file = formData.get('file');
|
||||
const category = (formData.get('category') as string) || 'sonstiges';
|
||||
const caseId = formData.get('caseId') as string | null;
|
||||
const decisionId = formData.get('decisionId') as string | null;
|
||||
const normInstrumentId = formData.get('normInstrumentId') as string | null;
|
||||
|
||||
if (!file || !(file instanceof File)) {
|
||||
return Response.json(
|
||||
{ error: 'Keine Datei hochgeladen. Feld "file" erwartet.' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (!VALID_CATEGORIES.has(category)) {
|
||||
return Response.json(
|
||||
{ error: `Ungueltige Kategorie. Erlaubt: ${[...VALID_CATEGORIES].join(', ')}` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
|
||||
?? request.headers.get('x-real-ip')
|
||||
?? undefined;
|
||||
|
||||
try {
|
||||
const result = await uploadDocument({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.userId,
|
||||
file,
|
||||
category: category as 'entscheidung' | 'norm' | 'falldokument' | 'sonstiges',
|
||||
caseId: caseId ?? undefined,
|
||||
decisionId: decisionId ?? undefined,
|
||||
normInstrumentId: normInstrumentId ?? undefined,
|
||||
});
|
||||
|
||||
await logAuditEvent(
|
||||
ctx, 'create', 'document', result.documentId,
|
||||
{ fileName: file.name, category, caseId, decisionId, normInstrumentId }, ip,
|
||||
);
|
||||
|
||||
// Trigger text extraction asynchronously (fire-and-forget)
|
||||
extractDocumentText(ctx.tenantId, result.documentId).catch(() => {
|
||||
// Extraction errors are stored in the document record
|
||||
});
|
||||
|
||||
return Response.json(result, { status: 201 });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Upload fehlgeschlagen';
|
||||
return Response.json({ error: message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const auth = await requirePermission('cases:read');
|
||||
if ('response' in auth) return auth.response;
|
||||
const { ctx } = auth;
|
||||
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const limit = Math.min(parseInt(searchParams.get('limit') ?? '20', 10), 100);
|
||||
const offset = parseInt(searchParams.get('offset') ?? '0', 10);
|
||||
const category = searchParams.get('category') as string | null;
|
||||
const caseId = searchParams.get('caseId') as string | null;
|
||||
const decisionId = searchParams.get('decisionId') as string | null;
|
||||
const normInstrumentId = searchParams.get('normInstrumentId') as string | null;
|
||||
|
||||
const docs = await listDocuments(
|
||||
ctx.tenantId,
|
||||
{
|
||||
category: category && VALID_CATEGORIES.has(category)
|
||||
? category as 'entscheidung' | 'norm' | 'falldokument' | 'sonstiges'
|
||||
: undefined,
|
||||
caseId: caseId ?? undefined,
|
||||
decisionId: decisionId ?? undefined,
|
||||
normInstrumentId: normInstrumentId ?? undefined,
|
||||
},
|
||||
limit,
|
||||
offset,
|
||||
);
|
||||
|
||||
return Response.json(docs);
|
||||
}
|
||||
206
src/components/documents/dokument-upload.tsx
Normal file
206
src/components/documents/dokument-upload.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
|
||||
interface DokumentUploadProps {
|
||||
category: 'entscheidung' | 'norm' | 'falldokument' | 'sonstiges';
|
||||
/** Optional linked entity ID */
|
||||
caseId?: string;
|
||||
decisionId?: string;
|
||||
normInstrumentId?: string;
|
||||
/** Label shown above the upload form */
|
||||
label?: string;
|
||||
}
|
||||
|
||||
interface DocumentItem {
|
||||
id: string;
|
||||
filename: string;
|
||||
mimeType: string;
|
||||
fileSizeBytes: number;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
uploaded: 'Hochgeladen',
|
||||
extracting: 'Text wird extrahiert...',
|
||||
extracted: 'Extrahiert',
|
||||
failed: 'Fehlgeschlagen',
|
||||
};
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
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 DokumentUpload({
|
||||
category,
|
||||
caseId,
|
||||
decisionId,
|
||||
normInstrumentId,
|
||||
label = 'Dokument hochladen',
|
||||
}: DokumentUploadProps) {
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
const [documents, setDocuments] = useState<DocumentItem[]>([]);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const fetchDocuments = useCallback(async () => {
|
||||
const params = new URLSearchParams({ category });
|
||||
if (caseId) params.set('caseId', caseId);
|
||||
if (decisionId) params.set('decisionId', decisionId);
|
||||
if (normInstrumentId) params.set('normInstrumentId', normInstrumentId);
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/documents?${params}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setDocuments(data);
|
||||
}
|
||||
} catch {
|
||||
// Silently fail on list fetch
|
||||
}
|
||||
}, [category, caseId, decisionId, normInstrumentId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchDocuments();
|
||||
}, [fetchDocuments]);
|
||||
|
||||
async function doUpload(file: File) {
|
||||
setError('');
|
||||
setSuccess('');
|
||||
setUploading(true);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('category', category);
|
||||
if (caseId) formData.append('caseId', caseId);
|
||||
if (decisionId) formData.append('decisionId', decisionId);
|
||||
if (normInstrumentId) formData.append('normInstrumentId', normInstrumentId);
|
||||
|
||||
const res = await fetch('/api/documents', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.error || 'Upload fehlgeschlagen');
|
||||
}
|
||||
|
||||
setSuccess(`"${file.name}" erfolgreich hochgeladen.`);
|
||||
if (fileRef.current) fileRef.current.value = '';
|
||||
fetchDocuments();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const file = fileRef.current?.files?.[0];
|
||||
if (file) doUpload(file);
|
||||
}
|
||||
|
||||
function handleDrop(e: React.DragEvent) {
|
||||
e.preventDefault();
|
||||
setDragging(false);
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (file) doUpload(file);
|
||||
}
|
||||
|
||||
function handleDragOver(e: React.DragEvent) {
|
||||
e.preventDefault();
|
||||
setDragging(true);
|
||||
}
|
||||
|
||||
function handleDragLeave(e: React.DragEvent) {
|
||||
e.preventDefault();
|
||||
setDragging(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
className={`bg-card-bg border rounded-xl p-5 transition-colors ${
|
||||
dragging ? 'border-primary border-dashed bg-primary/5' : 'border-card-border'
|
||||
}`}
|
||||
>
|
||||
<h3 className="text-sm font-semibold text-foreground mb-3">{label}</h3>
|
||||
<div className="flex gap-3 items-end">
|
||||
<div className="flex-1">
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept=".pdf,.docx"
|
||||
required
|
||||
className="block w-full text-sm text-muted file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-medium file:bg-primary/10 file:text-primary hover:file:bg-primary/20"
|
||||
/>
|
||||
<p className="text-xs text-muted mt-1">
|
||||
PDF oder DOCX, max. 10 MB. Drag & Drop wird unterstuetzt.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={uploading}
|
||||
className="px-4 py-2.5 bg-primary text-white rounded-lg text-sm font-medium hover:bg-primary-light transition-colors disabled:opacity-50 shrink-0"
|
||||
>
|
||||
{uploading ? 'Wird hochgeladen...' : 'Hochladen'}
|
||||
</button>
|
||||
</div>
|
||||
{error && <p className="text-sm text-danger mt-2">{error}</p>}
|
||||
{success && <p className="text-sm text-success mt-2">{success}</p>}
|
||||
</form>
|
||||
|
||||
{documents.length > 0 && (
|
||||
<div className="bg-card-bg border border-card-border rounded-xl p-5">
|
||||
<h3 className="text-sm font-semibold text-foreground mb-3">
|
||||
Dokumente ({documents.length})
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{documents.map((doc) => (
|
||||
<div
|
||||
key={doc.id}
|
||||
className="flex items-center justify-between p-3 rounded-lg border border-card-border"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-foreground truncate">
|
||||
{doc.filename}
|
||||
</p>
|
||||
<p className="text-xs text-muted">
|
||||
{formatFileSize(doc.fileSizeBytes)} ·{' '}
|
||||
{new Date(doc.createdAt).toLocaleDateString('de-DE')}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className={`text-xs px-2 py-0.5 rounded-full font-medium shrink-0 ml-3 ${
|
||||
STATUS_COLORS[doc.status] ?? 'bg-gray-500/10 text-gray-600'
|
||||
}`}
|
||||
>
|
||||
{STATUS_LABELS[doc.status] ?? doc.status}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -892,6 +892,75 @@ export const nonRenewalDeadlines = pgTable(
|
||||
],
|
||||
);
|
||||
|
||||
// ============================================================
|
||||
// Dokumente (Generic Document Upload — Phase 3.4)
|
||||
// ============================================================
|
||||
|
||||
/** Document category — what kind of document this is */
|
||||
export const documentCategoryEnum = pgEnum("document_category", [
|
||||
"entscheidung", // Court decision / arbitration award document
|
||||
"norm", // Legal norm / statute document
|
||||
"falldokument", // Case-related document
|
||||
"sonstiges", // Other / miscellaneous
|
||||
]);
|
||||
|
||||
/** Document processing status */
|
||||
export const documentStatusEnum = pgEnum("document_status", [
|
||||
"uploaded",
|
||||
"extracting",
|
||||
"extracted",
|
||||
"failed",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Dokumente — generic uploaded documents for decisions, norms, and cases.
|
||||
* Tenant-isolated via RLS. Files stored in /uploads/{tenantId}/documents/...
|
||||
*/
|
||||
export const documents = pgTable(
|
||||
"documents",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
tenantId: uuid("tenant_id").notNull().references(() => tenants.id, { onDelete: "cascade" }),
|
||||
userId: uuid("user_id").notNull().references(() => users.id),
|
||||
/** Document category */
|
||||
category: documentCategoryEnum("category").notNull().default("sonstiges"),
|
||||
/** Optional link to a case */
|
||||
caseId: uuid("case_id").references(() => cases.id, { onDelete: "set null" }),
|
||||
/** Optional link to a decision */
|
||||
decisionId: uuid("decision_id").references(() => decisions.id, { onDelete: "set null" }),
|
||||
/** Optional link to a norm instrument */
|
||||
normInstrumentId: uuid("norm_instrument_id").references(() => normInstruments.id, { onDelete: "set null" }),
|
||||
/** Original filename */
|
||||
filename: varchar("filename", { length: 500 }).notNull(),
|
||||
/** MIME type */
|
||||
mimeType: varchar("mime_type", { length: 100 }).notNull(),
|
||||
/** File size in bytes */
|
||||
fileSizeBytes: integer("file_size_bytes").notNull(),
|
||||
/** Storage path (absolute on filesystem) */
|
||||
storagePath: text("storage_path").notNull(),
|
||||
/** Extracted plain text from the document */
|
||||
extractedText: text("extracted_text"),
|
||||
/** Processing status */
|
||||
status: documentStatusEnum("status").notNull().default("uploaded"),
|
||||
/** Error message if processing failed */
|
||||
errorMessage: text("error_message"),
|
||||
metadata: jsonb("metadata").$type<Record<string, unknown>>(),
|
||||
/** DSGVO: scheduled deletion date */
|
||||
deleteAfter: timestamp("delete_after", { withTimezone: true }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(t) => [
|
||||
index("documents_tenant_idx").on(t.tenantId),
|
||||
index("documents_case_idx").on(t.caseId),
|
||||
index("documents_decision_idx").on(t.decisionId),
|
||||
index("documents_norm_instrument_idx").on(t.normInstrumentId),
|
||||
index("documents_category_idx").on(t.category),
|
||||
index("documents_status_idx").on(t.status),
|
||||
index("documents_delete_after_idx").on(t.deleteAfter),
|
||||
],
|
||||
);
|
||||
|
||||
// ============================================================
|
||||
// DSGVO / Audit
|
||||
// ============================================================
|
||||
@@ -1037,3 +1106,11 @@ export const compensationRulesRelations = relations(compensationRules, ({ one })
|
||||
export const nonRenewalDeadlinesRelations = relations(nonRenewalDeadlines, ({ one }) => ({
|
||||
contract: one(contracts, { fields: [nonRenewalDeadlines.contractId], references: [contracts.id] }),
|
||||
}));
|
||||
|
||||
export const documentsRelations = relations(documents, ({ one }) => ({
|
||||
tenant: one(tenants, { fields: [documents.tenantId], references: [tenants.id] }),
|
||||
case: one(cases, { fields: [documents.caseId], references: [cases.id] }),
|
||||
decision: one(decisions, { fields: [documents.decisionId], references: [decisions.id] }),
|
||||
normInstrument: one(normInstruments, { fields: [documents.normInstrumentId], references: [normInstruments.id] }),
|
||||
user: one(users, { fields: [documents.userId], references: [users.id] }),
|
||||
}));
|
||||
|
||||
224
src/lib/documents/index.ts
Normal file
224
src/lib/documents/index.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
// Generic Document Upload Module — upload, text extraction, listing
|
||||
// Handles PDF/DOCX for decisions, norms, and case documents
|
||||
// All DB access uses withTenantDb() for RLS-based tenant isolation (DSGVO Art. 32)
|
||||
|
||||
import { withTenantDb } from '@/lib/db';
|
||||
import { documents } from '@/lib/db/schema';
|
||||
import { eq, and, desc } from 'drizzle-orm';
|
||||
|
||||
const ALLOWED_MIME_TYPES = new Set([
|
||||
'application/pdf',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
]);
|
||||
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
|
||||
|
||||
type DocumentCategory = 'entscheidung' | 'norm' | 'falldokument' | 'sonstiges';
|
||||
|
||||
interface UploadOptions {
|
||||
tenantId: string;
|
||||
userId: string;
|
||||
file: File;
|
||||
category: DocumentCategory;
|
||||
caseId?: string;
|
||||
decisionId?: string;
|
||||
normInstrumentId?: string;
|
||||
}
|
||||
|
||||
interface UploadResult {
|
||||
documentId: string;
|
||||
filename: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and store a document upload.
|
||||
* Text extraction happens asynchronously via extractDocumentText().
|
||||
*/
|
||||
export async function uploadDocument(opts: UploadOptions): Promise<UploadResult> {
|
||||
const { tenantId, userId, file, category, caseId, decisionId, normInstrumentId } = opts;
|
||||
|
||||
if (!ALLOWED_MIME_TYPES.has(file.type)) {
|
||||
throw new Error(
|
||||
`Ungueltiger Dateityp: ${file.type}. Erlaubt sind PDF und DOCX.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
throw new Error(
|
||||
`Datei zu gross: ${(file.size / 1024 / 1024).toFixed(1)} MB. Maximum: ${MAX_FILE_SIZE / 1024 / 1024} MB.`,
|
||||
);
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
|
||||
const fs = await import('node:fs/promises');
|
||||
const path = await import('node:path');
|
||||
const uploadDir = path.join(process.cwd(), 'uploads', tenantId, 'documents', category);
|
||||
await fs.mkdir(uploadDir, { recursive: true });
|
||||
const filePath = path.join(uploadDir, `${Date.now()}-${file.name}`);
|
||||
await fs.writeFile(filePath, buffer);
|
||||
|
||||
// DSGVO: default retention — 90 days from upload
|
||||
const deleteAfter = new Date();
|
||||
deleteAfter.setDate(deleteAfter.getDate() + 90);
|
||||
|
||||
const doc = await withTenantDb(tenantId, async (tdb) => {
|
||||
const [d] = await tdb
|
||||
.insert(documents)
|
||||
.values({
|
||||
tenantId,
|
||||
userId,
|
||||
category,
|
||||
caseId: caseId ?? null,
|
||||
decisionId: decisionId ?? null,
|
||||
normInstrumentId: normInstrumentId ?? null,
|
||||
filename: file.name,
|
||||
mimeType: file.type,
|
||||
fileSizeBytes: file.size,
|
||||
storagePath: filePath,
|
||||
status: 'uploaded',
|
||||
deleteAfter,
|
||||
})
|
||||
.returning();
|
||||
return d;
|
||||
});
|
||||
|
||||
return {
|
||||
documentId: doc.id,
|
||||
filename: doc.filename,
|
||||
status: doc.status,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract text from a document (PDF or DOCX).
|
||||
* Updates the document record with extracted text.
|
||||
*/
|
||||
export async function extractDocumentText(tenantId: string, documentId: string): Promise<string> {
|
||||
const doc = await withTenantDb(tenantId, async (tdb) => {
|
||||
const [d] = await tdb
|
||||
.select()
|
||||
.from(documents)
|
||||
.where(eq(documents.id, documentId))
|
||||
.limit(1);
|
||||
return d ?? null;
|
||||
});
|
||||
|
||||
if (!doc) throw new Error('Dokument nicht gefunden');
|
||||
|
||||
await withTenantDb(tenantId, async (tdb) => {
|
||||
await tdb
|
||||
.update(documents)
|
||||
.set({ status: 'extracting', updatedAt: new Date() })
|
||||
.where(eq(documents.id, documentId));
|
||||
});
|
||||
|
||||
try {
|
||||
const fs = await import('node:fs/promises');
|
||||
const fileBuffer = await fs.readFile(doc.storagePath);
|
||||
|
||||
let text: string;
|
||||
|
||||
if (doc.mimeType === 'application/pdf') {
|
||||
const pdfParse = (await import('pdf-parse')).default;
|
||||
const pdfData = await pdfParse(fileBuffer);
|
||||
text = pdfData.text;
|
||||
} else {
|
||||
const mammoth = await import('mammoth');
|
||||
const result = await mammoth.extractRawText({ buffer: fileBuffer });
|
||||
text = result.value;
|
||||
}
|
||||
|
||||
await withTenantDb(tenantId, async (tdb) => {
|
||||
await tdb
|
||||
.update(documents)
|
||||
.set({
|
||||
extractedText: text,
|
||||
status: 'extracted',
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(documents.id, documentId));
|
||||
});
|
||||
|
||||
return text;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Textextraktion fehlgeschlagen';
|
||||
await withTenantDb(tenantId, async (tdb) => {
|
||||
await tdb
|
||||
.update(documents)
|
||||
.set({
|
||||
status: 'failed',
|
||||
errorMessage: message,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(documents.id, documentId));
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List documents for a tenant, optionally filtered by category or linked entity.
|
||||
*/
|
||||
export async function listDocuments(
|
||||
tenantId: string,
|
||||
filters?: {
|
||||
category?: DocumentCategory;
|
||||
caseId?: string;
|
||||
decisionId?: string;
|
||||
normInstrumentId?: string;
|
||||
},
|
||||
limit = 20,
|
||||
offset = 0,
|
||||
) {
|
||||
return withTenantDb(tenantId, async (tdb) => {
|
||||
const conditions = [eq(documents.tenantId, tenantId)];
|
||||
|
||||
if (filters?.category) {
|
||||
conditions.push(eq(documents.category, filters.category));
|
||||
}
|
||||
if (filters?.caseId) {
|
||||
conditions.push(eq(documents.caseId, filters.caseId));
|
||||
}
|
||||
if (filters?.decisionId) {
|
||||
conditions.push(eq(documents.decisionId, filters.decisionId));
|
||||
}
|
||||
if (filters?.normInstrumentId) {
|
||||
conditions.push(eq(documents.normInstrumentId, filters.normInstrumentId));
|
||||
}
|
||||
|
||||
return tdb
|
||||
.select({
|
||||
id: documents.id,
|
||||
filename: documents.filename,
|
||||
mimeType: documents.mimeType,
|
||||
fileSizeBytes: documents.fileSizeBytes,
|
||||
category: documents.category,
|
||||
status: documents.status,
|
||||
caseId: documents.caseId,
|
||||
decisionId: documents.decisionId,
|
||||
normInstrumentId: documents.normInstrumentId,
|
||||
createdAt: documents.createdAt,
|
||||
})
|
||||
.from(documents)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(documents.createdAt))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single document by ID.
|
||||
*/
|
||||
export async function getDocument(tenantId: string, documentId: string) {
|
||||
return withTenantDb(tenantId, async (tdb) => {
|
||||
const [doc] = await tdb
|
||||
.select()
|
||||
.from(documents)
|
||||
.where(eq(documents.id, documentId))
|
||||
.limit(1);
|
||||
return doc ?? null;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user