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:
CTO (LegalAI)
2026-04-09 10:18:56 +00:00
parent a8124fa6b9
commit 7b1407268b
10 changed files with 4767 additions and 0 deletions

View 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");

File diff suppressed because it is too large Load Diff

View File

@@ -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
}
]
}

View File

@@ -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">

View File

@@ -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>

View File

@@ -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>

View 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);
}

View 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 &amp; 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)} &middot;{' '}
{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>
);
}

View File

@@ -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
View 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;
});
}