diff --git a/drizzle/0001_curved_fabian_cortez.sql b/drizzle/0001_curved_fabian_cortez.sql
new file mode 100644
index 0000000..262dea4
--- /dev/null
+++ b/drizzle/0001_curved_fabian_cortez.sql
@@ -0,0 +1,148 @@
+CREATE TYPE "public"."clause_rating" AS ENUM('standard', 'abweichend', 'kritisch', 'unbekannt');--> statement-breakpoint
+CREATE TYPE "public"."contract_doc_status" AS ENUM('uploaded', 'extracting', 'extracted', 'analyzing', 'completed', 'failed');--> statement-breakpoint
+CREATE TYPE "public"."deadline_type" AS ENUM('frist', 'termin', 'vorfrist');--> statement-breakpoint
+CREATE TYPE "public"."proceeding_status" AS ENUM('vorbereitung', 'eingereicht', 'laufend', 'verhandlung', 'entschieden', 'abgeschlossen', 'ruht');--> statement-breakpoint
+CREATE TYPE "public"."proceeding_step_status" AS ENUM('ausstehend', 'aktiv', 'abgeschlossen', 'uebersprungen');--> statement-breakpoint
+CREATE TYPE "public"."proceeding_type" AS ENUM('bschgo_bezirk', 'bschgo_bund', 'arbgg_erste_instanz', 'arbgg_berufung', 'arbgg_revision');--> statement-breakpoint
+CREATE TABLE "contract_clauses" (
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
+ "document_id" uuid NOT NULL,
+ "category" varchar(200) NOT NULL,
+ "extracted_text" text NOT NULL,
+ "position_start" integer,
+ "position_end" integer,
+ "standard_clause_id" uuid,
+ "rating" "clause_rating" DEFAULT 'unbekannt' NOT NULL,
+ "analysis" text,
+ "deviations" jsonb,
+ "risk_score" integer,
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL
+);
+--> statement-breakpoint
+CREATE TABLE "contract_documents" (
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
+ "tenant_id" uuid NOT NULL,
+ "case_id" uuid,
+ "user_id" uuid NOT NULL,
+ "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" "contract_doc_status" DEFAULT 'uploaded' NOT NULL,
+ "error_message" text,
+ "fachgruppe_id" uuid,
+ "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 "proceeding_deadlines" (
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
+ "proceeding_id" uuid NOT NULL,
+ "step_id" uuid,
+ "type" "deadline_type" DEFAULT 'frist' NOT NULL,
+ "label" varchar(255) NOT NULL,
+ "description" text,
+ "due_date" date NOT NULL,
+ "due_time" varchar(10),
+ "warning_date" date,
+ "warning_days_before" integer,
+ "is_completed" boolean DEFAULT false,
+ "completed_at" timestamp with time zone,
+ "is_calculated" boolean DEFAULT false,
+ "calculation_basis" text,
+ "legal_basis" varchar(255),
+ "notes" text,
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL
+);
+--> statement-breakpoint
+CREATE TABLE "proceeding_steps" (
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
+ "proceeding_id" uuid NOT NULL,
+ "step_key" varchar(100) NOT NULL,
+ "label" varchar(255) NOT NULL,
+ "description" text,
+ "sort_order" integer DEFAULT 0 NOT NULL,
+ "status" "proceeding_step_status" DEFAULT 'ausstehend' NOT NULL,
+ "legal_basis" varchar(255),
+ "responsible_party" varchar(255),
+ "completed_at" timestamp with time zone,
+ "notes" text,
+ "metadata" jsonb,
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL
+);
+--> statement-breakpoint
+CREATE TABLE "proceedings" (
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
+ "tenant_id" uuid NOT NULL,
+ "case_id" uuid,
+ "type" "proceeding_type" NOT NULL,
+ "status" "proceeding_status" DEFAULT 'vorbereitung' NOT NULL,
+ "filing_date" date,
+ "internal_ref" varchar(100),
+ "external_ref" varchar(100),
+ "tribunal_id" uuid,
+ "court_name" varchar(255),
+ "chamber" varchar(100),
+ "presiding_judge" varchar(255),
+ "applicant" varchar(255),
+ "respondent" varchar(255),
+ "subject" text,
+ "amount_in_dispute_cents" integer,
+ "fachgruppe_id" uuid,
+ "current_step_key" varchar(100),
+ "notes" text,
+ "metadata" jsonb,
+ "closed_at" 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 "standard_clauses" (
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
+ "instrument_id" uuid NOT NULL,
+ "category" varchar(200) NOT NULL,
+ "label" varchar(500) NOT NULL,
+ "body" text NOT NULL,
+ "fachgruppe_ids" jsonb,
+ "norm_id" uuid,
+ "sort_order" integer DEFAULT 0,
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL
+);
+--> statement-breakpoint
+ALTER TABLE "contract_clauses" ADD CONSTRAINT "contract_clauses_document_id_contract_documents_id_fk" FOREIGN KEY ("document_id") REFERENCES "public"."contract_documents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "contract_clauses" ADD CONSTRAINT "contract_clauses_standard_clause_id_standard_clauses_id_fk" FOREIGN KEY ("standard_clause_id") REFERENCES "public"."standard_clauses"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "contract_documents" ADD CONSTRAINT "contract_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 "contract_documents" ADD CONSTRAINT "contract_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 "contract_documents" ADD CONSTRAINT "contract_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 "contract_documents" ADD CONSTRAINT "contract_documents_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 "proceeding_deadlines" ADD CONSTRAINT "proceeding_deadlines_proceeding_id_proceedings_id_fk" FOREIGN KEY ("proceeding_id") REFERENCES "public"."proceedings"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "proceeding_deadlines" ADD CONSTRAINT "proceeding_deadlines_step_id_proceeding_steps_id_fk" FOREIGN KEY ("step_id") REFERENCES "public"."proceeding_steps"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "proceeding_steps" ADD CONSTRAINT "proceeding_steps_proceeding_id_proceedings_id_fk" FOREIGN KEY ("proceeding_id") REFERENCES "public"."proceedings"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "proceedings" ADD CONSTRAINT "proceedings_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "proceedings" ADD CONSTRAINT "proceedings_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 "proceedings" ADD CONSTRAINT "proceedings_tribunal_id_arbitration_tribunals_id_fk" FOREIGN KEY ("tribunal_id") REFERENCES "public"."arbitration_tribunals"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "proceedings" ADD CONSTRAINT "proceedings_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 "standard_clauses" ADD CONSTRAINT "standard_clauses_instrument_id_norm_instruments_id_fk" FOREIGN KEY ("instrument_id") REFERENCES "public"."norm_instruments"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "standard_clauses" ADD CONSTRAINT "standard_clauses_norm_id_norms_id_fk" FOREIGN KEY ("norm_id") REFERENCES "public"."norms"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
+CREATE INDEX "contract_clauses_doc_idx" ON "contract_clauses" USING btree ("document_id");--> statement-breakpoint
+CREATE INDEX "contract_clauses_category_idx" ON "contract_clauses" USING btree ("category");--> statement-breakpoint
+CREATE INDEX "contract_clauses_rating_idx" ON "contract_clauses" USING btree ("rating");--> statement-breakpoint
+CREATE INDEX "contract_docs_tenant_idx" ON "contract_documents" USING btree ("tenant_id");--> statement-breakpoint
+CREATE INDEX "contract_docs_case_idx" ON "contract_documents" USING btree ("case_id");--> statement-breakpoint
+CREATE INDEX "contract_docs_status_idx" ON "contract_documents" USING btree ("status");--> statement-breakpoint
+CREATE INDEX "contract_docs_delete_after_idx" ON "contract_documents" USING btree ("delete_after");--> statement-breakpoint
+CREATE INDEX "proceeding_deadlines_proceeding_idx" ON "proceeding_deadlines" USING btree ("proceeding_id");--> statement-breakpoint
+CREATE INDEX "proceeding_deadlines_step_idx" ON "proceeding_deadlines" USING btree ("step_id");--> statement-breakpoint
+CREATE INDEX "proceeding_deadlines_due_date_idx" ON "proceeding_deadlines" USING btree ("due_date");--> statement-breakpoint
+CREATE INDEX "proceeding_deadlines_warning_idx" ON "proceeding_deadlines" USING btree ("warning_date");--> statement-breakpoint
+CREATE INDEX "proceeding_steps_proceeding_idx" ON "proceeding_steps" USING btree ("proceeding_id");--> statement-breakpoint
+CREATE UNIQUE INDEX "proceeding_steps_key_idx" ON "proceeding_steps" USING btree ("proceeding_id","step_key");--> statement-breakpoint
+CREATE INDEX "proceedings_tenant_idx" ON "proceedings" USING btree ("tenant_id");--> statement-breakpoint
+CREATE INDEX "proceedings_case_idx" ON "proceedings" USING btree ("case_id");--> statement-breakpoint
+CREATE INDEX "proceedings_type_idx" ON "proceedings" USING btree ("type");--> statement-breakpoint
+CREATE INDEX "proceedings_status_idx" ON "proceedings" USING btree ("status");--> statement-breakpoint
+CREATE INDEX "standard_clauses_instrument_idx" ON "standard_clauses" USING btree ("instrument_id");--> statement-breakpoint
+CREATE INDEX "standard_clauses_category_idx" ON "standard_clauses" USING btree ("category");
\ No newline at end of file
diff --git a/drizzle/0002_contract_analysis.sql b/drizzle/0002_contract_analysis.sql
new file mode 100644
index 0000000..7d861a4
--- /dev/null
+++ b/drizzle/0002_contract_analysis.sql
@@ -0,0 +1,87 @@
+-- Phase 3.3: Contract Analysis Module (Vertragsanalyse)
+-- Adds contract document upload, clause extraction, and standard clause comparison
+
+-- Enums
+CREATE TYPE contract_doc_status AS ENUM ('uploaded', 'extracting', 'extracted', 'analyzing', 'completed', 'failed');
+CREATE TYPE clause_rating AS ENUM ('standard', 'abweichend', 'kritisch', 'unbekannt');
+
+-- Contract documents table
+CREATE TABLE contract_documents (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
+ case_id UUID REFERENCES cases(id) ON DELETE SET NULL,
+ user_id UUID NOT NULL REFERENCES users(id),
+ 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 contract_doc_status NOT NULL DEFAULT 'uploaded',
+ error_message TEXT,
+ fachgruppe_id UUID REFERENCES nv_buehne_fachgruppen(id),
+ metadata JSONB,
+ delete_after TIMESTAMPTZ,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+CREATE INDEX contract_docs_tenant_idx ON contract_documents(tenant_id);
+CREATE INDEX contract_docs_case_idx ON contract_documents(case_id);
+CREATE INDEX contract_docs_status_idx ON contract_documents(status);
+CREATE INDEX contract_docs_delete_after_idx ON contract_documents(delete_after);
+
+-- Standard clauses (reference data from NV Bühne etc.)
+CREATE TABLE standard_clauses (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ instrument_id UUID NOT NULL REFERENCES norm_instruments(id) ON DELETE CASCADE,
+ category VARCHAR(200) NOT NULL,
+ label VARCHAR(500) NOT NULL,
+ body TEXT NOT NULL,
+ fachgruppe_ids JSONB,
+ norm_id UUID REFERENCES norms(id),
+ sort_order INTEGER DEFAULT 0,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+CREATE INDEX standard_clauses_instrument_idx ON standard_clauses(instrument_id);
+CREATE INDEX standard_clauses_category_idx ON standard_clauses(category);
+
+-- Contract clauses (extracted from uploaded documents)
+CREATE TABLE contract_clauses (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ document_id UUID NOT NULL REFERENCES contract_documents(id) ON DELETE CASCADE,
+ category VARCHAR(200) NOT NULL,
+ extracted_text TEXT NOT NULL,
+ position_start INTEGER,
+ position_end INTEGER,
+ standard_clause_id UUID REFERENCES standard_clauses(id),
+ rating clause_rating NOT NULL DEFAULT 'unbekannt',
+ analysis TEXT,
+ deviations JSONB,
+ risk_score INTEGER,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+CREATE INDEX contract_clauses_doc_idx ON contract_clauses(document_id);
+CREATE INDEX contract_clauses_category_idx ON contract_clauses(category);
+CREATE INDEX contract_clauses_rating_idx ON contract_clauses(rating);
+
+-- RLS policies for contract_documents
+ALTER TABLE contract_documents ENABLE ROW LEVEL SECURITY;
+
+CREATE POLICY contract_documents_tenant_isolation ON contract_documents
+ USING (tenant_id = current_setting('app.tenant_id')::uuid);
+
+CREATE POLICY contract_documents_tenant_insert ON contract_documents
+ FOR INSERT WITH CHECK (tenant_id = current_setting('app.tenant_id')::uuid);
+
+-- RLS policies for contract_clauses (via document join)
+ALTER TABLE contract_clauses ENABLE ROW LEVEL SECURITY;
+
+CREATE POLICY contract_clauses_tenant_isolation ON contract_clauses
+ USING (document_id IN (
+ SELECT id FROM contract_documents
+ WHERE tenant_id = current_setting('app.tenant_id')::uuid
+ ));
+
+-- Standard clauses are shared reference data (no RLS needed)
diff --git a/drizzle/meta/0001_snapshot.json b/drizzle/meta/0001_snapshot.json
new file mode 100644
index 0000000..9898771
--- /dev/null
+++ b/drizzle/meta/0001_snapshot.json
@@ -0,0 +1,3070 @@
+{
+ "id": "e62c26f7-4b34-4c87-9cce-db34b9789f60",
+ "prevId": "5d49fd29-b748-4be9-a6f3-418dca5d85f3",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.analyses": {
+ "name": "analyses",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "case_id": {
+ "name": "case_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "mode": {
+ "name": "mode",
+ "type": "analysis_mode",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "analysis_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'draft'"
+ },
+ "title": {
+ "name": "title",
+ "type": "varchar(500)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "query": {
+ "name": "query",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "result": {
+ "name": "result",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "sources": {
+ "name": "sources",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ai_provider": {
+ "name": "ai_provider",
+ "type": "varchar(50)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ai_model": {
+ "name": "ai_model",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "token_usage": {
+ "name": "token_usage",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "analyses_tenant_idx": {
+ "name": "analyses_tenant_idx",
+ "columns": [
+ {
+ "expression": "tenant_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "analyses_case_idx": {
+ "name": "analyses_case_idx",
+ "columns": [
+ {
+ "expression": "case_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "analyses_mode_idx": {
+ "name": "analyses_mode_idx",
+ "columns": [
+ {
+ "expression": "mode",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "analyses_tenant_id_tenants_id_fk": {
+ "name": "analyses_tenant_id_tenants_id_fk",
+ "tableFrom": "analyses",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "analyses_case_id_cases_id_fk": {
+ "name": "analyses_case_id_cases_id_fk",
+ "tableFrom": "analyses",
+ "tableTo": "cases",
+ "columnsFrom": [
+ "case_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "analyses_user_id_users_id_fk": {
+ "name": "analyses_user_id_users_id_fk",
+ "tableFrom": "analyses",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.arbitration_tribunals": {
+ "name": "arbitration_tribunals",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "level": {
+ "name": "level",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "seat": {
+ "name": "seat",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "bschgo_ref": {
+ "name": "bschgo_ref",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.audit_log": {
+ "name": "audit_log",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "action": {
+ "name": "action",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "entity_type": {
+ "name": "entity_type",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "entity_id": {
+ "name": "entity_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "details": {
+ "name": "details",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ip_address": {
+ "name": "ip_address",
+ "type": "varchar(45)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "audit_log_tenant_idx": {
+ "name": "audit_log_tenant_idx",
+ "columns": [
+ {
+ "expression": "tenant_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "audit_log_entity_idx": {
+ "name": "audit_log_entity_idx",
+ "columns": [
+ {
+ "expression": "entity_type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "entity_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "audit_log_created_idx": {
+ "name": "audit_log_created_idx",
+ "columns": [
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "audit_log_tenant_id_tenants_id_fk": {
+ "name": "audit_log_tenant_id_tenants_id_fk",
+ "tableFrom": "audit_log",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "audit_log_user_id_users_id_fk": {
+ "name": "audit_log_user_id_users_id_fk",
+ "tableFrom": "audit_log",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.cases": {
+ "name": "cases",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "case_number": {
+ "name": "case_number",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "varchar(500)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "client_name": {
+ "name": "client_name",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "opposing_party": {
+ "name": "opposing_party",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "venue": {
+ "name": "venue",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "fachgruppe_id": {
+ "name": "fachgruppe_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "domains": {
+ "name": "domains",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'[]'::jsonb"
+ },
+ "status": {
+ "name": "status",
+ "type": "varchar(50)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'active'"
+ },
+ "filing_date": {
+ "name": "filing_date",
+ "type": "date",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "hearing_date": {
+ "name": "hearing_date",
+ "type": "date",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "closed_at": {
+ "name": "closed_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "cases_tenant_number_idx": {
+ "name": "cases_tenant_number_idx",
+ "columns": [
+ {
+ "expression": "tenant_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "case_number",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "cases_tenant_idx": {
+ "name": "cases_tenant_idx",
+ "columns": [
+ {
+ "expression": "tenant_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "cases_status_idx": {
+ "name": "cases_status_idx",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "cases_tenant_id_tenants_id_fk": {
+ "name": "cases_tenant_id_tenants_id_fk",
+ "tableFrom": "cases",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "cases_fachgruppe_id_nv_buehne_fachgruppen_id_fk": {
+ "name": "cases_fachgruppe_id_nv_buehne_fachgruppen_id_fk",
+ "tableFrom": "cases",
+ "tableTo": "nv_buehne_fachgruppen",
+ "columnsFrom": [
+ "fachgruppe_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.contract_clauses": {
+ "name": "contract_clauses",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "document_id": {
+ "name": "document_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "category": {
+ "name": "category",
+ "type": "varchar(200)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "extracted_text": {
+ "name": "extracted_text",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "position_start": {
+ "name": "position_start",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "position_end": {
+ "name": "position_end",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "standard_clause_id": {
+ "name": "standard_clause_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "rating": {
+ "name": "rating",
+ "type": "clause_rating",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'unbekannt'"
+ },
+ "analysis": {
+ "name": "analysis",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "deviations": {
+ "name": "deviations",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "risk_score": {
+ "name": "risk_score",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "contract_clauses_doc_idx": {
+ "name": "contract_clauses_doc_idx",
+ "columns": [
+ {
+ "expression": "document_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "contract_clauses_category_idx": {
+ "name": "contract_clauses_category_idx",
+ "columns": [
+ {
+ "expression": "category",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "contract_clauses_rating_idx": {
+ "name": "contract_clauses_rating_idx",
+ "columns": [
+ {
+ "expression": "rating",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "contract_clauses_document_id_contract_documents_id_fk": {
+ "name": "contract_clauses_document_id_contract_documents_id_fk",
+ "tableFrom": "contract_clauses",
+ "tableTo": "contract_documents",
+ "columnsFrom": [
+ "document_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "contract_clauses_standard_clause_id_standard_clauses_id_fk": {
+ "name": "contract_clauses_standard_clause_id_standard_clauses_id_fk",
+ "tableFrom": "contract_clauses",
+ "tableTo": "standard_clauses",
+ "columnsFrom": [
+ "standard_clause_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.contract_documents": {
+ "name": "contract_documents",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "case_id": {
+ "name": "case_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "filename": {
+ "name": "filename",
+ "type": "varchar(500)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "mime_type": {
+ "name": "mime_type",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "file_size_bytes": {
+ "name": "file_size_bytes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "storage_path": {
+ "name": "storage_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "extracted_text": {
+ "name": "extracted_text",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "contract_doc_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'uploaded'"
+ },
+ "error_message": {
+ "name": "error_message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "fachgruppe_id": {
+ "name": "fachgruppe_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "delete_after": {
+ "name": "delete_after",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "contract_docs_tenant_idx": {
+ "name": "contract_docs_tenant_idx",
+ "columns": [
+ {
+ "expression": "tenant_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "contract_docs_case_idx": {
+ "name": "contract_docs_case_idx",
+ "columns": [
+ {
+ "expression": "case_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "contract_docs_status_idx": {
+ "name": "contract_docs_status_idx",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "contract_docs_delete_after_idx": {
+ "name": "contract_docs_delete_after_idx",
+ "columns": [
+ {
+ "expression": "delete_after",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "contract_documents_tenant_id_tenants_id_fk": {
+ "name": "contract_documents_tenant_id_tenants_id_fk",
+ "tableFrom": "contract_documents",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "contract_documents_case_id_cases_id_fk": {
+ "name": "contract_documents_case_id_cases_id_fk",
+ "tableFrom": "contract_documents",
+ "tableTo": "cases",
+ "columnsFrom": [
+ "case_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "contract_documents_user_id_users_id_fk": {
+ "name": "contract_documents_user_id_users_id_fk",
+ "tableFrom": "contract_documents",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "contract_documents_fachgruppe_id_nv_buehne_fachgruppen_id_fk": {
+ "name": "contract_documents_fachgruppe_id_nv_buehne_fachgruppen_id_fk",
+ "tableFrom": "contract_documents",
+ "tableTo": "nv_buehne_fachgruppen",
+ "columnsFrom": [
+ "fachgruppe_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.decision_norms": {
+ "name": "decision_norms",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "decision_id": {
+ "name": "decision_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "norm_id": {
+ "name": "norm_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "application_type": {
+ "name": "application_type",
+ "type": "varchar(50)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'angewendet'"
+ },
+ "passage": {
+ "name": "passage",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "decision_norms_unique_idx": {
+ "name": "decision_norms_unique_idx",
+ "columns": [
+ {
+ "expression": "decision_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "norm_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "decision_norms_decision_id_decisions_id_fk": {
+ "name": "decision_norms_decision_id_decisions_id_fk",
+ "tableFrom": "decision_norms",
+ "tableTo": "decisions",
+ "columnsFrom": [
+ "decision_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "decision_norms_norm_id_norms_id_fk": {
+ "name": "decision_norms_norm_id_norms_id_fk",
+ "tableFrom": "decision_norms",
+ "tableTo": "norms",
+ "columnsFrom": [
+ "norm_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.decision_references": {
+ "name": "decision_references",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "source_decision_id": {
+ "name": "source_decision_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "target_decision_id": {
+ "name": "target_decision_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "reference_type": {
+ "name": "reference_type",
+ "type": "varchar(50)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "decision_refs_unique_idx": {
+ "name": "decision_refs_unique_idx",
+ "columns": [
+ {
+ "expression": "source_decision_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "target_decision_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "reference_type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "decision_references_source_decision_id_decisions_id_fk": {
+ "name": "decision_references_source_decision_id_decisions_id_fk",
+ "tableFrom": "decision_references",
+ "tableTo": "decisions",
+ "columnsFrom": [
+ "source_decision_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "decision_references_target_decision_id_decisions_id_fk": {
+ "name": "decision_references_target_decision_id_decisions_id_fk",
+ "tableFrom": "decision_references",
+ "tableTo": "decisions",
+ "columnsFrom": [
+ "target_decision_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.decisions": {
+ "name": "decisions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "type": {
+ "name": "type",
+ "type": "decision_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "case_reference": {
+ "name": "case_reference",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "decision_date": {
+ "name": "decision_date",
+ "type": "date",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "court": {
+ "name": "court",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "tribunal_id": {
+ "name": "tribunal_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "chamber": {
+ "name": "chamber",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "headnote": {
+ "name": "headnote",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "tenor": {
+ "name": "tenor",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "facts": {
+ "name": "facts",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "reasoning": {
+ "name": "reasoning",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "full_text": {
+ "name": "full_text",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "domains": {
+ "name": "domains",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'[]'::jsonb"
+ },
+ "keywords": {
+ "name": "keywords",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'[]'::jsonb"
+ },
+ "publication_source": {
+ "name": "publication_source",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "is_published": {
+ "name": "is_published",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false,
+ "default": false
+ },
+ "is_anonymized": {
+ "name": "is_anonymized",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false,
+ "default": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "decisions_type_idx": {
+ "name": "decisions_type_idx",
+ "columns": [
+ {
+ "expression": "type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "decisions_date_idx": {
+ "name": "decisions_date_idx",
+ "columns": [
+ {
+ "expression": "decision_date",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "decisions_court_idx": {
+ "name": "decisions_court_idx",
+ "columns": [
+ {
+ "expression": "court",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "decisions_tenant_idx": {
+ "name": "decisions_tenant_idx",
+ "columns": [
+ {
+ "expression": "tenant_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "decisions_case_ref_idx": {
+ "name": "decisions_case_ref_idx",
+ "columns": [
+ {
+ "expression": "case_reference",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "decisions_tenant_id_tenants_id_fk": {
+ "name": "decisions_tenant_id_tenants_id_fk",
+ "tableFrom": "decisions",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "decisions_tribunal_id_arbitration_tribunals_id_fk": {
+ "name": "decisions_tribunal_id_arbitration_tribunals_id_fk",
+ "tableFrom": "decisions",
+ "tableTo": "arbitration_tribunals",
+ "columnsFrom": [
+ "tribunal_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.norm_instruments": {
+ "name": "norm_instruments",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "type": {
+ "name": "type",
+ "type": "norm_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source_rank": {
+ "name": "source_rank",
+ "type": "source_rank",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "abbreviation": {
+ "name": "abbreviation",
+ "type": "varchar(50)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "full_title": {
+ "name": "full_title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "enacted_at": {
+ "name": "enacted_at",
+ "type": "date",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "issuing_body": {
+ "name": "issuing_body",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "citation": {
+ "name": "citation",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "norm_instruments_type_idx": {
+ "name": "norm_instruments_type_idx",
+ "columns": [
+ {
+ "expression": "type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "norm_instruments_tenant_idx": {
+ "name": "norm_instruments_tenant_idx",
+ "columns": [
+ {
+ "expression": "tenant_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "norm_instruments_tenant_id_tenants_id_fk": {
+ "name": "norm_instruments_tenant_id_tenants_id_fk",
+ "tableFrom": "norm_instruments",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.norm_references": {
+ "name": "norm_references",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "source_norm_id": {
+ "name": "source_norm_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "target_norm_id": {
+ "name": "target_norm_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "reference_type": {
+ "name": "reference_type",
+ "type": "varchar(50)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'verweist_auf'"
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "norm_refs_unique_idx": {
+ "name": "norm_refs_unique_idx",
+ "columns": [
+ {
+ "expression": "source_norm_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "target_norm_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "reference_type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "norm_references_source_norm_id_norms_id_fk": {
+ "name": "norm_references_source_norm_id_norms_id_fk",
+ "tableFrom": "norm_references",
+ "tableTo": "norms",
+ "columnsFrom": [
+ "source_norm_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "norm_references_target_norm_id_norms_id_fk": {
+ "name": "norm_references_target_norm_id_norms_id_fk",
+ "tableFrom": "norm_references",
+ "tableTo": "norms",
+ "columnsFrom": [
+ "target_norm_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.norms": {
+ "name": "norms",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "instrument_id": {
+ "name": "instrument_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "paragraph": {
+ "name": "paragraph",
+ "type": "varchar(50)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "subsection": {
+ "name": "subsection",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "title": {
+ "name": "title",
+ "type": "varchar(500)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "body": {
+ "name": "body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "valid_from": {
+ "name": "valid_from",
+ "type": "date",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "valid_to": {
+ "name": "valid_to",
+ "type": "date",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "previous_version_id": {
+ "name": "previous_version_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "version_number": {
+ "name": "version_number",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 1
+ },
+ "domains": {
+ "name": "domains",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'[]'::jsonb"
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "norms_instrument_idx": {
+ "name": "norms_instrument_idx",
+ "columns": [
+ {
+ "expression": "instrument_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "norms_paragraph_idx": {
+ "name": "norms_paragraph_idx",
+ "columns": [
+ {
+ "expression": "instrument_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "paragraph",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "norms_valid_from_idx": {
+ "name": "norms_valid_from_idx",
+ "columns": [
+ {
+ "expression": "valid_from",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "norms_tenant_idx": {
+ "name": "norms_tenant_idx",
+ "columns": [
+ {
+ "expression": "tenant_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "norms_tenant_id_tenants_id_fk": {
+ "name": "norms_tenant_id_tenants_id_fk",
+ "tableFrom": "norms",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "norms_instrument_id_norm_instruments_id_fk": {
+ "name": "norms_instrument_id_norm_instruments_id_fk",
+ "tableFrom": "norms",
+ "tableTo": "norm_instruments",
+ "columnsFrom": [
+ "instrument_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "norms_previous_version_id_norms_id_fk": {
+ "name": "norms_previous_version_id_norms_id_fk",
+ "tableFrom": "norms",
+ "tableTo": "norms",
+ "columnsFrom": [
+ "previous_version_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.nv_buehne_fachgruppen": {
+ "name": "nv_buehne_fachgruppen",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "abbreviation": {
+ "name": "abbreviation",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "section_ref": {
+ "name": "section_ref",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "special_provisions": {
+ "name": "special_provisions",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'[]'::jsonb"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.proceeding_deadlines": {
+ "name": "proceeding_deadlines",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "proceeding_id": {
+ "name": "proceeding_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "step_id": {
+ "name": "step_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "type": {
+ "name": "type",
+ "type": "deadline_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'frist'"
+ },
+ "label": {
+ "name": "label",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "due_date": {
+ "name": "due_date",
+ "type": "date",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "due_time": {
+ "name": "due_time",
+ "type": "varchar(10)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "warning_date": {
+ "name": "warning_date",
+ "type": "date",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "warning_days_before": {
+ "name": "warning_days_before",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "is_completed": {
+ "name": "is_completed",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false,
+ "default": false
+ },
+ "completed_at": {
+ "name": "completed_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "is_calculated": {
+ "name": "is_calculated",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false,
+ "default": false
+ },
+ "calculation_basis": {
+ "name": "calculation_basis",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "legal_basis": {
+ "name": "legal_basis",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "proceeding_deadlines_proceeding_idx": {
+ "name": "proceeding_deadlines_proceeding_idx",
+ "columns": [
+ {
+ "expression": "proceeding_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "proceeding_deadlines_step_idx": {
+ "name": "proceeding_deadlines_step_idx",
+ "columns": [
+ {
+ "expression": "step_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "proceeding_deadlines_due_date_idx": {
+ "name": "proceeding_deadlines_due_date_idx",
+ "columns": [
+ {
+ "expression": "due_date",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "proceeding_deadlines_warning_idx": {
+ "name": "proceeding_deadlines_warning_idx",
+ "columns": [
+ {
+ "expression": "warning_date",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "proceeding_deadlines_proceeding_id_proceedings_id_fk": {
+ "name": "proceeding_deadlines_proceeding_id_proceedings_id_fk",
+ "tableFrom": "proceeding_deadlines",
+ "tableTo": "proceedings",
+ "columnsFrom": [
+ "proceeding_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "proceeding_deadlines_step_id_proceeding_steps_id_fk": {
+ "name": "proceeding_deadlines_step_id_proceeding_steps_id_fk",
+ "tableFrom": "proceeding_deadlines",
+ "tableTo": "proceeding_steps",
+ "columnsFrom": [
+ "step_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.proceeding_steps": {
+ "name": "proceeding_steps",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "proceeding_id": {
+ "name": "proceeding_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "step_key": {
+ "name": "step_key",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "label": {
+ "name": "label",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "status": {
+ "name": "status",
+ "type": "proceeding_step_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'ausstehend'"
+ },
+ "legal_basis": {
+ "name": "legal_basis",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "responsible_party": {
+ "name": "responsible_party",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "completed_at": {
+ "name": "completed_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "proceeding_steps_proceeding_idx": {
+ "name": "proceeding_steps_proceeding_idx",
+ "columns": [
+ {
+ "expression": "proceeding_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "proceeding_steps_key_idx": {
+ "name": "proceeding_steps_key_idx",
+ "columns": [
+ {
+ "expression": "proceeding_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "step_key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "proceeding_steps_proceeding_id_proceedings_id_fk": {
+ "name": "proceeding_steps_proceeding_id_proceedings_id_fk",
+ "tableFrom": "proceeding_steps",
+ "tableTo": "proceedings",
+ "columnsFrom": [
+ "proceeding_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.proceedings": {
+ "name": "proceedings",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "case_id": {
+ "name": "case_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "type": {
+ "name": "type",
+ "type": "proceeding_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "proceeding_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'vorbereitung'"
+ },
+ "filing_date": {
+ "name": "filing_date",
+ "type": "date",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "internal_ref": {
+ "name": "internal_ref",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "external_ref": {
+ "name": "external_ref",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "tribunal_id": {
+ "name": "tribunal_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "court_name": {
+ "name": "court_name",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "chamber": {
+ "name": "chamber",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "presiding_judge": {
+ "name": "presiding_judge",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "applicant": {
+ "name": "applicant",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "respondent": {
+ "name": "respondent",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "subject": {
+ "name": "subject",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "amount_in_dispute_cents": {
+ "name": "amount_in_dispute_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "fachgruppe_id": {
+ "name": "fachgruppe_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "current_step_key": {
+ "name": "current_step_key",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "closed_at": {
+ "name": "closed_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "proceedings_tenant_idx": {
+ "name": "proceedings_tenant_idx",
+ "columns": [
+ {
+ "expression": "tenant_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "proceedings_case_idx": {
+ "name": "proceedings_case_idx",
+ "columns": [
+ {
+ "expression": "case_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "proceedings_type_idx": {
+ "name": "proceedings_type_idx",
+ "columns": [
+ {
+ "expression": "type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "proceedings_status_idx": {
+ "name": "proceedings_status_idx",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "proceedings_tenant_id_tenants_id_fk": {
+ "name": "proceedings_tenant_id_tenants_id_fk",
+ "tableFrom": "proceedings",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "proceedings_case_id_cases_id_fk": {
+ "name": "proceedings_case_id_cases_id_fk",
+ "tableFrom": "proceedings",
+ "tableTo": "cases",
+ "columnsFrom": [
+ "case_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "proceedings_tribunal_id_arbitration_tribunals_id_fk": {
+ "name": "proceedings_tribunal_id_arbitration_tribunals_id_fk",
+ "tableFrom": "proceedings",
+ "tableTo": "arbitration_tribunals",
+ "columnsFrom": [
+ "tribunal_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "proceedings_fachgruppe_id_nv_buehne_fachgruppen_id_fk": {
+ "name": "proceedings_fachgruppe_id_nv_buehne_fachgruppen_id_fk",
+ "tableFrom": "proceedings",
+ "tableTo": "nv_buehne_fachgruppen",
+ "columnsFrom": [
+ "fachgruppe_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.standard_clauses": {
+ "name": "standard_clauses",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "instrument_id": {
+ "name": "instrument_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "category": {
+ "name": "category",
+ "type": "varchar(200)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "label": {
+ "name": "label",
+ "type": "varchar(500)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "body": {
+ "name": "body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "fachgruppe_ids": {
+ "name": "fachgruppe_ids",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "norm_id": {
+ "name": "norm_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "standard_clauses_instrument_idx": {
+ "name": "standard_clauses_instrument_idx",
+ "columns": [
+ {
+ "expression": "instrument_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "standard_clauses_category_idx": {
+ "name": "standard_clauses_category_idx",
+ "columns": [
+ {
+ "expression": "category",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "standard_clauses_instrument_id_norm_instruments_id_fk": {
+ "name": "standard_clauses_instrument_id_norm_instruments_id_fk",
+ "tableFrom": "standard_clauses",
+ "tableTo": "norm_instruments",
+ "columnsFrom": [
+ "instrument_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "standard_clauses_norm_id_norms_id_fk": {
+ "name": "standard_clauses_norm_id_norms_id_fk",
+ "tableFrom": "standard_clauses",
+ "tableTo": "norms",
+ "columnsFrom": [
+ "norm_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.tenants": {
+ "name": "tenants",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "slug": {
+ "name": "slug",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "address": {
+ "name": "address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "phone": {
+ "name": "phone",
+ "type": "varchar(50)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "email": {
+ "name": "email",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "retention_days": {
+ "name": "retention_days",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "default": 3650
+ },
+ "settings": {
+ "name": "settings",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "tenants_slug_unique": {
+ "name": "tenants_slug_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "slug"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.users": {
+ "name": "users",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "role": {
+ "name": "role",
+ "type": "user_role",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'viewer'"
+ },
+ "password_hash": {
+ "name": "password_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "email_verified_at": {
+ "name": "email_verified_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_login_at": {
+ "name": "last_login_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "users_tenant_email_idx": {
+ "name": "users_tenant_email_idx",
+ "columns": [
+ {
+ "expression": "tenant_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "email",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "users_tenant_id_tenants_id_fk": {
+ "name": "users_tenant_id_tenants_id_fk",
+ "tableFrom": "users",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {
+ "public.analysis_mode": {
+ "name": "analysis_mode",
+ "schema": "public",
+ "values": [
+ "gutachten",
+ "entscheidung",
+ "vergleich",
+ "risiko"
+ ]
+ },
+ "public.analysis_status": {
+ "name": "analysis_status",
+ "schema": "public",
+ "values": [
+ "draft",
+ "in_progress",
+ "completed",
+ "archived"
+ ]
+ },
+ "public.clause_rating": {
+ "name": "clause_rating",
+ "schema": "public",
+ "values": [
+ "standard",
+ "abweichend",
+ "kritisch",
+ "unbekannt"
+ ]
+ },
+ "public.contract_doc_status": {
+ "name": "contract_doc_status",
+ "schema": "public",
+ "values": [
+ "uploaded",
+ "extracting",
+ "extracted",
+ "analyzing",
+ "completed",
+ "failed"
+ ]
+ },
+ "public.deadline_type": {
+ "name": "deadline_type",
+ "schema": "public",
+ "values": [
+ "frist",
+ "termin",
+ "vorfrist"
+ ]
+ },
+ "public.decision_type": {
+ "name": "decision_type",
+ "schema": "public",
+ "values": [
+ "schiedsspruch",
+ "urteil",
+ "beschluss",
+ "vergleich",
+ "einstweilige_verfuegung"
+ ]
+ },
+ "public.legal_domain": {
+ "name": "legal_domain",
+ "schema": "public",
+ "values": [
+ "buehnenrecht",
+ "arbeitsrecht",
+ "tarifrecht",
+ "urheberrecht",
+ "sozialrecht",
+ "vertragsrecht",
+ "prozessrecht"
+ ]
+ },
+ "public.norm_type": {
+ "name": "norm_type",
+ "schema": "public",
+ "values": [
+ "gesetz",
+ "tarifvertrag",
+ "schiedsordnung",
+ "verordnung",
+ "satzung",
+ "richtlinie"
+ ]
+ },
+ "public.proceeding_status": {
+ "name": "proceeding_status",
+ "schema": "public",
+ "values": [
+ "vorbereitung",
+ "eingereicht",
+ "laufend",
+ "verhandlung",
+ "entschieden",
+ "abgeschlossen",
+ "ruht"
+ ]
+ },
+ "public.proceeding_step_status": {
+ "name": "proceeding_step_status",
+ "schema": "public",
+ "values": [
+ "ausstehend",
+ "aktiv",
+ "abgeschlossen",
+ "uebersprungen"
+ ]
+ },
+ "public.proceeding_type": {
+ "name": "proceeding_type",
+ "schema": "public",
+ "values": [
+ "bschgo_bezirk",
+ "bschgo_bund",
+ "arbgg_erste_instanz",
+ "arbgg_berufung",
+ "arbgg_revision"
+ ]
+ },
+ "public.source_rank": {
+ "name": "source_rank",
+ "schema": "public",
+ "values": [
+ "gesetz",
+ "tarif",
+ "schiedsordnung",
+ "praxis",
+ "kommentar"
+ ]
+ },
+ "public.user_role": {
+ "name": "user_role",
+ "schema": "public",
+ "values": [
+ "admin",
+ "attorney",
+ "paralegal",
+ "viewer"
+ ]
+ }
+ },
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
\ No newline at end of file
diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json
index 7a6618b..c0ddb10 100644
--- a/drizzle/meta/_journal.json
+++ b/drizzle/meta/_journal.json
@@ -8,6 +8,13 @@
"when": 1775682934077,
"tag": "0000_peaceful_amazoness",
"breakpoints": true
+ },
+ {
+ "idx": 1,
+ "version": "7",
+ "when": 1775690117252,
+ "tag": "0001_curved_fabian_cortez",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
index 6858e05..6dc4a4b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10,25 +10,29 @@
"dependencies": {
"@ai-sdk/anthropic": "^3.0.68",
"@ai-sdk/openai": "^3.0.52",
+ "@types/bcryptjs": "^2.4.6",
"ai": "^6.0.154",
+ "bcryptjs": "^3.0.3",
"drizzle-orm": "^0.45.2",
+ "mammoth": "^1.12.0",
"next": "16.2.3",
"next-auth": "^4.24.13",
+ "pdf-parse": "^2.4.5",
"pg": "^8.20.0",
"react": "19.2.4",
"react-dom": "19.2.4"
},
"devDependencies": {
- "@tailwindcss/postcss": "^4",
+ "@tailwindcss/postcss": "^4.2.2",
"@types/node": "^20",
"@types/pg": "^8.20.0",
- "@types/react": "^19",
+ "@types/react": "19.2.14",
"@types/react-dom": "^19",
"drizzle-kit": "^0.31.10",
"eslint": "^9",
"eslint-config-next": "16.2.3",
- "tailwindcss": "^4",
- "typescript": "^5"
+ "tailwindcss": "^4.2.2",
+ "typescript": "5.9.3"
}
},
"node_modules/@ai-sdk/anthropic": {
@@ -2049,6 +2053,205 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
+ "node_modules/@napi-rs/canvas": {
+ "version": "0.1.80",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.80.tgz",
+ "integrity": "sha512-DxuT1ClnIPts1kQx8FBmkk4BQDTfI5kIzywAaMjQSXfNnra5UFU9PwurXrl+Je3bJ6BGsp/zmshVVFbCmyI+ww==",
+ "license": "MIT",
+ "workspaces": [
+ "e2e/*"
+ ],
+ "engines": {
+ "node": ">= 10"
+ },
+ "optionalDependencies": {
+ "@napi-rs/canvas-android-arm64": "0.1.80",
+ "@napi-rs/canvas-darwin-arm64": "0.1.80",
+ "@napi-rs/canvas-darwin-x64": "0.1.80",
+ "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.80",
+ "@napi-rs/canvas-linux-arm64-gnu": "0.1.80",
+ "@napi-rs/canvas-linux-arm64-musl": "0.1.80",
+ "@napi-rs/canvas-linux-riscv64-gnu": "0.1.80",
+ "@napi-rs/canvas-linux-x64-gnu": "0.1.80",
+ "@napi-rs/canvas-linux-x64-musl": "0.1.80",
+ "@napi-rs/canvas-win32-x64-msvc": "0.1.80"
+ }
+ },
+ "node_modules/@napi-rs/canvas-android-arm64": {
+ "version": "0.1.80",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.80.tgz",
+ "integrity": "sha512-sk7xhN/MoXeuExlggf91pNziBxLPVUqF2CAVnB57KLG/pz7+U5TKG8eXdc3pm0d7Od0WreB6ZKLj37sX9muGOQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@napi-rs/canvas-darwin-arm64": {
+ "version": "0.1.80",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.80.tgz",
+ "integrity": "sha512-O64APRTXRUiAz0P8gErkfEr3lipLJgM6pjATwavZ22ebhjYl/SUbpgM0xcWPQBNMP1n29afAC/Us5PX1vg+JNQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@napi-rs/canvas-darwin-x64": {
+ "version": "0.1.80",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.80.tgz",
+ "integrity": "sha512-FqqSU7qFce0Cp3pwnTjVkKjjOtxMqRe6lmINxpIZYaZNnVI0H5FtsaraZJ36SiTHNjZlUB69/HhxNDT1Aaa9vA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
+ "version": "0.1.80",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.80.tgz",
+ "integrity": "sha512-eyWz0ddBDQc7/JbAtY4OtZ5SpK8tR4JsCYEZjCE3dI8pqoWUC8oMwYSBGCYfsx2w47cQgQCgMVRVTFiiO38hHQ==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@napi-rs/canvas-linux-arm64-gnu": {
+ "version": "0.1.80",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.80.tgz",
+ "integrity": "sha512-qwA63t8A86bnxhuA/GwOkK3jvb+XTQaTiVML0vAWoHyoZYTjNs7BzoOONDgTnNtr8/yHrq64XXzUoLqDzU+Uuw==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@napi-rs/canvas-linux-arm64-musl": {
+ "version": "0.1.80",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.80.tgz",
+ "integrity": "sha512-1XbCOz/ymhj24lFaIXtWnwv/6eFHXDrjP0jYkc6iHQ9q8oXKzUX1Lc6bu+wuGiLhGh2GS/2JlfORC5ZcXimRcg==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
+ "version": "0.1.80",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.80.tgz",
+ "integrity": "sha512-XTzR125w5ZMs0lJcxRlS1K3P5RaZ9RmUsPtd1uGt+EfDyYMu4c6SEROYsxyatbbu/2+lPe7MPHOO/0a0x7L/gw==",
+ "cpu": [
+ "riscv64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@napi-rs/canvas-linux-x64-gnu": {
+ "version": "0.1.80",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.80.tgz",
+ "integrity": "sha512-BeXAmhKg1kX3UCrJsYbdQd3hIMDH/K6HnP/pG2LuITaXhXBiNdh//TVVVVCBbJzVQaV5gK/4ZOCMrQW9mvuTqA==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@napi-rs/canvas-linux-x64-musl": {
+ "version": "0.1.80",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.80.tgz",
+ "integrity": "sha512-x0XvZWdHbkgdgucJsRxprX/4o4sEed7qo9rCQA9ugiS9qE2QvP0RIiEugtZhfLH3cyI+jIRFJHV4Fuz+1BHHMg==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@napi-rs/canvas-win32-x64-msvc": {
+ "version": "0.1.80",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.80.tgz",
+ "integrity": "sha512-Z8jPsM6df5V8B1HrCHB05+bDiCxjE9QA//3YrkKIdVDEwn5RKaqOxCJDRJkl48cJbylcrJbW4HxZbTte8juuPg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
"node_modules/@napi-rs/wasm-runtime": {
"version": "0.2.12",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
@@ -2600,6 +2803,12 @@
"tslib": "^2.4.0"
}
},
+ "node_modules/@types/bcryptjs": {
+ "version": "2.4.6",
+ "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz",
+ "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==",
+ "license": "MIT"
+ },
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -3260,6 +3469,15 @@
"node": ">= 20"
}
},
+ "node_modules/@xmldom/xmldom": {
+ "version": "0.8.12",
+ "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.12.tgz",
+ "integrity": "sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
"node_modules/acorn": {
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
@@ -3571,6 +3789,26 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/base64-js": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
+ "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
"node_modules/baseline-browser-mapping": {
"version": "2.10.16",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.16.tgz",
@@ -3583,6 +3821,21 @@
"node": ">=6.0.0"
}
},
+ "node_modules/bcryptjs": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
+ "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
+ "license": "BSD-3-Clause",
+ "bin": {
+ "bcrypt": "bin/bcrypt"
+ }
+ },
+ "node_modules/bluebird": {
+ "version": "3.4.7",
+ "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz",
+ "integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==",
+ "license": "MIT"
+ },
"node_modules/brace-expansion": {
"version": "1.1.13",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
@@ -3794,6 +4047,12 @@
"node": ">= 0.6"
}
},
+ "node_modules/core-util-is": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
+ "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
+ "license": "MIT"
+ },
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -3948,6 +4207,12 @@
"node": ">=8"
}
},
+ "node_modules/dingbat-to-unicode": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dingbat-to-unicode/-/dingbat-to-unicode-1.0.1.tgz",
+ "integrity": "sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==",
+ "license": "BSD-2-Clause"
+ },
"node_modules/doctrine": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
@@ -4102,6 +4367,15 @@
}
}
},
+ "node_modules/duck": {
+ "version": "0.1.12",
+ "resolved": "https://registry.npmjs.org/duck/-/duck-0.1.12.tgz",
+ "integrity": "sha512-wkctla1O6VfP89gQ+J/yDesM0S7B7XLXjKGzXxMDVFg7uEn706niAtyYovKbyq1oT9YwDcly721/iUWoc8MVRg==",
+ "license": "BSD",
+ "dependencies": {
+ "underscore": "^1.13.1"
+ }
+ },
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -5274,6 +5548,12 @@
"node": ">= 4"
}
},
+ "node_modules/immediate": {
+ "version": "3.0.6",
+ "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
+ "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
+ "license": "MIT"
+ },
"node_modules/import-fresh": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@@ -5301,6 +5581,12 @@
"node": ">=0.8.19"
}
},
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "license": "ISC"
+ },
"node_modules/internal-slot": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
@@ -5871,6 +6157,18 @@
"node": ">=4.0"
}
},
+ "node_modules/jszip": {
+ "version": "3.10.1",
+ "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
+ "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
+ "license": "(MIT OR GPL-3.0-or-later)",
+ "dependencies": {
+ "lie": "~3.3.0",
+ "pako": "~1.0.2",
+ "readable-stream": "~2.3.6",
+ "setimmediate": "^1.0.5"
+ }
+ },
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -5915,6 +6213,15 @@
"node": ">= 0.8.0"
}
},
+ "node_modules/lie": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
+ "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
+ "license": "MIT",
+ "dependencies": {
+ "immediate": "~3.0.5"
+ }
+ },
"node_modules/lightningcss": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
@@ -6224,6 +6531,17 @@
"loose-envify": "cli.js"
}
},
+ "node_modules/lop": {
+ "version": "0.4.2",
+ "resolved": "https://registry.npmjs.org/lop/-/lop-0.4.2.tgz",
+ "integrity": "sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "duck": "^0.1.12",
+ "option": "~0.2.1",
+ "underscore": "^1.13.1"
+ }
+ },
"node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -6244,6 +6562,39 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
+ "node_modules/mammoth": {
+ "version": "1.12.0",
+ "resolved": "https://registry.npmjs.org/mammoth/-/mammoth-1.12.0.tgz",
+ "integrity": "sha512-cwnK1RIcRdDMi2HRx2EXGYlxqIEh0Oo3bLhorgnsVJi2UkbX1+jKxuBNR9PC5+JaX7EkmJxFPmo6mjLpqShI2w==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "@xmldom/xmldom": "^0.8.6",
+ "argparse": "~1.0.3",
+ "base64-js": "^1.5.1",
+ "bluebird": "~3.4.0",
+ "dingbat-to-unicode": "^1.0.1",
+ "jszip": "^3.7.1",
+ "lop": "^0.4.2",
+ "path-is-absolute": "^1.0.0",
+ "underscore": "^1.13.1",
+ "xmlbuilder": "^10.0.0"
+ },
+ "bin": {
+ "mammoth": "bin/mammoth"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/mammoth/node_modules/argparse": {
+ "version": "1.0.10",
+ "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
+ "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
+ "license": "MIT",
+ "dependencies": {
+ "sprintf-js": "~1.0.2"
+ }
+ },
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -6668,6 +7019,12 @@
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"license": "ISC"
},
+ "node_modules/option": {
+ "version": "0.2.4",
+ "resolved": "https://registry.npmjs.org/option/-/option-0.2.4.tgz",
+ "integrity": "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A==",
+ "license": "BSD-2-Clause"
+ },
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -6736,6 +7093,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/pako": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
+ "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
+ "license": "(MIT AND Zlib)"
+ },
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -6759,6 +7122,15 @@
"node": ">=8"
}
},
+ "node_modules/path-is-absolute": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
+ "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
@@ -6776,6 +7148,38 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/pdf-parse": {
+ "version": "2.4.5",
+ "resolved": "https://registry.npmjs.org/pdf-parse/-/pdf-parse-2.4.5.tgz",
+ "integrity": "sha512-mHU89HGh7v+4u2ubfnevJ03lmPgQ5WU4CxAVmTSh/sxVTEDYd1er/dKS/A6vg77NX47KTEoihq8jZBLr8Cxuwg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@napi-rs/canvas": "0.1.80",
+ "pdfjs-dist": "5.4.296"
+ },
+ "bin": {
+ "pdf-parse": "bin/cli.mjs"
+ },
+ "engines": {
+ "node": ">=20.16.0 <21 || >=22.3.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/mehmet-kozan"
+ }
+ },
+ "node_modules/pdfjs-dist": {
+ "version": "5.4.296",
+ "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.296.tgz",
+ "integrity": "sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=20.16.0 || >=22.3.0"
+ },
+ "optionalDependencies": {
+ "@napi-rs/canvas": "^0.1.80"
+ }
+ },
"node_modules/pg": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz",
@@ -7000,6 +7404,12 @@
"integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==",
"license": "MIT"
},
+ "node_modules/process-nextick-args": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
+ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
+ "license": "MIT"
+ },
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
@@ -7071,6 +7481,27 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/readable-stream": {
+ "version": "2.3.8",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
+ "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
+ "license": "MIT",
+ "dependencies": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ },
+ "node_modules/readable-stream/node_modules/isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "license": "MIT"
+ },
"node_modules/reflect.getprototypeof": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@@ -7214,6 +7645,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/safe-buffer": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
+ "license": "MIT"
+ },
"node_modules/safe-push-apply": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
@@ -7314,6 +7751,12 @@
"node": ">= 0.4"
}
},
+ "node_modules/setimmediate": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
+ "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
+ "license": "MIT"
+ },
"node_modules/sharp": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
@@ -7510,6 +7953,12 @@
"node": ">= 10.x"
}
},
+ "node_modules/sprintf-js": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
+ "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
+ "license": "BSD-3-Clause"
+ },
"node_modules/stable-hash": {
"version": "0.0.5",
"resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
@@ -7531,6 +7980,15 @@
"node": ">= 0.4"
}
},
+ "node_modules/string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "~5.1.0"
+ }
+ },
"node_modules/string.prototype.includes": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz",
@@ -8495,6 +8953,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/underscore": {
+ "version": "1.13.8",
+ "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz",
+ "integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==",
+ "license": "MIT"
+ },
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
@@ -8578,6 +9042,12 @@
"punycode": "^2.1.0"
}
},
+ "node_modules/util-deprecate": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+ "license": "MIT"
+ },
"node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
@@ -8702,6 +9172,15 @@
"node": ">=0.10.0"
}
},
+ "node_modules/xmlbuilder": {
+ "version": "10.1.1",
+ "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-10.1.1.tgz",
+ "integrity": "sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
diff --git a/package.json b/package.json
index c415322..fe8b6bf 100644
--- a/package.json
+++ b/package.json
@@ -11,24 +11,28 @@
"dependencies": {
"@ai-sdk/anthropic": "^3.0.68",
"@ai-sdk/openai": "^3.0.52",
+ "@types/bcryptjs": "^2.4.6",
"ai": "^6.0.154",
+ "bcryptjs": "^3.0.3",
"drizzle-orm": "^0.45.2",
+ "mammoth": "^1.12.0",
"next": "16.2.3",
"next-auth": "^4.24.13",
+ "pdf-parse": "^2.4.5",
"pg": "^8.20.0",
"react": "19.2.4",
"react-dom": "19.2.4"
},
"devDependencies": {
- "@tailwindcss/postcss": "^4",
+ "@tailwindcss/postcss": "^4.2.2",
"@types/node": "^20",
"@types/pg": "^8.20.0",
- "@types/react": "^19",
+ "@types/react": "19.2.14",
"@types/react-dom": "^19",
"drizzle-kit": "^0.31.10",
"eslint": "^9",
"eslint-config-next": "16.2.3",
- "tailwindcss": "^4",
- "typescript": "^5"
+ "tailwindcss": "^4.2.2",
+ "typescript": "5.9.3"
}
}
diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx
new file mode 100644
index 0000000..cae4523
--- /dev/null
+++ b/src/app/(auth)/login/page.tsx
@@ -0,0 +1,104 @@
+'use client';
+
+import { useState } from 'react';
+import { signIn } from 'next-auth/react';
+import { useRouter } from 'next/navigation';
+import Link from 'next/link';
+
+export default function LoginPage() {
+ const router = useRouter();
+ const [email, setEmail] = useState('');
+ const [password, setPassword] = useState('');
+ const [error, setError] = useState('');
+ const [loading, setLoading] = useState(false);
+
+ async function handleSubmit(e: React.FormEvent) {
+ e.preventDefault();
+ setError('');
+ setLoading(true);
+
+ const result = await signIn('credentials', {
+ email,
+ password,
+ redirect: false,
+ });
+
+ setLoading(false);
+
+ if (result?.error) {
+ setError('E-Mail oder Passwort ungültig.');
+ } else {
+ router.push('/dashboard');
+ }
+ }
+
+ return (
+
+
+
+
+
LegalAI
+
Bühnenrecht-Plattform
+
+
+
+
+
+ );
+}
diff --git a/src/app/(auth)/register/page.tsx b/src/app/(auth)/register/page.tsx
new file mode 100644
index 0000000..bdf240a
--- /dev/null
+++ b/src/app/(auth)/register/page.tsx
@@ -0,0 +1,102 @@
+'use client';
+
+import { useState } from 'react';
+import { useRouter } from 'next/navigation';
+import Link from 'next/link';
+
+export default function RegisterPage() {
+ const router = useRouter();
+ const [form, setForm] = useState({ name: '', email: '', password: '', tenantName: '' });
+ const [error, setError] = useState('');
+ const [loading, setLoading] = useState(false);
+
+ async function handleSubmit(e: React.FormEvent) {
+ e.preventDefault();
+ setError('');
+ setLoading(true);
+
+ try {
+ const res = await fetch('/api/auth/register', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(form),
+ });
+
+ if (!res.ok) {
+ const data = await res.json();
+ throw new Error(data.error || 'Registrierung fehlgeschlagen');
+ }
+
+ router.push('/login?registered=true');
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten');
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ function updateForm(field: string, value: string) {
+ setForm((prev) => ({ ...prev, [field]: value }));
+ }
+
+ return (
+
+
+
+
+
LegalAI
+
Konto erstellen
+
+
+
+
+
+ );
+}
diff --git a/src/app/(dashboard)/analyse/analyse-form.tsx b/src/app/(dashboard)/analyse/analyse-form.tsx
new file mode 100644
index 0000000..b768cdc
--- /dev/null
+++ b/src/app/(dashboard)/analyse/analyse-form.tsx
@@ -0,0 +1,140 @@
+'use client';
+
+import { useState } from 'react';
+
+interface CaseOption {
+ id: string;
+ title: string;
+ caseNumber: string;
+}
+
+const MODES = [
+ { key: 'gutachten', label: 'Gutachten' },
+ { key: 'entscheidung', label: 'Entscheidungsprognose' },
+ { key: 'vergleich', label: 'Vergleichsanalyse' },
+ { key: 'risiko', label: 'Risikoanalyse' },
+] as const;
+
+export default function AnalyseForm({ cases }: { cases: CaseOption[] }) {
+ const [mode, setMode] = useState('gutachten');
+ const [caseId, setCaseId] = useState('');
+ const [question, setQuestion] = useState('');
+ const [result, setResult] = useState('');
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState('');
+
+ async function handleSubmit(e: React.FormEvent) {
+ e.preventDefault();
+ if (!question.trim()) return;
+
+ setError('');
+ setResult('');
+ setLoading(true);
+
+ try {
+ const res = await fetch('/api/analyses', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ mode,
+ question: question.trim(),
+ caseId: caseId || undefined,
+ }),
+ });
+
+ if (!res.ok) {
+ throw new Error('Analyse konnte nicht gestartet werden');
+ }
+
+ const reader = res.body?.getReader();
+ const decoder = new TextDecoder();
+
+ if (reader) {
+ let text = '';
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ text += decoder.decode(value, { stream: true });
+ setResult(text);
+ }
+ }
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten');
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ return (
+
+
+
+ {result && (
+
+
+
Ergebnis
+
+ {MODES.find((m) => m.key === mode)?.label}
+
+
+
+ {result}
+
+
+ )}
+
+ );
+}
diff --git a/src/app/(dashboard)/analyse/page.tsx b/src/app/(dashboard)/analyse/page.tsx
new file mode 100644
index 0000000..03b37a2
--- /dev/null
+++ b/src/app/(dashboard)/analyse/page.tsx
@@ -0,0 +1,109 @@
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth';
+import { db } from '@/lib/db';
+import { analyses, cases } from '@/lib/db/schema';
+import { eq, desc } from 'drizzle-orm';
+import Link from 'next/link';
+import AnalyseForm from './analyse-form';
+
+const MODE_INFO = [
+ {
+ key: 'gutachten',
+ label: 'Gutachten',
+ description: 'Systematische Rechtsprüfung nach dem juristischen Gutachtenstil (Obersatz, Definition, Subsumtion, Ergebnis).',
+ icon: 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z',
+ },
+ {
+ key: 'entscheidung',
+ label: 'Entscheidungsprognose',
+ description: 'Prognose der wahrscheinlichen Gerichts- oder Schiedsentscheidung mit Präzedenzfällen.',
+ icon: 'M3 6l3 1m0 0l-3 9a5.002 5.002 0 006.001 0M6 7l3 9M6 7l6-2m6 2l3-1m-3 1l-3 9a5.002 5.002 0 006.001 0M18 7l3 9m-3-9l-6-2m0-2v2m0 16V5m0 16H9m3 0h3',
+ },
+ {
+ key: 'vergleich',
+ label: 'Vergleichsanalyse',
+ description: 'Bewertung von Vergleichsoptionen: Erfolgsaussichten, Wirtschaftlichkeit, Risiko.',
+ icon: 'M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z',
+ },
+ {
+ key: 'risiko',
+ label: 'Risikoanalyse',
+ description: 'Risikomatrix mit Fristrisiken, Compliance-Risiken und priorisierter Handlungsempfehlung.',
+ icon: 'M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z',
+ },
+];
+
+export default async function AnalysePage() {
+ const session = await getServerSession(authOptions);
+ const tenantId = session!.user.tenantId;
+
+ const [tenantCases, recentAnalyses] = await Promise.all([
+ db
+ .select({ id: cases.id, title: cases.title, caseNumber: cases.caseNumber })
+ .from(cases)
+ .where(eq(cases.tenantId, tenantId))
+ .orderBy(desc(cases.createdAt))
+ .limit(50),
+ db
+ .select({
+ id: analyses.id,
+ title: analyses.title,
+ mode: analyses.mode,
+ status: analyses.status,
+ createdAt: analyses.createdAt,
+ })
+ .from(analyses)
+ .where(eq(analyses.tenantId, tenantId))
+ .orderBy(desc(analyses.createdAt))
+ .limit(10),
+ ]);
+
+ return (
+
+
+
Neue Analyse
+
+ Wählen Sie einen Analysemodus und beschreiben Sie den Sachverhalt.
+
+
+
+
+ {MODE_INFO.map((mode) => (
+
+
+
{mode.label}
+
{mode.description}
+
+ ))}
+
+
+
+
+ {recentAnalyses.length > 0 && (
+
+
Bisherige Analysen
+
+ {recentAnalyses.map((a) => (
+
+
+
{a.title || 'Ohne Titel'}
+
+ {MODE_INFO.find((m) => m.key === a.mode)?.label ?? a.mode}
+
+
+
+ {new Date(a.createdAt).toLocaleDateString('de-DE')}
+
+
+ ))}
+
+
+ )}
+
+ );
+}
diff --git a/src/app/(dashboard)/dashboard/page.tsx b/src/app/(dashboard)/dashboard/page.tsx
new file mode 100644
index 0000000..155e9b5
--- /dev/null
+++ b/src/app/(dashboard)/dashboard/page.tsx
@@ -0,0 +1,145 @@
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth';
+import { db } from '@/lib/db';
+import { cases, analyses, proceedings } from '@/lib/db/schema';
+import { eq, and, count, desc } from 'drizzle-orm';
+import Link from 'next/link';
+
+export default async function DashboardPage() {
+ const session = await getServerSession(authOptions);
+ const tenantId = session!.user.tenantId;
+
+ const [caseCount, analysisCount, proceedingCount, recentAnalyses] = await Promise.all([
+ db.select({ value: count() }).from(cases).where(eq(cases.tenantId, tenantId)),
+ db.select({ value: count() }).from(analyses).where(eq(analyses.tenantId, tenantId)),
+ db.select({ value: count() }).from(proceedings).where(eq(proceedings.tenantId, tenantId)),
+ db
+ .select({
+ id: analyses.id,
+ title: analyses.title,
+ mode: analyses.mode,
+ status: analyses.status,
+ createdAt: analyses.createdAt,
+ })
+ .from(analyses)
+ .where(eq(analyses.tenantId, tenantId))
+ .orderBy(desc(analyses.createdAt))
+ .limit(5),
+ ]);
+
+ const stats = [
+ { label: 'Fälle', value: caseCount[0]?.value ?? 0, href: '/dashboard', color: 'bg-primary' },
+ { label: 'Analysen', value: analysisCount[0]?.value ?? 0, href: '/analyse', color: 'bg-accent' },
+ { label: 'Verfahren', value: proceedingCount[0]?.value ?? 0, href: '/verfahren', color: 'bg-success' },
+ ];
+
+ const modeLabels: Record = {
+ gutachten: 'Gutachten',
+ entscheidung: 'Entscheidungsprognose',
+ vergleich: 'Vergleichsanalyse',
+ risiko: 'Risikoanalyse',
+ };
+
+ const statusBadge: Record = {
+ completed: 'bg-success/10 text-success',
+ in_progress: 'bg-warning/10 text-warning',
+ draft: 'bg-muted/10 text-muted',
+ };
+
+ return (
+
+
+
Übersicht
+
+ {stats.map((stat) => (
+
+
+
+ {stat.value}
+
+
{stat.label}
+
+
+ ))}
+
+
+
+
+
+
Letzte Analysen
+
+ Alle anzeigen
+
+
+
+ {recentAnalyses.length === 0 ? (
+
+
Noch keine Analysen erstellt.
+
+ Erste Analyse starten
+
+
+ ) : (
+
+ {recentAnalyses.map((a) => (
+
+
+
{a.title || 'Ohne Titel'}
+
{modeLabels[a.mode] ?? a.mode}
+
+
+
+ {a.status === 'completed' ? 'Fertig' : a.status === 'in_progress' ? 'In Bearbeitung' : 'Entwurf'}
+
+
+ {new Date(a.createdAt).toLocaleDateString('de-DE')}
+
+
+
+ ))}
+
+ )}
+
+
+
+
+
Schnellzugriff
+
+
+
+ Neue Analyse erstellen
+
+
+
+ Normen durchsuchen
+
+
+
+ Entscheidungen suchen
+
+
+
+
+
+
Analysemodi
+
+
Gutachten: Systematische Rechtsprüfung nach Gutachtenstil
+
Entscheidung: Prognose zu Gerichts-/Schiedsentscheidungen
+
Vergleich: Bewertung von Vergleichsoptionen
+
Risiko: Risikoanalyse und Priorisierung
+
+
+
+
+ );
+}
diff --git a/src/app/(dashboard)/einstellungen/page.tsx b/src/app/(dashboard)/einstellungen/page.tsx
new file mode 100644
index 0000000..ea19b7e
--- /dev/null
+++ b/src/app/(dashboard)/einstellungen/page.tsx
@@ -0,0 +1,101 @@
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth';
+import { hasPermission } from '@/lib/auth/rbac';
+import { db } from '@/lib/db';
+import { tenants, users } from '@/lib/db/schema';
+import { eq } from 'drizzle-orm';
+
+const ROLE_LABELS: Record = {
+ admin: 'Administrator',
+ attorney: 'Rechtsanwalt',
+ paralegal: 'Paralegal',
+ viewer: 'Leser',
+};
+
+export default async function EinstellungenPage() {
+ const session = await getServerSession(authOptions);
+ const user = session!.user;
+ const isAdmin = hasPermission(user.role, 'settings:manage');
+
+ const tenant = await db
+ .select()
+ .from(tenants)
+ .where(eq(tenants.id, user.tenantId))
+ .limit(1);
+
+ const tenantUsers = isAdmin
+ ? await db
+ .select({
+ id: users.id,
+ name: users.name,
+ email: users.email,
+ role: users.role,
+ createdAt: users.createdAt,
+ })
+ .from(users)
+ .where(eq(users.tenantId, user.tenantId))
+ : [];
+
+ return (
+
+
+
Mandanten-Information
+
+
+ Name
+ {tenant[0]?.name ?? '—'}
+
+
+ Mandanten-ID
+ {user.tenantId}
+
+
+
+
+
+
Ihr Profil
+
+
+ Name
+ {user.name}
+
+
+ E-Mail
+ {user.email}
+
+
+ Rolle
+ {ROLE_LABELS[user.role] ?? user.role}
+
+
+
+
+ {isAdmin && tenantUsers.length > 0 && (
+
+
Benutzer
+
+ {tenantUsers.map((u) => (
+
+
+
+ {ROLE_LABELS[u.role] ?? u.role}
+
+
+ ))}
+
+
+ )}
+
+ {!isAdmin && (
+
+
+ Für die Verwaltung von Benutzern und Mandanten-Einstellungen wenden Sie sich an Ihren Administrator.
+
+
+ )}
+
+ );
+}
diff --git a/src/app/(dashboard)/entscheidungen/[id]/page.tsx b/src/app/(dashboard)/entscheidungen/[id]/page.tsx
new file mode 100644
index 0000000..4ef523e
--- /dev/null
+++ b/src/app/(dashboard)/entscheidungen/[id]/page.tsx
@@ -0,0 +1,92 @@
+import { db } from '@/lib/db';
+import { decisions, decisionNorms, norms, normInstruments } from '@/lib/db/schema';
+import { eq } from 'drizzle-orm';
+import { notFound } from 'next/navigation';
+import Link from 'next/link';
+
+export default async function EntscheidungDetailPage({
+ params,
+}: {
+ params: Promise<{ id: string }>;
+}) {
+ const { id } = await params;
+
+ const result = await db
+ .select()
+ .from(decisions)
+ .where(eq(decisions.id, id))
+ .limit(1);
+
+ if (result.length === 0) {
+ notFound();
+ }
+
+ const decision = result[0];
+
+ const appliedNorms = await db
+ .select({
+ normId: decisionNorms.normId,
+ paragraph: norms.paragraph,
+ title: norms.title,
+ instrumentTitle: normInstruments.fullTitle,
+ instrumentAbbreviation: normInstruments.abbreviation,
+ })
+ .from(decisionNorms)
+ .innerJoin(norms, eq(decisionNorms.normId, norms.id))
+ .innerJoin(normInstruments, eq(norms.instrumentId, normInstruments.id))
+ .where(eq(decisionNorms.decisionId, id));
+
+ return (
+
+
+ Entscheidungen
+ /
+ {decision.caseReference || decision.court}
+
+
+
+
+ {decision.court}{decision.caseReference ? ` — ${decision.caseReference}` : ''}
+
+
+ {decision.court && Gericht: {decision.court}}
+ {decision.caseReference && Az.: {decision.caseReference}}
+ {decision.decisionDate && Datum: {new Date(decision.decisionDate).toLocaleDateString('de-DE')}}
+
+
+ {decision.headnote && (
+
+
Leitsatz
+
{decision.headnote}
+
+ )}
+
+ {decision.fullText && (
+
+
Volltext
+
+ {decision.fullText}
+
+
+ )}
+
+
+ {appliedNorms.length > 0 && (
+
+
Angewandte Normen
+
+ {appliedNorms.map((n) => (
+
+ {n.instrumentAbbreviation || n.instrumentTitle} {n.paragraph}
+ {n.title ? ` (${n.title})` : ''}
+
+ ))}
+
+
+ )}
+
+ );
+}
diff --git a/src/app/(dashboard)/entscheidungen/page.tsx b/src/app/(dashboard)/entscheidungen/page.tsx
new file mode 100644
index 0000000..ba015be
--- /dev/null
+++ b/src/app/(dashboard)/entscheidungen/page.tsx
@@ -0,0 +1,125 @@
+import { db } from '@/lib/db';
+import { decisions } from '@/lib/db/schema';
+import { desc, ilike, or } from 'drizzle-orm';
+import Link from 'next/link';
+
+const DECISION_TYPE_LABELS: Record = {
+ schiedsspruch: 'Schiedsspruch',
+ urteil: 'Urteil',
+ beschluss: 'Beschluss',
+ vergleich: 'Vergleich',
+ einstweilige_verfuegung: 'Einstw. Verfügung',
+};
+
+export default async function EntscheidungenPage({
+ searchParams,
+}: {
+ searchParams: Promise<{ q?: string; page?: string }>;
+}) {
+ const { q, page } = await searchParams;
+ const currentPage = parseInt(page ?? '1', 10);
+ const pageSize = 20;
+ const offset = (currentPage - 1) * pageSize;
+
+ let query = db
+ .select({
+ id: decisions.id,
+ court: decisions.court,
+ decisionDate: decisions.decisionDate,
+ type: decisions.type,
+ caseReference: decisions.caseReference,
+ headnote: decisions.headnote,
+ })
+ .from(decisions)
+ .orderBy(desc(decisions.decisionDate))
+ .limit(pageSize)
+ .offset(offset);
+
+ if (q) {
+ query = query.where(
+ or(
+ ilike(decisions.headnote, `%${q}%`),
+ ilike(decisions.court, `%${q}%`),
+ ilike(decisions.caseReference, `%${q}%`),
+ )
+ ) as typeof query;
+ }
+
+ const results = await query;
+
+ return (
+
+
+
+ Entscheidungsdatenbank für Bühnenrecht, Schiedssprüche und Arbeitsgerichtsurteile.
+
+
+
+
+
+ {results.length === 0 ? (
+
+
+ {q ? `Keine Entscheidungen für "${q}" gefunden.` : 'Noch keine Entscheidungen in der Datenbank.'}
+
+
+ ) : (
+
+ {results.map((d) => (
+
+
+
+ {d.court}{d.caseReference ? ` — ${d.caseReference}` : ''}
+
+
+ {d.type && (
+
+ {DECISION_TYPE_LABELS[d.type] ?? d.type}
+
+ )}
+
+
+
+ {d.court && {d.court}}
+ {d.caseReference && Az. {d.caseReference}}
+ {d.decisionDate && {new Date(d.decisionDate).toLocaleDateString('de-DE')}}
+
+ {d.headnote && (
+
{d.headnote}
+ )}
+
+ ))}
+
+ )}
+
+ {results.length === pageSize && (
+
+
+ Weitere Ergebnisse laden
+
+
+ )}
+
+ );
+}
diff --git a/src/app/(dashboard)/layout.tsx b/src/app/(dashboard)/layout.tsx
new file mode 100644
index 0000000..60a2f1c
--- /dev/null
+++ b/src/app/(dashboard)/layout.tsx
@@ -0,0 +1,28 @@
+import { getServerSession } from 'next-auth';
+import { redirect } from 'next/navigation';
+import { authOptions } from '@/lib/auth';
+import Sidebar from '@/components/layout/sidebar';
+import Header from '@/components/layout/header';
+
+export default async function DashboardLayout({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ const session = await getServerSession(authOptions);
+ if (!session?.user) {
+ redirect('/login');
+ }
+
+ return (
+
+
+
+
+
+ {children}
+
+
+
+ );
+}
diff --git a/src/app/(dashboard)/loading.tsx b/src/app/(dashboard)/loading.tsx
new file mode 100644
index 0000000..2f2d201
--- /dev/null
+++ b/src/app/(dashboard)/loading.tsx
@@ -0,0 +1,10 @@
+export default function Loading() {
+ return (
+
+ );
+}
diff --git a/src/app/(dashboard)/normen/[instrumentId]/page.tsx b/src/app/(dashboard)/normen/[instrumentId]/page.tsx
new file mode 100644
index 0000000..36500d5
--- /dev/null
+++ b/src/app/(dashboard)/normen/[instrumentId]/page.tsx
@@ -0,0 +1,101 @@
+import { db } from '@/lib/db';
+import { normInstruments, norms } from '@/lib/db/schema';
+import { eq, asc } from 'drizzle-orm';
+import { notFound } from 'next/navigation';
+import Link from 'next/link';
+
+const QUELLENRANG_LABELS: Record = {
+ gesetz: 'Gesetz',
+ tarif: 'Tarifvertrag',
+ schiedsordnung: 'Schiedsordnung',
+ praxis: 'Praxis',
+ kommentar: 'Kommentar',
+};
+
+export default async function InstrumentDetailPage({
+ params,
+}: {
+ params: Promise<{ instrumentId: string }>;
+}) {
+ const { instrumentId } = await params;
+
+ const instrument = await db
+ .select()
+ .from(normInstruments)
+ .where(eq(normInstruments.id, instrumentId))
+ .limit(1);
+
+ if (instrument.length === 0) {
+ notFound();
+ }
+
+ const inst = instrument[0];
+
+ const normList = await db
+ .select({
+ id: norms.id,
+ paragraph: norms.paragraph,
+ title: norms.title,
+ body: norms.body,
+ validFrom: norms.validFrom,
+ validTo: norms.validTo,
+ })
+ .from(norms)
+ .where(eq(norms.instrumentId, instrumentId))
+ .orderBy(asc(norms.paragraph));
+
+ return (
+
+
+ Normen
+ /
+ {inst.abbreviation || inst.fullTitle}
+
+
+
+
+
+
{inst.fullTitle}
+ {inst.abbreviation && (
+
Abkürzung: {inst.abbreviation}
+ )}
+
+
+ {QUELLENRANG_LABELS[inst.sourceRank] ?? inst.sourceRank}
+
+
+
+ {normList.length} Vorschriften
+ {inst.enactedAt && Gültig ab: {new Date(inst.enactedAt).toLocaleDateString('de-DE')}}
+
+
+
+ {normList.length === 0 ? (
+
+
Keine Vorschriften für dieses Regelwerk hinterlegt.
+
+ ) : (
+
+ {normList.map((norm) => (
+
+
+
+ {norm.paragraph}{norm.title ? ` — ${norm.title}` : ''}
+
+
+ {norm.validFrom && ab {new Date(norm.validFrom).toLocaleDateString('de-DE')}}
+ {norm.validTo && bis {new Date(norm.validTo).toLocaleDateString('de-DE')}}
+
+
+ {norm.body && (
+
+ {norm.body.length > 500 ? norm.body.slice(0, 500) + '...' : norm.body}
+
+ )}
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/src/app/(dashboard)/normen/page.tsx b/src/app/(dashboard)/normen/page.tsx
new file mode 100644
index 0000000..84da87b
--- /dev/null
+++ b/src/app/(dashboard)/normen/page.tsx
@@ -0,0 +1,92 @@
+import { db } from '@/lib/db';
+import { normInstruments, norms } from '@/lib/db/schema';
+import { eq, count } from 'drizzle-orm';
+import Link from 'next/link';
+
+const QUELLENRANG_LABELS: Record = {
+ gesetz: 'Gesetz',
+ tarif: 'Tarifvertrag',
+ schiedsordnung: 'Schiedsordnung',
+ praxis: 'Praxis',
+ kommentar: 'Kommentar',
+};
+
+const QUELLENRANG_COLORS: Record = {
+ gesetz: 'bg-primary/10 text-primary',
+ tarif: 'bg-accent/10 text-accent',
+ schiedsordnung: 'bg-success/10 text-success',
+ praxis: 'bg-warning/10 text-warning',
+ kommentar: 'bg-muted/10 text-muted',
+};
+
+export default async function NormenPage() {
+ const instruments = await db
+ .select({
+ id: normInstruments.id,
+ fullTitle: normInstruments.fullTitle,
+ abbreviation: normInstruments.abbreviation,
+ sourceRank: normInstruments.sourceRank,
+ type: normInstruments.type,
+ enactedAt: normInstruments.enactedAt,
+ normCount: count(norms.id),
+ })
+ .from(normInstruments)
+ .leftJoin(norms, eq(norms.instrumentId, normInstruments.id))
+ .groupBy(normInstruments.id)
+ .orderBy(normInstruments.sourceRank, normInstruments.fullTitle);
+
+ return (
+
+
+
+
+ Rechtsquellen nach Quellenrang geordnet. Höherrangige Normen gehen vor.
+
+
+
+
+
+ {Object.entries(QUELLENRANG_LABELS).map(([key, label]) => (
+
+ {label}
+
+ ))}
+
+
+ {instruments.length === 0 ? (
+
+
Noch keine Normen importiert.
+
Verwenden Sie die API zum Importieren: POST /api/norms/import
+
+ ) : (
+
+ {instruments.map((inst) => (
+
+
+
+
{inst.abbreviation || inst.fullTitle}
+ {inst.abbreviation && (
+
{inst.fullTitle}
+ )}
+
+
+ {QUELLENRANG_LABELS[inst.sourceRank] ?? inst.sourceRank}
+
+
+
+ {inst.normCount} Normen
+ {inst.enactedAt && (
+ ab {new Date(inst.enactedAt).toLocaleDateString('de-DE')}
+ )}
+
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/src/app/(dashboard)/verfahren/page.tsx b/src/app/(dashboard)/verfahren/page.tsx
new file mode 100644
index 0000000..70fb46c
--- /dev/null
+++ b/src/app/(dashboard)/verfahren/page.tsx
@@ -0,0 +1,99 @@
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth';
+import { db } from '@/lib/db';
+import { proceedings, cases } from '@/lib/db/schema';
+import { eq, desc } from 'drizzle-orm';
+import Link from 'next/link';
+
+const TYPE_LABELS: Record = {
+ bschgo_bezirk: 'BSchGO Bezirk',
+ bschgo_bund: 'BSchGO Bund',
+ arbgg_erste_instanz: 'ArbGG 1. Instanz',
+ arbgg_berufung: 'ArbGG Berufung',
+ arbgg_revision: 'ArbGG Revision',
+};
+
+const STATUS_LABELS: Record = {
+ vorbereitung: 'Vorbereitung',
+ eingereicht: 'Eingereicht',
+ laufend: 'Laufend',
+ verhandlung: 'Verhandlung',
+ entschieden: 'Entschieden',
+ abgeschlossen: 'Abgeschlossen',
+ ruht: 'Ruht',
+};
+
+const STATUS_COLORS: Record = {
+ vorbereitung: 'bg-muted/10 text-muted',
+ eingereicht: 'bg-primary/10 text-primary',
+ laufend: 'bg-warning/10 text-warning',
+ verhandlung: 'bg-accent/10 text-accent',
+ entschieden: 'bg-success/10 text-success',
+ abgeschlossen: 'bg-success/10 text-success',
+ ruht: 'bg-muted/10 text-muted',
+};
+
+export default async function VerfahrenPage() {
+ const session = await getServerSession(authOptions);
+ const tenantId = session!.user.tenantId;
+
+ const proceedingList = await db
+ .select({
+ id: proceedings.id,
+ type: proceedings.type,
+ status: proceedings.status,
+ filingDate: proceedings.filingDate,
+ caseId: proceedings.caseId,
+ caseTitle: cases.title,
+ caseNumber: cases.caseNumber,
+ })
+ .from(proceedings)
+ .innerJoin(cases, eq(proceedings.caseId, cases.id))
+ .where(eq(proceedings.tenantId, tenantId))
+ .orderBy(desc(proceedings.createdAt));
+
+ return (
+
+
+
+ Bühnenschiedsverfahren (BSchGO) und Arbeitsgerichtsverfahren (ArbGG) verwalten.
+
+
+
+ {proceedingList.length === 0 ? (
+
+
Noch keine Verfahren angelegt.
+
Erstellen Sie ein Verfahren über die API: POST /api/proceedings
+
+ ) : (
+
+ {proceedingList.map((p) => (
+
+
+
+
+ {p.caseNumber} — {p.caseTitle}
+
+
+ {TYPE_LABELS[p.type] ?? p.type}
+
+
+
+ {STATUS_LABELS[p.status] ?? p.status}
+
+
+ {p.filingDate && (
+
+ Eingereicht: {new Date(p.filingDate).toLocaleDateString('de-DE')}
+
+ )}
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/src/app/(dashboard)/vertraege/page.tsx b/src/app/(dashboard)/vertraege/page.tsx
new file mode 100644
index 0000000..7fee6e7
--- /dev/null
+++ b/src/app/(dashboard)/vertraege/page.tsx
@@ -0,0 +1,78 @@
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth';
+import { db } from '@/lib/db';
+import { contractDocuments, cases } from '@/lib/db/schema';
+import { eq, desc } from 'drizzle-orm';
+import VertragUpload from './vertrag-upload';
+
+const STATUS_LABELS: Record = {
+ uploaded: 'Hochgeladen',
+ extracting: 'Wird extrahiert',
+ extracted: 'Extrahiert',
+ analyzing: 'Wird analysiert',
+ completed: 'Abgeschlossen',
+ failed: 'Fehlgeschlagen',
+};
+
+const STATUS_COLORS: Record = {
+ uploaded: 'bg-muted/10 text-muted',
+ extracting: 'bg-warning/10 text-warning',
+ extracted: 'bg-warning/10 text-warning',
+ analyzing: 'bg-primary/10 text-primary',
+ completed: 'bg-success/10 text-success',
+ failed: 'bg-danger/10 text-danger',
+};
+
+export default async function VertraegePage() {
+ const session = await getServerSession(authOptions);
+ const tenantId = session!.user.tenantId;
+
+ const documents = await db
+ .select({
+ id: contractDocuments.id,
+ filename: contractDocuments.filename,
+ mimeType: contractDocuments.mimeType,
+ status: contractDocuments.status,
+ createdAt: contractDocuments.createdAt,
+ })
+ .from(contractDocuments)
+ .where(eq(contractDocuments.tenantId, tenantId))
+ .orderBy(desc(contractDocuments.createdAt));
+
+ return (
+
+
+
+ Vertragsdokumente hochladen und KI-gestützt auf Klauseln analysieren.
+
+
+
+
+
+ {documents.length === 0 ? (
+
+
Noch keine Vertragsdokumente hochgeladen.
+
+ ) : (
+
+ {documents.map((doc) => (
+
+
+
+
+
{doc.filename}
+
{doc.mimeType?.includes('pdf') ? 'PDF' : 'DOCX'} — {new Date(doc.createdAt).toLocaleDateString('de-DE')}
+
+
+
+ {STATUS_LABELS[doc.status] ?? doc.status}
+
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/src/app/(dashboard)/vertraege/vertrag-upload.tsx b/src/app/(dashboard)/vertraege/vertrag-upload.tsx
new file mode 100644
index 0000000..9ce6741
--- /dev/null
+++ b/src/app/(dashboard)/vertraege/vertrag-upload.tsx
@@ -0,0 +1,69 @@
+'use client';
+
+import { useState, useRef } from 'react';
+
+export default function VertragUpload() {
+ const [uploading, setUploading] = useState(false);
+ const [error, setError] = useState('');
+ const [success, setSuccess] = useState('');
+ const fileRef = useRef(null);
+
+ async function handleUpload(e: React.FormEvent) {
+ e.preventDefault();
+ const file = fileRef.current?.files?.[0];
+ if (!file) return;
+
+ setError('');
+ setSuccess('');
+ setUploading(true);
+
+ try {
+ const formData = new FormData();
+ formData.append('file', file);
+
+ const res = await fetch('/api/contracts', {
+ 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 = '';
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten');
+ } finally {
+ setUploading(false);
+ }
+ }
+
+ return (
+
+ );
+}
diff --git a/src/app/api/analyses/[id]/route.ts b/src/app/api/analyses/[id]/route.ts
new file mode 100644
index 0000000..2e7afe9
--- /dev/null
+++ b/src/app/api/analyses/[id]/route.ts
@@ -0,0 +1,75 @@
+// GET /api/analyses/:id — Retrieve a single analysis with its sources
+
+import { type NextRequest } from 'next/server';
+import { db } from '@/lib/db';
+import { analyses, norms, normInstruments, decisions } from '@/lib/db/schema';
+import { eq, inArray } from 'drizzle-orm';
+
+export async function GET(
+ request: NextRequest,
+ { params }: { params: Promise<{ id: string }> },
+) {
+ 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 });
+ }
+
+ const [analysis] = await db
+ .select()
+ .from(analyses)
+ .where(eq(analyses.id, id))
+ .limit(1);
+
+ if (!analysis) {
+ return Response.json({ error: 'Analysis not found' }, { status: 404 });
+ }
+
+ if (analysis.tenantId !== tenantId) {
+ return Response.json({ error: 'Forbidden' }, { status: 403 });
+ }
+
+ // Fetch referenced norms and decisions
+ const sources = analysis.sources as {
+ normIds: string[];
+ decisionIds: string[];
+ otherSources: string[];
+ } | null;
+
+ let referencedNorms: any[] = [];
+ let referencedDecisions: any[] = [];
+
+ if (sources?.normIds?.length) {
+ referencedNorms = await db
+ .select({
+ id: norms.id,
+ paragraph: norms.paragraph,
+ title: norms.title,
+ instrumentAbbreviation: normInstruments.abbreviation,
+ sourceRank: normInstruments.sourceRank,
+ })
+ .from(norms)
+ .innerJoin(normInstruments, eq(norms.instrumentId, normInstruments.id))
+ .where(inArray(norms.id, sources.normIds));
+ }
+
+ if (sources?.decisionIds?.length) {
+ referencedDecisions = await db
+ .select({
+ id: decisions.id,
+ caseReference: decisions.caseReference,
+ court: decisions.court,
+ decisionDate: decisions.decisionDate,
+ headnote: decisions.headnote,
+ })
+ .from(decisions)
+ .where(inArray(decisions.id, sources.decisionIds));
+ }
+
+ return Response.json({
+ ...analysis,
+ referencedNorms,
+ referencedDecisions,
+ });
+}
diff --git a/src/app/api/analyses/route.ts b/src/app/api/analyses/route.ts
new file mode 100644
index 0000000..93a2532
--- /dev/null
+++ b/src/app/api/analyses/route.ts
@@ -0,0 +1,79 @@
+// POST /api/analyses — Create and stream a new AI analysis
+// GET /api/analyses — List analyses for the current tenant
+
+import { type NextRequest } from 'next/server';
+import { db } from '@/lib/db';
+import { analyses } from '@/lib/db/schema';
+import { eq, desc } from 'drizzle-orm';
+import { runAnalysis } from '@/lib/ai/analysis';
+import { AnalyseMode } from '@/types';
+
+const VALID_MODES = new Set(Object.values(AnalyseMode));
+
+export async function POST(request: NextRequest) {
+ // TODO: Replace with real auth once DPO implements AIIA-19
+ const tenantId = request.headers.get('x-tenant-id');
+ const userId = request.headers.get('x-user-id');
+
+ if (!tenantId || !userId) {
+ return Response.json(
+ { error: 'Missing x-tenant-id or x-user-id header' },
+ { status: 401 },
+ );
+ }
+
+ const body = await request.json();
+ const { mode, title, query, caseId, normIds, decisionIds, stichtag } = body;
+
+ if (!mode || !VALID_MODES.has(mode)) {
+ return Response.json(
+ { error: `Invalid mode. Must be one of: ${[...VALID_MODES].join(', ')}` },
+ { status: 400 },
+ );
+ }
+
+ if (!title || !query) {
+ return Response.json(
+ { error: 'title and query are required' },
+ { status: 400 },
+ );
+ }
+
+ const { analysisId, stream } = await runAnalysis({
+ tenantId,
+ userId,
+ caseId,
+ mode,
+ title,
+ query,
+ normIds,
+ decisionIds,
+ stichtag,
+ });
+
+ // Return streaming response with analysis ID in header
+ const response = stream.toTextStreamResponse();
+ response.headers.set('X-Analysis-Id', analysisId);
+ return response;
+}
+
+export async function GET(request: NextRequest) {
+ const tenantId = request.headers.get('x-tenant-id');
+ if (!tenantId) {
+ return Response.json({ error: 'Missing x-tenant-id header' }, { status: 401 });
+ }
+
+ 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 results = await db
+ .select()
+ .from(analyses)
+ .where(eq(analyses.tenantId, tenantId))
+ .orderBy(desc(analyses.createdAt))
+ .limit(limit)
+ .offset(offset);
+
+ return Response.json(results);
+}
diff --git a/src/app/api/analyses/structured/route.ts b/src/app/api/analyses/structured/route.ts
new file mode 100644
index 0000000..f7570c5
--- /dev/null
+++ b/src/app/api/analyses/structured/route.ts
@@ -0,0 +1,66 @@
+// POST /api/analyses/structured — Create a structured (JSON) analysis
+// Returns typed JSON output per analysis mode schema for frontend rendering
+
+import { type NextRequest } from 'next/server';
+import { runStructuredAnalysis } from '@/lib/ai/structured-analysis';
+import { AnalyseMode } from '@/types';
+
+const VALID_MODES = new Set(Object.values(AnalyseMode));
+
+export async function POST(request: NextRequest) {
+ const tenantId = request.headers.get('x-tenant-id');
+ const userId = request.headers.get('x-user-id');
+
+ if (!tenantId || !userId) {
+ return Response.json(
+ { error: 'Missing x-tenant-id or x-user-id header' },
+ { status: 401 },
+ );
+ }
+
+ const body = await request.json();
+ const {
+ mode,
+ title,
+ query,
+ caseId,
+ normIds,
+ decisionIds,
+ stichtag,
+ additionalContext,
+ } = body;
+
+ if (!mode || !VALID_MODES.has(mode)) {
+ return Response.json(
+ { error: `Invalid mode. Must be one of: ${[...VALID_MODES].join(', ')}` },
+ { status: 400 },
+ );
+ }
+
+ if (!title || !query) {
+ return Response.json(
+ { error: 'title and query are required' },
+ { status: 400 },
+ );
+ }
+
+ try {
+ const result = await runStructuredAnalysis({
+ tenantId,
+ userId,
+ caseId,
+ mode,
+ title,
+ query,
+ normIds,
+ decisionIds,
+ stichtag,
+ additionalContext,
+ });
+
+ return Response.json(result);
+ } catch (err) {
+ const message = err instanceof Error ? err.message : 'Analyse fehlgeschlagen';
+ return Response.json({ error: message }, { status: 500 });
+ }
+}
diff --git a/src/app/api/auth/[...nextauth]/route.ts b/src/app/api/auth/[...nextauth]/route.ts
new file mode 100644
index 0000000..6011195
--- /dev/null
+++ b/src/app/api/auth/[...nextauth]/route.ts
@@ -0,0 +1,5 @@
+import NextAuth from 'next-auth';
+import { authOptions } from '@/lib/auth';
+
+const handler = NextAuth(authOptions);
+export { handler as GET, handler as POST };
diff --git a/src/app/api/contracts/[id]/analyze/route.ts b/src/app/api/contracts/[id]/analyze/route.ts
new file mode 100644
index 0000000..7fb04b6
--- /dev/null
+++ b/src/app/api/contracts/[id]/analyze/route.ts
@@ -0,0 +1,73 @@
+// POST /api/contracts/:id/analyze — Trigger text extraction and clause analysis
+
+import { type NextRequest } from 'next/server';
+import { db } from '@/lib/db';
+import { contractDocuments } from '@/lib/db/schema';
+import { eq } from 'drizzle-orm';
+import { extractDocumentText, analyzeContractClauses } from '@/lib/contracts';
+
+export async function POST(
+ request: NextRequest,
+ { params }: { params: Promise<{ id: string }> },
+) {
+ const tenantId = request.headers.get('x-tenant-id');
+ const userId = request.headers.get('x-user-id');
+
+ if (!tenantId || !userId) {
+ return Response.json(
+ { error: 'Missing x-tenant-id or x-user-id header' },
+ { status: 401 },
+ );
+ }
+
+ const { id } = await params;
+
+ const [doc] = await db
+ .select()
+ .from(contractDocuments)
+ .where(eq(contractDocuments.id, id))
+ .limit(1);
+
+ if (!doc) {
+ return Response.json({ error: 'Dokument nicht gefunden' }, { status: 404 });
+ }
+
+ if (doc.tenantId !== tenantId) {
+ return Response.json({ error: 'Zugriff verweigert' }, { status: 403 });
+ }
+
+ if (doc.status === 'analyzing' || doc.status === 'extracting') {
+ return Response.json(
+ { error: 'Analyse läuft bereits', status: doc.status },
+ { status: 409 },
+ );
+ }
+
+ try {
+ // Step 1: Extract text if not yet done
+ if (!doc.extractedText) {
+ await extractDocumentText(id);
+ }
+
+ // Step 2: Run clause analysis
+ await analyzeContractClauses(id);
+
+ return Response.json({
+ documentId: id,
+ status: 'completed',
+ message: 'Vertragsanalyse abgeschlossen',
+ });
+ } catch (err) {
+ const message = err instanceof Error ? err.message : 'Analyse fehlgeschlagen';
+ await db
+ .update(contractDocuments)
+ .set({
+ status: 'failed',
+ errorMessage: message,
+ updatedAt: new Date(),
+ })
+ .where(eq(contractDocuments.id, id));
+
+ return Response.json({ error: message }, { status: 500 });
+ }
+}
diff --git a/src/app/api/contracts/[id]/route.ts b/src/app/api/contracts/[id]/route.ts
new file mode 100644
index 0000000..4e113e6
--- /dev/null
+++ b/src/app/api/contracts/[id]/route.ts
@@ -0,0 +1,46 @@
+// GET /api/contracts/:id — Get contract document with analysis results
+
+import { type NextRequest } from 'next/server';
+import { getContractAnalysis } from '@/lib/contracts';
+
+export async function GET(
+ request: NextRequest,
+ { params }: { params: Promise<{ id: string }> },
+) {
+ const tenantId = request.headers.get('x-tenant-id');
+ if (!tenantId) {
+ return Response.json({ error: 'Missing x-tenant-id header' }, { status: 401 });
+ }
+
+ const { id } = await params;
+ const result = await getContractAnalysis(id);
+
+ if (!result) {
+ return Response.json({ error: 'Dokument nicht gefunden' }, { status: 404 });
+ }
+
+ if (result.document.tenantId !== tenantId) {
+ return Response.json({ error: 'Zugriff verweigert' }, { status: 403 });
+ }
+
+ // Omit extracted text and storage path from response for security
+ const { storagePath, extractedText, ...documentMeta } = result.document;
+
+ return Response.json({
+ document: documentMeta,
+ clauses: result.clauses,
+ summary: {
+ totalClauses: result.clauses.length,
+ standard: result.clauses.filter((c) => c.rating === 'standard').length,
+ abweichend: result.clauses.filter((c) => c.rating === 'abweichend').length,
+ kritisch: result.clauses.filter((c) => c.rating === 'kritisch').length,
+ averageRiskScore:
+ result.clauses.length > 0
+ ? Math.round(
+ result.clauses.reduce((sum, c) => sum + (c.riskScore ?? 0), 0) /
+ result.clauses.length,
+ )
+ : 0,
+ },
+ });
+}
diff --git a/src/app/api/contracts/route.ts b/src/app/api/contracts/route.ts
new file mode 100644
index 0000000..a7516e5
--- /dev/null
+++ b/src/app/api/contracts/route.ts
@@ -0,0 +1,67 @@
+// POST /api/contracts — Upload a contract document
+// GET /api/contracts — List contract documents for the current tenant
+
+import { type NextRequest } from 'next/server';
+import {
+ uploadContractDocument,
+ listContractDocuments,
+} from '@/lib/contracts';
+
+const MAX_UPLOAD_SIZE = 10 * 1024 * 1024; // 10 MB
+
+export async function POST(request: NextRequest) {
+ const tenantId = request.headers.get('x-tenant-id');
+ const userId = request.headers.get('x-user-id');
+
+ if (!tenantId || !userId) {
+ return Response.json(
+ { error: 'Missing x-tenant-id or x-user-id header' },
+ { status: 401 },
+ );
+ }
+
+ const formData = await request.formData();
+ const file = formData.get('file');
+ const caseId = formData.get('caseId') as string | null;
+
+ if (!file || !(file instanceof File)) {
+ return Response.json(
+ { error: 'Keine Datei hochgeladen. Feld "file" erwartet.' },
+ { status: 400 },
+ );
+ }
+
+ if (file.size > MAX_UPLOAD_SIZE) {
+ return Response.json(
+ { error: `Datei zu groß. Maximum: ${MAX_UPLOAD_SIZE / 1024 / 1024} MB.` },
+ { status: 413 },
+ );
+ }
+
+ try {
+ const result = await uploadContractDocument(
+ tenantId,
+ userId,
+ file,
+ caseId ?? undefined,
+ );
+ 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 tenantId = request.headers.get('x-tenant-id');
+ if (!tenantId) {
+ return Response.json({ error: 'Missing x-tenant-id header' }, { status: 401 });
+ }
+
+ 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 documents = await listContractDocuments(tenantId, limit, offset);
+ return Response.json(documents);
+}
diff --git a/src/app/api/decisions/[id]/norms/route.ts b/src/app/api/decisions/[id]/norms/route.ts
new file mode 100644
index 0000000..ff442b3
--- /dev/null
+++ b/src/app/api/decisions/[id]/norms/route.ts
@@ -0,0 +1,92 @@
+// GET /api/decisions/:id/norms — list norms applied in a decision
+// POST /api/decisions/:id/norms — link a norm to a decision
+
+import { db } from "@/lib/db";
+import { decisionNorms, decisions } from "@/lib/db/schema";
+import { eq } from "drizzle-orm";
+import type { NextRequest } from "next/server";
+
+export async function GET(
+ _request: NextRequest,
+ ctx: RouteContext<"/api/decisions/[id]/norms">,
+) {
+ const { id } = await ctx.params;
+
+ // Verify decision exists
+ const decision = await db.query.decisions.findFirst({
+ where: eq(decisions.id, id),
+ columns: { id: true },
+ });
+
+ if (!decision) {
+ return Response.json({ error: "Decision not found." }, { status: 404 });
+ }
+
+ const links = await db.query.decisionNorms.findMany({
+ where: eq(decisionNorms.decisionId, id),
+ with: {
+ norm: {
+ with: {
+ instrument: true,
+ },
+ },
+ },
+ });
+
+ return Response.json({
+ decisionId: id,
+ norms: links,
+ count: links.length,
+ });
+}
+
+export async function POST(
+ request: NextRequest,
+ ctx: RouteContext<"/api/decisions/[id]/norms">,
+) {
+ const { id } = await ctx.params;
+
+ let body: { normId: string; applicationType?: string; passage?: string };
+ try {
+ body = await request.json();
+ } catch {
+ return Response.json({ error: "Invalid JSON body." }, { status: 400 });
+ }
+
+ if (!body.normId) {
+ return Response.json(
+ { error: "normId is required." },
+ { status: 400 },
+ );
+ }
+
+ // Verify decision exists
+ const decision = await db.query.decisions.findFirst({
+ where: eq(decisions.id, id),
+ columns: { id: true },
+ });
+
+ if (!decision) {
+ return Response.json({ error: "Decision not found." }, { status: 404 });
+ }
+
+ const [created] = await db
+ .insert(decisionNorms)
+ .values({
+ decisionId: id,
+ normId: body.normId,
+ applicationType: body.applicationType ?? "angewendet",
+ passage: body.passage ?? null,
+ })
+ .onConflictDoNothing()
+ .returning();
+
+ if (!created) {
+ return Response.json(
+ { error: "Link already exists." },
+ { status: 409 },
+ );
+ }
+
+ return Response.json({ decisionNorm: created }, { status: 201 });
+}
diff --git a/src/app/api/decisions/[id]/references/route.ts b/src/app/api/decisions/[id]/references/route.ts
new file mode 100644
index 0000000..0e8ad10
--- /dev/null
+++ b/src/app/api/decisions/[id]/references/route.ts
@@ -0,0 +1,121 @@
+// GET /api/decisions/:id/references — list decision cross-references (precedents)
+// POST /api/decisions/:id/references — create a cross-reference to another decision
+
+import { db } from "@/lib/db";
+import { decisionReferences, decisions } from "@/lib/db/schema";
+import { eq } from "drizzle-orm";
+import type { NextRequest } from "next/server";
+
+export async function GET(
+ _request: NextRequest,
+ ctx: RouteContext<"/api/decisions/[id]/references">,
+) {
+ const { id } = await ctx.params;
+
+ const decision = await db.query.decisions.findFirst({
+ where: eq(decisions.id, id),
+ columns: { id: true },
+ });
+
+ if (!decision) {
+ return Response.json({ error: "Decision not found." }, { status: 404 });
+ }
+
+ const [outgoing, incoming] = await Promise.all([
+ db.query.decisionReferences.findMany({
+ where: eq(decisionReferences.sourceDecisionId, id),
+ with: { targetDecision: true },
+ }),
+ db.query.decisionReferences.findMany({
+ where: eq(decisionReferences.targetDecisionId, id),
+ with: { sourceDecision: true },
+ }),
+ ]);
+
+ return Response.json({
+ decisionId: id,
+ outgoing,
+ incoming,
+ totalReferences: outgoing.length + incoming.length,
+ });
+}
+
+export async function POST(
+ request: NextRequest,
+ ctx: RouteContext<"/api/decisions/[id]/references">,
+) {
+ const { id } = await ctx.params;
+
+ let body: {
+ targetDecisionId: string;
+ referenceType: string;
+ description?: string;
+ };
+ try {
+ body = await request.json();
+ } catch {
+ return Response.json({ error: "Invalid JSON body." }, { status: 400 });
+ }
+
+ if (!body.targetDecisionId || !body.referenceType) {
+ return Response.json(
+ { error: "targetDecisionId and referenceType are required." },
+ { status: 400 },
+ );
+ }
+
+ const validTypes = ["bestaetigt", "abweicht", "aufgehoben", "zitiert"];
+ if (!validTypes.includes(body.referenceType)) {
+ return Response.json(
+ {
+ error: `referenceType must be one of: ${validTypes.join(", ")}`,
+ },
+ { status: 400 },
+ );
+ }
+
+ // Verify both decisions exist
+ const [source, target] = await Promise.all([
+ db.query.decisions.findFirst({
+ where: eq(decisions.id, id),
+ columns: { id: true },
+ }),
+ db.query.decisions.findFirst({
+ where: eq(decisions.id, body.targetDecisionId),
+ columns: { id: true },
+ }),
+ ]);
+
+ if (!source) {
+ return Response.json(
+ { error: "Source decision not found." },
+ { status: 404 },
+ );
+ }
+ if (!target) {
+ return Response.json(
+ { error: "Target decision not found." },
+ { status: 404 },
+ );
+ }
+
+ const [created] = await db
+ .insert(decisionReferences)
+ .values({
+ sourceDecisionId: id,
+ targetDecisionId: body.targetDecisionId,
+ referenceType: body.referenceType,
+ description: body.description ?? null,
+ })
+ .onConflictDoNothing()
+ .returning();
+
+ if (!created) {
+ return Response.json(
+ { error: "Reference already exists." },
+ { status: 409 },
+ );
+ }
+
+ return Response.json({ reference: created }, { status: 201 });
+}
diff --git a/src/app/api/decisions/[id]/route.ts b/src/app/api/decisions/[id]/route.ts
new file mode 100644
index 0000000..8e7671b
--- /dev/null
+++ b/src/app/api/decisions/[id]/route.ts
@@ -0,0 +1,128 @@
+// GET /api/decisions/:id — get a single decision with relations
+// PATCH /api/decisions/:id — update a decision
+// DELETE /api/decisions/:id — delete a decision
+
+import { db } from "@/lib/db";
+import { decisions } from "@/lib/db/schema";
+import { eq } from "drizzle-orm";
+import type { NextRequest } from "next/server";
+
+export async function GET(
+ _request: NextRequest,
+ ctx: RouteContext<"/api/decisions/[id]">,
+) {
+ const { id } = await ctx.params;
+
+ const decision = await db.query.decisions.findFirst({
+ where: eq(decisions.id, id),
+ with: {
+ tribunal: true,
+ appliedNorms: {
+ with: {
+ norm: {
+ with: {
+ instrument: true,
+ },
+ },
+ },
+ },
+ outgoingReferences: {
+ with: {
+ targetDecision: true,
+ },
+ },
+ incomingReferences: {
+ with: {
+ sourceDecision: true,
+ },
+ },
+ },
+ });
+
+ if (!decision) {
+ return Response.json({ error: "Decision not found." }, { status: 404 });
+ }
+
+ return Response.json({ decision });
+}
+
+export async function PATCH(
+ request: NextRequest,
+ ctx: RouteContext<"/api/decisions/[id]">,
+) {
+ const { id } = await ctx.params;
+
+ let body: Record;
+ try {
+ body = await request.json();
+ } catch {
+ return Response.json({ error: "Invalid JSON body." }, { status: 400 });
+ }
+
+ // Only allow updating specific fields
+ const allowedFields = [
+ "type",
+ "caseReference",
+ "decisionDate",
+ "court",
+ "tribunalId",
+ "chamber",
+ "headnote",
+ "tenor",
+ "facts",
+ "reasoning",
+ "fullText",
+ "domains",
+ "keywords",
+ "publicationSource",
+ "isPublished",
+ "isAnonymized",
+ "metadata",
+ ] as const;
+
+ const updates: Record = {};
+ for (const key of allowedFields) {
+ if (key in body) {
+ updates[key] = body[key];
+ }
+ }
+
+ if (Object.keys(updates).length === 0) {
+ return Response.json(
+ { error: "No valid fields to update." },
+ { status: 400 },
+ );
+ }
+
+ updates.updatedAt = new Date();
+
+ const [updated] = await db
+ .update(decisions)
+ .set(updates)
+ .where(eq(decisions.id, id))
+ .returning();
+
+ if (!updated) {
+ return Response.json({ error: "Decision not found." }, { status: 404 });
+ }
+
+ return Response.json({ decision: updated });
+}
+
+export async function DELETE(
+ _request: NextRequest,
+ ctx: RouteContext<"/api/decisions/[id]">,
+) {
+ const { id } = await ctx.params;
+
+ const [deleted] = await db
+ .delete(decisions)
+ .where(eq(decisions.id, id))
+ .returning({ id: decisions.id });
+
+ if (!deleted) {
+ return Response.json({ error: "Decision not found." }, { status: 404 });
+ }
+
+ return Response.json({ deleted: true, id: deleted.id });
+}
diff --git a/src/app/api/decisions/route.ts b/src/app/api/decisions/route.ts
new file mode 100644
index 0000000..6f5d9ab
--- /dev/null
+++ b/src/app/api/decisions/route.ts
@@ -0,0 +1,161 @@
+// GET /api/decisions — list/search decisions with filters and FTS
+// POST /api/decisions — create a new decision
+
+import { db } from "@/lib/db";
+import { decisions } from "@/lib/db/schema";
+import { eq, and, desc, asc, sql, ilike, type SQL } from "drizzle-orm";
+import type { NextRequest } from "next/server";
+
+export async function GET(request: NextRequest) {
+ const url = new URL(request.url);
+
+ // Pagination
+ const limit = Math.min(parseInt(url.searchParams.get("limit") || "20"), 100);
+ const offset = parseInt(url.searchParams.get("offset") || "0");
+
+ // Full-text search query
+ const q = url.searchParams.get("q");
+
+ // Metadata filters
+ const caseReference = url.searchParams.get("caseReference");
+ const court = url.searchParams.get("court");
+ const type = url.searchParams.get("type");
+ const dateFrom = url.searchParams.get("dateFrom");
+ const dateTo = url.searchParams.get("dateTo");
+ const domain = url.searchParams.get("domain");
+
+ // Build WHERE conditions
+ const conditions: SQL[] = [];
+
+ if (caseReference) {
+ conditions.push(ilike(decisions.caseReference, `%${caseReference}%`));
+ }
+ if (court) {
+ conditions.push(ilike(decisions.court, `%${court}%`));
+ }
+ if (type) {
+ conditions.push(eq(decisions.type, type as any));
+ }
+ if (dateFrom) {
+ conditions.push(sql`${decisions.decisionDate} >= ${dateFrom}`);
+ }
+ if (dateTo) {
+ conditions.push(sql`${decisions.decisionDate} <= ${dateTo}`);
+ }
+ if (domain) {
+ conditions.push(sql`${decisions.domains} @> ${JSON.stringify([domain])}::jsonb`);
+ }
+
+ // Full-text search using PostgreSQL tsvector/tsquery
+ // Searches across headnote, reasoning, and fullText
+ let orderBy: SQL = desc(decisions.decisionDate);
+ if (q) {
+ const tsQuery = sql`plainto_tsquery('german', ${q})`;
+ const tsVector = sql`(
+ setweight(to_tsvector('german', coalesce(${decisions.headnote}, '')), 'A') ||
+ setweight(to_tsvector('german', coalesce(${decisions.reasoning}, '')), 'B') ||
+ setweight(to_tsvector('german', coalesce(${decisions.fullText}, '')), 'C') ||
+ setweight(to_tsvector('german', coalesce(${decisions.tenor}, '')), 'B')
+ )`;
+ conditions.push(sql`${tsVector} @@ ${tsQuery}`);
+ // Order by relevance when searching
+ orderBy = sql`ts_rank(${tsVector}, ${tsQuery}) DESC`;
+ }
+
+ const where = conditions.length > 0 ? and(...conditions) : undefined;
+
+ const [results, countResult] = await Promise.all([
+ db
+ .select()
+ .from(decisions)
+ .where(where)
+ .orderBy(orderBy)
+ .limit(limit)
+ .offset(offset),
+ db
+ .select({ count: sql`count(*)::int` })
+ .from(decisions)
+ .where(where),
+ ]);
+
+ return Response.json({
+ decisions: results,
+ pagination: {
+ total: countResult[0].count,
+ limit,
+ offset,
+ hasMore: offset + limit < countResult[0].count,
+ },
+ });
+}
+
+export async function POST(request: Request) {
+ let body: Record;
+ try {
+ body = await request.json();
+ } catch {
+ return Response.json({ error: "Invalid JSON body." }, { status: 400 });
+ }
+
+ const {
+ tenantId,
+ type,
+ caseReference,
+ decisionDate,
+ court,
+ tribunalId,
+ chamber,
+ headnote,
+ tenor,
+ facts,
+ reasoning,
+ fullText,
+ domains,
+ keywords,
+ publicationSource,
+ isPublished,
+ isAnonymized,
+ metadata,
+ } = body as any;
+
+ // Validate required fields
+ if (!type || !decisionDate || !court) {
+ return Response.json(
+ { error: "type, decisionDate, and court are required." },
+ { status: 400 },
+ );
+ }
+
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(decisionDate)) {
+ return Response.json(
+ { error: "decisionDate must be YYYY-MM-DD." },
+ { status: 400 },
+ );
+ }
+
+ const [created] = await db
+ .insert(decisions)
+ .values({
+ tenantId: tenantId ?? null,
+ type,
+ caseReference: caseReference ?? null,
+ decisionDate,
+ court,
+ tribunalId: tribunalId ?? null,
+ chamber: chamber ?? null,
+ headnote: headnote ?? null,
+ tenor: tenor ?? null,
+ facts: facts ?? null,
+ reasoning: reasoning ?? null,
+ fullText: fullText ?? null,
+ domains: domains ?? [],
+ keywords: keywords ?? [],
+ publicationSource: publicationSource ?? null,
+ isPublished: isPublished ?? false,
+ isAnonymized: isAnonymized ?? false,
+ metadata: metadata ?? null,
+ })
+ .returning();
+
+ return Response.json({ decision: created }, { status: 201 });
+}
diff --git a/src/app/api/norms/[instrumentId]/[paragraph]/route.ts b/src/app/api/norms/[instrumentId]/[paragraph]/route.ts
new file mode 100644
index 0000000..e40b0a3
--- /dev/null
+++ b/src/app/api/norms/[instrumentId]/[paragraph]/route.ts
@@ -0,0 +1,86 @@
+// GET /api/norms/:instrumentId/:paragraph?date=YYYY-MM-DD
+// Returns the valid version of a norm provision for a given Stichtag (effective date).
+// If no date is provided, returns the currently valid version.
+
+import { db } from "@/lib/db";
+import { norms, normInstruments } from "@/lib/db/schema";
+import { QUELLENRANG_ORDER } from "@/lib/norms";
+import { eq, and, lte, or, isNull, desc, sql } from "drizzle-orm";
+import type { NextRequest } from "next/server";
+
+export async function GET(
+ request: NextRequest,
+ ctx: RouteContext<"/api/norms/[instrumentId]/[paragraph]">,
+) {
+ const { instrumentId, paragraph } = await ctx.params;
+
+ const url = new URL(request.url);
+ const dateParam = url.searchParams.get("date");
+ const stichtag = dateParam || new Date().toISOString().split("T")[0];
+
+ // Validate date format
+ if (dateParam && !/^\d{4}-\d{2}-\d{2}$/.test(dateParam)) {
+ return Response.json(
+ { error: "Invalid date format. Use YYYY-MM-DD." },
+ { status: 400 },
+ );
+ }
+
+ // Fetch the instrument for source rank context
+ const instrument = await db.query.normInstruments.findFirst({
+ where: eq(normInstruments.id, instrumentId),
+ });
+
+ if (!instrument) {
+ return Response.json(
+ { error: "Norm instrument not found." },
+ { status: 404 },
+ );
+ }
+
+ // Find the valid version at the Stichtag:
+ // validFrom <= stichtag AND (validTo IS NULL OR validTo >= stichtag)
+ // Order by versionNumber desc to get the latest applicable version.
+ const result = await db.query.norms.findFirst({
+ where: and(
+ eq(norms.instrumentId, instrumentId),
+ eq(norms.paragraph, paragraph),
+ lte(norms.validFrom, stichtag),
+ or(isNull(norms.validTo), sql`${norms.validTo} >= ${stichtag}`),
+ ),
+ orderBy: [desc(norms.versionNumber)],
+ with: {
+ instrument: true,
+ },
+ });
+
+ if (!result) {
+ return Response.json(
+ {
+ error: `No valid version of ${paragraph} found for Stichtag ${stichtag}.`,
+ },
+ { status: 404 },
+ );
+ }
+
+ const sourceRankIndex = QUELLENRANG_ORDER.indexOf(
+ instrument.sourceRank as any,
+ );
+
+ return Response.json({
+ norm: result,
+ stichtag,
+ sourceRank: {
+ rank: instrument.sourceRank,
+ precedenceIndex: sourceRankIndex,
+ label: sourceRankIndex === 0 ? "highest" : `level ${sourceRankIndex}`,
+ },
+ instrument: {
+ id: instrument.id,
+ abbreviation: instrument.abbreviation,
+ fullTitle: instrument.fullTitle,
+ type: instrument.type,
+ sourceRank: instrument.sourceRank,
+ },
+ });
+}
diff --git a/src/app/api/norms/[instrumentId]/route.ts b/src/app/api/norms/[instrumentId]/route.ts
new file mode 100644
index 0000000..d659730
--- /dev/null
+++ b/src/app/api/norms/[instrumentId]/route.ts
@@ -0,0 +1,73 @@
+// GET /api/norms/:instrumentId?date=YYYY-MM-DD
+// Lists all paragraphs of a norm instrument, optionally filtered to a Stichtag.
+
+import { db } from "@/lib/db";
+import { norms, normInstruments } from "@/lib/db/schema";
+import { QUELLENRANG_ORDER } from "@/lib/norms";
+import { eq, and, lte, or, isNull, desc, asc, sql } from "drizzle-orm";
+import type { NextRequest } from "next/server";
+
+export async function GET(
+ request: NextRequest,
+ ctx: RouteContext<"/api/norms/[instrumentId]">,
+) {
+ const { instrumentId } = await ctx.params;
+
+ const url = new URL(request.url);
+ const dateParam = url.searchParams.get("date");
+ const stichtag = dateParam || new Date().toISOString().split("T")[0];
+
+ if (dateParam && !/^\d{4}-\d{2}-\d{2}$/.test(dateParam)) {
+ return Response.json(
+ { error: "Invalid date format. Use YYYY-MM-DD." },
+ { status: 400 },
+ );
+ }
+
+ const instrument = await db.query.normInstruments.findFirst({
+ where: eq(normInstruments.id, instrumentId),
+ });
+
+ if (!instrument) {
+ return Response.json(
+ { error: "Norm instrument not found." },
+ { status: 404 },
+ );
+ }
+
+ // Get all norms for this instrument valid at the Stichtag
+ const allNorms = await db.query.norms.findMany({
+ where: and(
+ eq(norms.instrumentId, instrumentId),
+ lte(norms.validFrom, stichtag),
+ or(isNull(norms.validTo), sql`${norms.validTo} >= ${stichtag}`),
+ ),
+ orderBy: [asc(norms.paragraph), desc(norms.versionNumber)],
+ });
+
+ // Deduplicate: keep only the latest version per paragraph
+ const latestByParagraph = new Map();
+ for (const norm of allNorms) {
+ if (!latestByParagraph.has(norm.paragraph)) {
+ latestByParagraph.set(norm.paragraph, norm);
+ }
+ }
+
+ const sourceRankIndex = QUELLENRANG_ORDER.indexOf(
+ instrument.sourceRank as any,
+ );
+
+ return Response.json({
+ instrument: {
+ id: instrument.id,
+ abbreviation: instrument.abbreviation,
+ fullTitle: instrument.fullTitle,
+ type: instrument.type,
+ sourceRank: instrument.sourceRank,
+ sourceRankPrecedence: sourceRankIndex,
+ },
+ stichtag,
+ norms: Array.from(latestByParagraph.values()),
+ count: latestByParagraph.size,
+ });
+}
diff --git a/src/app/api/norms/import/route.ts b/src/app/api/norms/import/route.ts
new file mode 100644
index 0000000..28376de
--- /dev/null
+++ b/src/app/api/norms/import/route.ts
@@ -0,0 +1,124 @@
+// POST /api/norms/import
+// Bulk import norm provisions from JSON.
+// Supports NV Bühne, BSchGO, ArbGG, and other instruments.
+//
+// Body: {
+// instrumentId: string, // existing instrument ID
+// provisions: Array<{
+// paragraph: string, // e.g. "§ 53"
+// subsection?: string,
+// title?: string,
+// body: string,
+// validFrom: string, // YYYY-MM-DD
+// validTo?: string,
+// versionNumber?: number,
+// previousVersionId?: string,
+// domains?: string[],
+// notes?: string,
+// }>
+// }
+
+import { db } from "@/lib/db";
+import { norms, normInstruments } from "@/lib/db/schema";
+import { eq } from "drizzle-orm";
+
+interface ImportProvision {
+ paragraph: string;
+ subsection?: string;
+ title?: string;
+ body: string;
+ validFrom: string;
+ validTo?: string;
+ versionNumber?: number;
+ previousVersionId?: string;
+ domains?: string[];
+ notes?: string;
+}
+
+interface ImportRequest {
+ instrumentId: string;
+ tenantId?: string | null;
+ provisions: ImportProvision[];
+}
+
+export async function POST(request: Request) {
+ let body: ImportRequest;
+ try {
+ body = await request.json();
+ } catch {
+ return Response.json({ error: "Invalid JSON body." }, { status: 400 });
+ }
+
+ const { instrumentId, tenantId, provisions } = body;
+
+ if (!instrumentId || !Array.isArray(provisions) || provisions.length === 0) {
+ return Response.json(
+ { error: "instrumentId and non-empty provisions array required." },
+ { status: 400 },
+ );
+ }
+
+ // Validate instrument exists
+ const instrument = await db.query.normInstruments.findFirst({
+ where: eq(normInstruments.id, instrumentId),
+ });
+
+ if (!instrument) {
+ return Response.json(
+ { error: "Norm instrument not found." },
+ { status: 404 },
+ );
+ }
+
+ // Validate each provision
+ const errors: string[] = [];
+ for (let i = 0; i < provisions.length; i++) {
+ const p = provisions[i];
+ if (!p.paragraph) errors.push(`provisions[${i}]: paragraph is required`);
+ if (!p.body) errors.push(`provisions[${i}]: body is required`);
+ if (!p.validFrom || !/^\d{4}-\d{2}-\d{2}$/.test(p.validFrom)) {
+ errors.push(`provisions[${i}]: validFrom must be YYYY-MM-DD`);
+ }
+ if (p.validTo && !/^\d{4}-\d{2}-\d{2}$/.test(p.validTo)) {
+ errors.push(`provisions[${i}]: validTo must be YYYY-MM-DD`);
+ }
+ }
+
+ if (errors.length > 0) {
+ return Response.json(
+ { error: "Validation failed.", details: errors },
+ { status: 400 },
+ );
+ }
+
+ // Insert all provisions
+ const inserted = await db
+ .insert(norms)
+ .values(
+ provisions.map((p) => ({
+ tenantId: tenantId ?? null,
+ instrumentId,
+ paragraph: p.paragraph,
+ subsection: p.subsection ?? null,
+ title: p.title ?? null,
+ body: p.body,
+ validFrom: p.validFrom,
+ validTo: p.validTo ?? null,
+ previousVersionId: p.previousVersionId ?? null,
+ versionNumber: p.versionNumber ?? 1,
+ domains: p.domains ?? [],
+ notes: p.notes ?? null,
+ })),
+ )
+ .returning();
+
+ return Response.json(
+ {
+ imported: inserted.length,
+ instrumentId,
+ instrumentAbbreviation: instrument.abbreviation,
+ provisions: inserted,
+ },
+ { status: 201 },
+ );
+}
diff --git a/src/app/api/proceedings/[id]/advance/route.ts b/src/app/api/proceedings/[id]/advance/route.ts
new file mode 100644
index 0000000..3173894
--- /dev/null
+++ b/src/app/api/proceedings/[id]/advance/route.ts
@@ -0,0 +1,161 @@
+// POST /api/proceedings/:id/advance — advance a proceeding to the next step.
+// Completes the current step, activates the next, and creates its deadlines.
+
+import { db } from "@/lib/db";
+import {
+ proceedings,
+ proceedingSteps,
+ proceedingDeadlines,
+} from "@/lib/db/schema";
+import { eq, and } from "drizzle-orm";
+import { advanceStep } from "@/lib/proceedings";
+import type { NextRequest } from "next/server";
+
+export async function POST(
+ request: NextRequest,
+ ctx: RouteContext<"/api/proceedings/[id]/advance">,
+) {
+ const { id } = await ctx.params;
+
+ // Optional body to set completion date, notes
+ let body: Record = {};
+ try {
+ body = await request.json();
+ } catch {
+ // no body is fine
+ }
+
+ const activationDate =
+ (body.activationDate as string) ||
+ new Date().toISOString().slice(0, 10);
+
+ // Load proceeding
+ const proceeding = await db.query.proceedings.findFirst({
+ where: eq(proceedings.id, id),
+ });
+
+ if (!proceeding) {
+ return Response.json({ error: "Proceeding not found." }, { status: 404 });
+ }
+
+ if (!proceeding.currentStepKey) {
+ return Response.json(
+ { error: "Proceeding has no active step to advance from." },
+ { status: 400 },
+ );
+ }
+
+ if (
+ proceeding.status === "abgeschlossen" ||
+ proceeding.status === "entschieden"
+ ) {
+ return Response.json(
+ { error: "Proceeding is already completed." },
+ { status: 400 },
+ );
+ }
+
+ // Compute next step from workflow template
+ const result = advanceStep(
+ proceeding.type,
+ proceeding.currentStepKey,
+ activationDate,
+ );
+
+ // Mark current step as completed
+ await db
+ .update(proceedingSteps)
+ .set({
+ status: "abgeschlossen",
+ completedAt: new Date(),
+ notes: (body.stepNotes as string) ?? null,
+ })
+ .where(
+ and(
+ eq(proceedingSteps.proceedingId, id),
+ eq(proceedingSteps.stepKey, proceeding.currentStepKey),
+ ),
+ );
+
+ if (result.nextStepKey) {
+ // Activate next step
+ await db
+ .update(proceedingSteps)
+ .set({ status: "aktiv" })
+ .where(
+ and(
+ eq(proceedingSteps.proceedingId, id),
+ eq(proceedingSteps.stepKey, result.nextStepKey),
+ ),
+ );
+
+ // Update proceeding's current step
+ await db
+ .update(proceedings)
+ .set({
+ currentStepKey: result.nextStepKey,
+ status: "laufend",
+ updatedAt: new Date(),
+ })
+ .where(eq(proceedings.id, id));
+
+ // Create deadlines for the new step
+ let insertedDeadlines: any[] = [];
+ if (result.deadlines.length > 0) {
+ // Find the step record for the next step
+ const nextStepRecord = await db.query.proceedingSteps.findFirst({
+ where: and(
+ eq(proceedingSteps.proceedingId, id),
+ eq(proceedingSteps.stepKey, result.nextStepKey),
+ ),
+ });
+
+ if (nextStepRecord) {
+ insertedDeadlines = await db
+ .insert(proceedingDeadlines)
+ .values(
+ result.deadlines.map((d) => ({
+ proceedingId: id,
+ stepId: nextStepRecord.id,
+ type: d.type,
+ label: d.label,
+ description: d.description,
+ dueDate: d.dueDate,
+ warningDate: d.warningDate,
+ warningDaysBefore: d.warningDaysBefore,
+ isCalculated: d.isCalculated,
+ calculationBasis: d.calculationBasis,
+ legalBasis: d.legalBasis,
+ })),
+ )
+ .returning();
+ }
+ }
+
+ return Response.json({
+ advanced: true,
+ previousStep: proceeding.currentStepKey,
+ currentStep: result.nextStepKey,
+ deadlines: insertedDeadlines,
+ });
+ }
+
+ // No next step — proceeding is complete
+ await db
+ .update(proceedings)
+ .set({
+ currentStepKey: null,
+ status: "abgeschlossen",
+ closedAt: new Date(),
+ updatedAt: new Date(),
+ })
+ .where(eq(proceedings.id, id));
+
+ return Response.json({
+ advanced: true,
+ previousStep: proceeding.currentStepKey,
+ currentStep: null,
+ completed: true,
+ deadlines: [],
+ });
+}
diff --git a/src/app/api/proceedings/[id]/deadlines/route.ts b/src/app/api/proceedings/[id]/deadlines/route.ts
new file mode 100644
index 0000000..fcd2744
--- /dev/null
+++ b/src/app/api/proceedings/[id]/deadlines/route.ts
@@ -0,0 +1,72 @@
+// GET /api/proceedings/:id/deadlines — list deadlines for a proceeding with filters
+// Supports filtering by: completed, overdue, upcoming (within N days), stepKey
+
+import { db } from "@/lib/db";
+import { proceedingDeadlines, proceedings } from "@/lib/db/schema";
+import { eq, and, sql, type SQL } from "drizzle-orm";
+import type { NextRequest } from "next/server";
+
+export async function GET(
+ request: NextRequest,
+ ctx: RouteContext<"/api/proceedings/[id]/deadlines">,
+) {
+ const { id } = await ctx.params;
+ const url = new URL(request.url);
+
+ // Verify proceeding exists
+ const proceeding = await db.query.proceedings.findFirst({
+ where: eq(proceedings.id, id),
+ });
+
+ if (!proceeding) {
+ return Response.json({ error: "Proceeding not found." }, { status: 404 });
+ }
+
+ const conditions: SQL[] = [eq(proceedingDeadlines.proceedingId, id)];
+
+ // Filter: completed / pending
+ const completed = url.searchParams.get("completed");
+ if (completed === "true") {
+ conditions.push(eq(proceedingDeadlines.isCompleted, true));
+ } else if (completed === "false") {
+ conditions.push(eq(proceedingDeadlines.isCompleted, false));
+ }
+
+ // Filter: overdue (due_date < today AND not completed)
+ const overdue = url.searchParams.get("overdue");
+ if (overdue === "true") {
+ conditions.push(
+ sql`${proceedingDeadlines.dueDate} < CURRENT_DATE`,
+ );
+ conditions.push(eq(proceedingDeadlines.isCompleted, false));
+ }
+
+ // Filter: upcoming within N days
+ const upcoming = url.searchParams.get("upcoming");
+ if (upcoming) {
+ const days = parseInt(upcoming);
+ if (!isNaN(days) && days > 0) {
+ conditions.push(
+ sql`${proceedingDeadlines.dueDate} >= CURRENT_DATE`,
+ );
+ conditions.push(
+ sql`${proceedingDeadlines.dueDate} <= CURRENT_DATE + ${days}::int`,
+ );
+ conditions.push(eq(proceedingDeadlines.isCompleted, false));
+ }
+ }
+
+ // Filter: by step
+ const stepId = url.searchParams.get("stepId");
+ if (stepId) {
+ conditions.push(eq(proceedingDeadlines.stepId, stepId));
+ }
+
+ const deadlines = await db
+ .select()
+ .from(proceedingDeadlines)
+ .where(and(...conditions))
+ .orderBy(sql`${proceedingDeadlines.dueDate} ASC`);
+
+ return Response.json({ deadlines });
+}
diff --git a/src/app/api/proceedings/[id]/route.ts b/src/app/api/proceedings/[id]/route.ts
new file mode 100644
index 0000000..3fe162e
--- /dev/null
+++ b/src/app/api/proceedings/[id]/route.ts
@@ -0,0 +1,113 @@
+// GET /api/proceedings/:id — get a proceeding with steps and deadlines
+// PATCH /api/proceedings/:id — update a proceeding
+// DELETE /api/proceedings/:id — delete a proceeding
+
+import { db } from "@/lib/db";
+import { proceedings } from "@/lib/db/schema";
+import { eq } from "drizzle-orm";
+import type { NextRequest } from "next/server";
+
+export async function GET(
+ _request: NextRequest,
+ ctx: RouteContext<"/api/proceedings/[id]">,
+) {
+ const { id } = await ctx.params;
+
+ const proceeding = await db.query.proceedings.findFirst({
+ where: eq(proceedings.id, id),
+ with: {
+ case: true,
+ tribunal: true,
+ fachgruppe: true,
+ steps: {
+ orderBy: (steps, { asc }) => [asc(steps.sortOrder)],
+ },
+ deadlines: {
+ orderBy: (deadlines, { asc }) => [asc(deadlines.dueDate)],
+ },
+ },
+ });
+
+ if (!proceeding) {
+ return Response.json({ error: "Proceeding not found." }, { status: 404 });
+ }
+
+ return Response.json({ proceeding });
+}
+
+export async function PATCH(
+ request: NextRequest,
+ ctx: RouteContext<"/api/proceedings/[id]">,
+) {
+ const { id } = await ctx.params;
+
+ let body: Record;
+ try {
+ body = await request.json();
+ } catch {
+ return Response.json({ error: "Invalid JSON body." }, { status: 400 });
+ }
+
+ // Only allow safe fields to be updated
+ const allowedFields = [
+ "status",
+ "filingDate",
+ "internalRef",
+ "externalRef",
+ "tribunalId",
+ "courtName",
+ "chamber",
+ "presidingJudge",
+ "applicant",
+ "respondent",
+ "subject",
+ "amountInDisputeCents",
+ "fachgruppeId",
+ "currentStepKey",
+ "notes",
+ "metadata",
+ ] as const;
+
+ const updates: Record = {};
+ for (const field of allowedFields) {
+ if (field in body) {
+ updates[field] = body[field];
+ }
+ }
+
+ if (body.status === "abgeschlossen") {
+ updates.closedAt = new Date();
+ }
+
+ updates.updatedAt = new Date();
+
+ const [updated] = await db
+ .update(proceedings)
+ .set(updates)
+ .where(eq(proceedings.id, id))
+ .returning();
+
+ if (!updated) {
+ return Response.json({ error: "Proceeding not found." }, { status: 404 });
+ }
+
+ return Response.json({ proceeding: updated });
+}
+
+export async function DELETE(
+ _request: NextRequest,
+ ctx: RouteContext<"/api/proceedings/[id]">,
+) {
+ const { id } = await ctx.params;
+
+ const [deleted] = await db
+ .delete(proceedings)
+ .where(eq(proceedings.id, id))
+ .returning();
+
+ if (!deleted) {
+ return Response.json({ error: "Proceeding not found." }, { status: 404 });
+ }
+
+ return Response.json({ deleted: true });
+}
diff --git a/src/app/api/proceedings/[id]/steps/[stepId]/route.ts b/src/app/api/proceedings/[id]/steps/[stepId]/route.ts
new file mode 100644
index 0000000..2b0e50a
--- /dev/null
+++ b/src/app/api/proceedings/[id]/steps/[stepId]/route.ts
@@ -0,0 +1,48 @@
+// PATCH /api/proceedings/:id/steps/:stepId — update a proceeding step
+// Allows updating status, notes, and completion date.
+
+import { db } from "@/lib/db";
+import { proceedingSteps } from "@/lib/db/schema";
+import { eq, and } from "drizzle-orm";
+import type { NextRequest } from "next/server";
+
+export async function PATCH(
+ request: NextRequest,
+ ctx: RouteContext<"/api/proceedings/[id]/steps/[stepId]">,
+) {
+ const { id, stepId } = await ctx.params;
+
+ let body: Record;
+ try {
+ body = await request.json();
+ } catch {
+ return Response.json({ error: "Invalid JSON body." }, { status: 400 });
+ }
+
+ const updates: Record = {};
+
+ if ("status" in body) updates.status = body.status;
+ if ("notes" in body) updates.notes = body.notes;
+ if ("completedAt" in body) updates.completedAt = body.completedAt ? new Date(body.completedAt as string) : null;
+
+ if (body.status === "abgeschlossen" && !body.completedAt) {
+ updates.completedAt = new Date();
+ }
+
+ const [updated] = await db
+ .update(proceedingSteps)
+ .set(updates)
+ .where(
+ and(
+ eq(proceedingSteps.id, stepId),
+ eq(proceedingSteps.proceedingId, id),
+ ),
+ )
+ .returning();
+
+ if (!updated) {
+ return Response.json({ error: "Step not found." }, { status: 404 });
+ }
+
+ return Response.json({ step: updated });
+}
diff --git a/src/app/api/proceedings/route.ts b/src/app/api/proceedings/route.ts
new file mode 100644
index 0000000..2a5e2e7
--- /dev/null
+++ b/src/app/api/proceedings/route.ts
@@ -0,0 +1,192 @@
+// GET /api/proceedings — list proceedings with filters
+// POST /api/proceedings — create a new proceeding (initializes workflow steps + deadlines)
+
+import { db } from "@/lib/db";
+import {
+ proceedings,
+ proceedingSteps,
+ proceedingDeadlines,
+} from "@/lib/db/schema";
+import { eq, and, desc, sql, type SQL } from "drizzle-orm";
+import { initializeProceeding, workflowTemplates } from "@/lib/proceedings";
+import type { NextRequest } from "next/server";
+
+export async function GET(request: NextRequest) {
+ const url = new URL(request.url);
+
+ const limit = Math.min(parseInt(url.searchParams.get("limit") || "20"), 100);
+ const offset = parseInt(url.searchParams.get("offset") || "0");
+
+ const type = url.searchParams.get("type");
+ const status = url.searchParams.get("status");
+ const caseId = url.searchParams.get("caseId");
+ const tenantId = url.searchParams.get("tenantId");
+
+ const conditions: SQL[] = [];
+ if (type) conditions.push(eq(proceedings.type, type as any));
+ if (status) conditions.push(eq(proceedings.status, status as any));
+ if (caseId) conditions.push(eq(proceedings.caseId, caseId));
+ if (tenantId) conditions.push(eq(proceedings.tenantId, tenantId));
+
+ const where = conditions.length > 0 ? and(...conditions) : undefined;
+
+ const [results, countResult] = await Promise.all([
+ db
+ .select()
+ .from(proceedings)
+ .where(where)
+ .orderBy(desc(proceedings.updatedAt))
+ .limit(limit)
+ .offset(offset),
+ db
+ .select({ count: sql`count(*)::int` })
+ .from(proceedings)
+ .where(where),
+ ]);
+
+ return Response.json({
+ proceedings: results,
+ pagination: {
+ total: countResult[0].count,
+ limit,
+ offset,
+ hasMore: offset + limit < countResult[0].count,
+ },
+ });
+}
+
+export async function POST(request: Request) {
+ let body: Record;
+ try {
+ body = await request.json();
+ } catch {
+ return Response.json({ error: "Invalid JSON body." }, { status: 400 });
+ }
+
+ const {
+ tenantId,
+ caseId,
+ type,
+ filingDate,
+ internalRef,
+ externalRef,
+ tribunalId,
+ courtName,
+ chamber,
+ presidingJudge,
+ applicant,
+ respondent,
+ subject,
+ amountInDisputeCents,
+ fachgruppeId,
+ notes,
+ metadata,
+ } = body as any;
+
+ if (!tenantId || !type) {
+ return Response.json(
+ { error: "tenantId and type are required." },
+ { status: 400 },
+ );
+ }
+
+ if (!workflowTemplates[type]) {
+ return Response.json(
+ {
+ error: `Invalid proceeding type "${type}". Valid types: ${Object.keys(workflowTemplates).join(", ")}`,
+ },
+ { status: 400 },
+ );
+ }
+
+ const effectiveFilingDate =
+ filingDate || new Date().toISOString().slice(0, 10);
+
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(effectiveFilingDate)) {
+ return Response.json(
+ { error: "filingDate must be YYYY-MM-DD." },
+ { status: 400 },
+ );
+ }
+
+ // Initialize workflow steps and deadlines from template
+ const initialized = initializeProceeding(type, effectiveFilingDate);
+
+ // Create the proceeding
+ const [created] = await db
+ .insert(proceedings)
+ .values({
+ tenantId,
+ caseId: caseId ?? null,
+ type,
+ status: "eingereicht",
+ filingDate: effectiveFilingDate,
+ internalRef: internalRef ?? null,
+ externalRef: externalRef ?? null,
+ tribunalId: tribunalId ?? null,
+ courtName: courtName ?? null,
+ chamber: chamber ?? null,
+ presidingJudge: presidingJudge ?? null,
+ applicant: applicant ?? null,
+ respondent: respondent ?? null,
+ subject: subject ?? null,
+ amountInDisputeCents: amountInDisputeCents ?? null,
+ fachgruppeId: fachgruppeId ?? null,
+ currentStepKey: initialized.firstStepKey,
+ notes: notes ?? null,
+ metadata: metadata ?? null,
+ })
+ .returning();
+
+ // Insert all workflow steps
+ const insertedSteps = await db
+ .insert(proceedingSteps)
+ .values(
+ initialized.steps.map((s) => ({
+ proceedingId: created.id,
+ stepKey: s.stepKey,
+ label: s.label,
+ description: s.description,
+ sortOrder: s.sortOrder,
+ status: s.status,
+ legalBasis: s.legalBasis,
+ responsibleParty: s.responsibleParty,
+ })),
+ )
+ .returning();
+
+ // Insert deadlines for the first active step
+ const firstStep = insertedSteps.find(
+ (s) => s.stepKey === initialized.firstStepKey,
+ );
+ let insertedDeadlines: any[] = [];
+ if (initialized.deadlines.length > 0 && firstStep) {
+ insertedDeadlines = await db
+ .insert(proceedingDeadlines)
+ .values(
+ initialized.deadlines.map((d) => ({
+ proceedingId: created.id,
+ stepId: firstStep.id,
+ type: d.type,
+ label: d.label,
+ description: d.description,
+ dueDate: d.dueDate,
+ warningDate: d.warningDate,
+ warningDaysBefore: d.warningDaysBefore,
+ isCalculated: d.isCalculated,
+ calculationBasis: d.calculationBasis,
+ legalBasis: d.legalBasis,
+ })),
+ )
+ .returning();
+ }
+
+ return Response.json(
+ {
+ proceeding: created,
+ steps: insertedSteps,
+ deadlines: insertedDeadlines,
+ },
+ { status: 201 },
+ );
+}
diff --git a/src/app/globals.css b/src/app/globals.css
index a2dc41e..ede554a 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -1,26 +1,42 @@
@import "tailwindcss";
:root {
- --background: #ffffff;
+ --background: #fafafa;
--foreground: #171717;
+ --primary: #1e3a5f;
+ --primary-light: #2a5280;
+ --accent: #c59a3e;
+ --sidebar-bg: #1e3a5f;
+ --sidebar-text: #e2e8f0;
+ --sidebar-hover: #2a5280;
+ --card-bg: #ffffff;
+ --card-border: #e5e7eb;
+ --muted: #6b7280;
+ --success: #059669;
+ --warning: #d97706;
+ --danger: #dc2626;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
+ --color-primary: var(--primary);
+ --color-primary-light: var(--primary-light);
+ --color-accent: var(--accent);
+ --color-sidebar-bg: var(--sidebar-bg);
+ --color-sidebar-text: var(--sidebar-text);
+ --color-sidebar-hover: var(--sidebar-hover);
+ --color-card-bg: var(--card-bg);
+ --color-card-border: var(--card-border);
+ --color-muted: var(--muted);
+ --color-success: var(--success);
+ --color-warning: var(--warning);
+ --color-danger: var(--danger);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
-@media (prefers-color-scheme: dark) {
- :root {
- --background: #0a0a0a;
- --foreground: #ededed;
- }
-}
-
body {
background: var(--background);
color: var(--foreground);
- font-family: Arial, Helvetica, sans-serif;
}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 976eb90..f5d9791 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -13,8 +13,8 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = {
- title: "Create Next App",
- description: "Generated by create next app",
+ title: "LegalAI — Bühnenrecht",
+ description: "KI-gestützte Rechtsberatung für Bühnenrecht und Bühnenschiedsgerichtsbarkeit",
};
export default function RootLayout({
@@ -24,7 +24,7 @@ export default function RootLayout({
}>) {
return (
{children}
diff --git a/src/app/page.tsx b/src/app/page.tsx
index 3f36f7c..f889cb6 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -1,65 +1,5 @@
-import Image from "next/image";
+import { redirect } from 'next/navigation';
export default function Home() {
- return (
-
-
-
-
-
- To get started, edit the page.tsx file.
-
-
- Looking for a starting point or more instructions? Head over to{" "}
-
- Templates
- {" "}
- or the{" "}
-
- Learning
- {" "}
- center.
-
-
-
-
-
- );
+ redirect('/dashboard');
}
diff --git a/src/components/layout/header.tsx b/src/components/layout/header.tsx
new file mode 100644
index 0000000..5369f5c
--- /dev/null
+++ b/src/components/layout/header.tsx
@@ -0,0 +1,43 @@
+'use client';
+
+import { usePathname } from 'next/navigation';
+
+const PAGE_TITLES: Record = {
+ '/dashboard': 'Dashboard',
+ '/normen': 'Normen-Browser',
+ '/entscheidungen': 'Entscheidungen',
+ '/analyse': 'Analyse',
+ '/vertraege': 'Verträge',
+ '/verfahren': 'Verfahren',
+ '/einstellungen': 'Einstellungen',
+};
+
+export default function Header({ userName, userRole }: { userName: string; userRole: string }) {
+ const pathname = usePathname();
+ const matchedKey = Object.keys(PAGE_TITLES).find(
+ (key) => pathname === key || pathname.startsWith(key + '/')
+ );
+ const title = matchedKey ? PAGE_TITLES[matchedKey] : 'LegalAI';
+
+ const roleLabel: Record = {
+ admin: 'Administrator',
+ attorney: 'Rechtsanwalt',
+ paralegal: 'Paralegal',
+ viewer: 'Leser',
+ };
+
+ return (
+
+ );
+}
diff --git a/src/components/layout/sidebar.tsx b/src/components/layout/sidebar.tsx
new file mode 100644
index 0000000..71136e3
--- /dev/null
+++ b/src/components/layout/sidebar.tsx
@@ -0,0 +1,56 @@
+'use client';
+
+import Link from 'next/link';
+import { usePathname } from 'next/navigation';
+
+const NAV_ITEMS = [
+ { href: '/dashboard', label: 'Dashboard', icon: 'M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6' },
+ { href: '/normen', label: 'Normen', icon: 'M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253' },
+ { href: '/entscheidungen', label: 'Entscheidungen', icon: 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z' },
+ { href: '/analyse', label: 'Analyse', icon: 'M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z' },
+ { href: '/vertraege', label: 'Verträge', icon: 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z' },
+ { href: '/verfahren', label: 'Verfahren', icon: 'M3 6l3 1m0 0l-3 9a5.002 5.002 0 006.001 0M6 7l3 9M6 7l6-2m6 2l3-1m-3 1l-3 9a5.002 5.002 0 006.001 0M18 7l3 9m-3-9l-6-2m0-2v2m0 16V5m0 16H9m3 0h3' },
+ { href: '/einstellungen', label: 'Einstellungen', icon: 'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z M15 12a3 3 0 11-6 0 3 3 0 016 0z' },
+];
+
+export default function Sidebar() {
+ const pathname = usePathname();
+
+ return (
+
+ );
+}
diff --git a/src/components/layout/tenant-switcher.tsx b/src/components/layout/tenant-switcher.tsx
new file mode 100644
index 0000000..c18ad66
--- /dev/null
+++ b/src/components/layout/tenant-switcher.tsx
@@ -0,0 +1,63 @@
+'use client';
+
+import { useState } from 'react';
+
+interface Tenant {
+ id: string;
+ name: string;
+}
+
+export default function TenantSwitcher({
+ tenants,
+ currentTenantId,
+}: {
+ tenants: Tenant[];
+ currentTenantId: string;
+}) {
+ const [open, setOpen] = useState(false);
+ const current = tenants.find((t) => t.id === currentTenantId);
+
+ if (tenants.length <= 1) {
+ return (
+
+
Mandant
+
{current?.name ?? 'Kein Mandant'}
+
+ );
+ }
+
+ return (
+
+
+
+ {open && (
+
+ {tenants.map((tenant) => (
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/src/lib/ai/analysis.ts b/src/lib/ai/analysis.ts
new file mode 100644
index 0000000..c30d672
--- /dev/null
+++ b/src/lib/ai/analysis.ts
@@ -0,0 +1,235 @@
+// Core analysis service — orchestrates norm/decision lookup, prompt assembly, and AI generation
+
+import { streamText, generateText } from 'ai';
+import { getModel, getProvider } from './providers';
+import { SYSTEM_PROMPTS, buildContextBlock, type AnalysisModeKey } from './prompts';
+import { ANALYSIS_MODES } from './modes';
+import { AnalyseMode } from '@/types';
+import { db } from '@/lib/db';
+import { norms, normInstruments, decisions, analyses } from '@/lib/db/schema';
+import { eq, and, lte, or, isNull, gte, inArray } from 'drizzle-orm';
+
+interface AnalysisInput {
+ tenantId: string;
+ userId: string;
+ caseId?: string;
+ mode: AnalyseMode;
+ title: string;
+ query: string;
+ /** Optional: specific norm IDs to include as context */
+ normIds?: string[];
+ /** Optional: specific decision IDs to include as context */
+ decisionIds?: string[];
+ /** Optional: reference date for norm versioning (Stichtag) */
+ stichtag?: string;
+}
+
+/**
+ * Fetch norms relevant to the analysis, respecting temporal versioning.
+ * If normIds are given, fetch those. Otherwise fetch all active norms for the tenant.
+ */
+async function fetchNormContext(
+ tenantId: string,
+ normIds?: string[],
+ stichtag?: string,
+) {
+ const date = stichtag ?? new Date().toISOString().split('T')[0];
+
+ const conditions = [
+ lte(norms.validFrom, date),
+ or(isNull(norms.validTo), gte(norms.validTo, date)),
+ or(eq(norms.tenantId, tenantId), isNull(norms.tenantId)),
+ ];
+
+ if (normIds?.length) {
+ conditions.push(inArray(norms.id, normIds));
+ }
+
+ return db
+ .select({
+ id: norms.id,
+ paragraph: norms.paragraph,
+ title: norms.title,
+ body: norms.body,
+ instrumentAbbreviation: normInstruments.abbreviation,
+ sourceRank: normInstruments.sourceRank,
+ })
+ .from(norms)
+ .innerJoin(normInstruments, eq(norms.instrumentId, normInstruments.id))
+ .where(and(...conditions))
+ .limit(normIds?.length ? 100 : 20);
+}
+
+/**
+ * Fetch decisions relevant to the analysis.
+ */
+async function fetchDecisionContext(
+ tenantId: string,
+ decisionIds?: string[],
+) {
+ const conditions = [
+ or(eq(decisions.tenantId, tenantId), isNull(decisions.tenantId)),
+ ];
+
+ if (decisionIds?.length) {
+ conditions.push(inArray(decisions.id, decisionIds));
+ }
+
+ return db
+ .select({
+ id: decisions.id,
+ caseReference: decisions.caseReference,
+ court: decisions.court,
+ decisionDate: decisions.decisionDate,
+ headnote: decisions.headnote,
+ reasoning: decisions.reasoning,
+ })
+ .from(decisions)
+ .where(and(...conditions))
+ .limit(decisionIds?.length ? 50 : 10);
+}
+
+/**
+ * Create an analysis record in the database and return a streaming response.
+ */
+export async function runAnalysis(input: AnalysisInput) {
+ const modeConfig = ANALYSIS_MODES[input.mode];
+ const systemPromptKey = modeConfig.systemPromptKey as AnalysisModeKey;
+
+ // Fetch context in parallel
+ const [normContext, decisionContext] = await Promise.all([
+ modeConfig.requiresNorms
+ ? fetchNormContext(input.tenantId, input.normIds, input.stichtag)
+ : Promise.resolve([]),
+ modeConfig.requiresDecisions
+ ? fetchDecisionContext(input.tenantId, input.decisionIds)
+ : Promise.resolve([]),
+ ]);
+
+ const contextBlock = buildContextBlock(normContext, decisionContext);
+
+ const model = getModel();
+
+ // Create the analysis record (status: in_progress)
+ const [analysis] = await db
+ .insert(analyses)
+ .values({
+ tenantId: input.tenantId,
+ userId: input.userId,
+ caseId: input.caseId ?? null,
+ mode: input.mode,
+ status: 'in_progress',
+ title: input.title,
+ query: input.query,
+ aiProvider: getProvider(),
+ aiModel: process.env.AI_MODEL ?? 'default',
+ sources: {
+ normIds: normContext.map((n) => n.id),
+ decisionIds: decisionContext.map((d) => d.id),
+ otherSources: [],
+ },
+ })
+ .returning();
+
+ const userMessage = contextBlock
+ ? `${contextBlock}\n\n---\n\n## Rechtsfrage\n\n${input.query}`
+ : input.query;
+
+ return {
+ analysisId: analysis.id,
+ stream: streamText({
+ model,
+ system: SYSTEM_PROMPTS[systemPromptKey],
+ messages: [{ role: 'user', content: userMessage }],
+ maxOutputTokens: 4096,
+ onFinish: async ({ text, usage }) => {
+ // Update the analysis record with the result
+ await db
+ .update(analyses)
+ .set({
+ status: 'completed',
+ result: text,
+ tokenUsage: {
+ inputTokens: usage.inputTokens ?? 0,
+ outputTokens: usage.outputTokens ?? 0,
+ },
+ updatedAt: new Date(),
+ })
+ .where(eq(analyses.id, analysis.id));
+ },
+ }),
+ };
+}
+
+/**
+ * Non-streaming analysis — for batch/background use.
+ */
+export async function runAnalysisSync(input: AnalysisInput) {
+ const modeConfig = ANALYSIS_MODES[input.mode];
+ const systemPromptKey = modeConfig.systemPromptKey as AnalysisModeKey;
+
+ const [normContext, decisionContext] = await Promise.all([
+ modeConfig.requiresNorms
+ ? fetchNormContext(input.tenantId, input.normIds, input.stichtag)
+ : Promise.resolve([]),
+ modeConfig.requiresDecisions
+ ? fetchDecisionContext(input.tenantId, input.decisionIds)
+ : Promise.resolve([]),
+ ]);
+
+ const contextBlock = buildContextBlock(normContext, decisionContext);
+ const userMessage = contextBlock
+ ? `${contextBlock}\n\n---\n\n## Rechtsfrage\n\n${input.query}`
+ : input.query;
+
+ const model = getModel();
+
+ const [analysis] = await db
+ .insert(analyses)
+ .values({
+ tenantId: input.tenantId,
+ userId: input.userId,
+ caseId: input.caseId ?? null,
+ mode: input.mode,
+ status: 'in_progress',
+ title: input.title,
+ query: input.query,
+ aiProvider: getProvider(),
+ aiModel: process.env.AI_MODEL ?? 'default',
+ sources: {
+ normIds: normContext.map((n) => n.id),
+ decisionIds: decisionContext.map((d) => d.id),
+ otherSources: [],
+ },
+ })
+ .returning();
+
+ const result = await generateText({
+ model,
+ system: SYSTEM_PROMPTS[systemPromptKey],
+ messages: [{ role: 'user', content: userMessage }],
+ maxOutputTokens: 4096,
+ });
+
+ await db
+ .update(analyses)
+ .set({
+ status: 'completed',
+ result: result.text,
+ tokenUsage: {
+ inputTokens: result.usage.inputTokens ?? 0,
+ outputTokens: result.usage.outputTokens ?? 0,
+ },
+ updatedAt: new Date(),
+ })
+ .where(eq(analyses.id, analysis.id));
+
+ return {
+ analysisId: analysis.id,
+ result: result.text,
+ sources: {
+ normIds: normContext.map((n) => n.id),
+ decisionIds: decisionContext.map((d) => d.id),
+ },
+ };
+}
diff --git a/src/lib/ai/modes/index.ts b/src/lib/ai/modes/index.ts
index 7207b6b..5a0fcfa 100644
--- a/src/lib/ai/modes/index.ts
+++ b/src/lib/ai/modes/index.ts
@@ -8,6 +8,10 @@ export interface AnalysisModeConfig {
systemPromptKey: string;
requiresNorms: boolean;
requiresDecisions: boolean;
+ /** Human-readable German label */
+ label: string;
+ /** Short description */
+ description: string;
}
export const ANALYSIS_MODES: Record = {
@@ -16,23 +20,31 @@ export const ANALYSIS_MODES: Record = {
systemPromptKey: 'gutachten',
requiresNorms: true,
requiresDecisions: true,
+ label: 'Rechtsgutachten',
+ description: 'Strukturiertes Gutachten nach klassischer Methodik (Obersatz → Definition → Subsumtion → Ergebnis)',
},
[AnalyseMode.ENTSCHEIDUNG]: {
mode: AnalyseMode.ENTSCHEIDUNG,
systemPromptKey: 'entscheidung',
requiresNorms: true,
requiresDecisions: true,
+ label: 'Entscheidungsvorhersage',
+ description: 'Prognose der wahrscheinlichen gerichtlichen/schiedsgerichtlichen Entscheidung',
},
[AnalyseMode.VERGLEICH]: {
mode: AnalyseMode.VERGLEICH,
systemPromptKey: 'vergleich',
requiresNorms: true,
requiresDecisions: false,
+ label: 'Vergleichsvorschlag',
+ description: 'Erarbeitung eines Vergleichsvorschlags mit Bewertung der Erfolgsaussichten',
},
[AnalyseMode.RISIKO]: {
mode: AnalyseMode.RISIKO,
systemPromptKey: 'risiko',
requiresNorms: true,
requiresDecisions: true,
+ label: 'Risikoanalyse',
+ description: 'Umfassende Risikoanalyse mit Eintrittswahrscheinlichkeiten und Minderungsstrategien',
},
};
diff --git a/src/lib/ai/prompts.ts b/src/lib/ai/prompts.ts
new file mode 100644
index 0000000..b3e3e49
--- /dev/null
+++ b/src/lib/ai/prompts.ts
@@ -0,0 +1,153 @@
+// System prompts for each analysis mode
+// Embeds Quellenrang hierarchy and German legal methodology
+
+import { QuellenRang } from '@/types';
+import { QUELLENRANG_ORDER } from '@/lib/norms';
+
+const QUELLENRANG_LABELS: Record = {
+ [QuellenRang.GESETZ]: 'Gesetz (Rang 1 — höchste Autorität)',
+ [QuellenRang.TARIF]: 'Tarifvertrag (Rang 2)',
+ [QuellenRang.SCHIEDSORDNUNG]: 'Schiedsordnung (Rang 3)',
+ [QuellenRang.PRAXIS]: 'Bühnenpraxis / Gewohnheitsrecht (Rang 4)',
+ [QuellenRang.KOMMENTAR]: 'Kommentarliteratur / Doktrin (Rang 5 — niedrigste Autorität)',
+};
+
+function quellenrangBlock(): string {
+ return QUELLENRANG_ORDER
+ .map((r) => `- ${QUELLENRANG_LABELS[r]}`)
+ .join('\n');
+}
+
+const BASE_INSTRUCTIONS = `Du bist ein juristischer Assistent für deutsches Bühnenrecht (Theaterrecht).
+Du arbeitest mit dem Normalvertrag Bühne (NV Bühne), der Bühnenschiedsgerichtsordnung (BSchGO),
+dem Arbeitsgerichtsgesetz (ArbGG) und verwandtem Arbeits- und Tarifrecht.
+
+Quellenrang-Hierarchie (höhere Ränge haben Vorrang bei Konflikten):
+${quellenrangBlock()}
+
+Regeln:
+- Zitiere immer die konkrete Norm mit § und Absatz.
+- Gib bei jeder zitierten Quelle den Quellenrang in eckigen Klammern an, z.B. [Rang 1: Gesetz].
+- Bei Konflikten zwischen Quellen verschiedener Ränge hat die höherrangige Quelle Vorrang.
+- Antworte ausschließlich auf Deutsch.
+- Nutze die bereitgestellten Normen und Entscheidungen als primäre Quellen.`;
+
+export const SYSTEM_PROMPTS = {
+ gutachten: `${BASE_INSTRUCTIONS}
+
+Modus: GUTACHTEN (Rechtsgutachten)
+
+Erstelle ein strukturiertes Rechtsgutachten nach der klassischen Methodik:
+
+1. **Sachverhalt** — Kurze Zusammenfassung des zu prüfenden Sachverhalts
+2. **Rechtsfrage** — Präzise Formulierung der zu klärenden Rechtsfrage(n)
+3. **Obersatz** — Abstrakte Rechtsregel aus der einschlägigen Norm
+4. **Definition** — Auslegung der relevanten Tatbestandsmerkmale
+5. **Untersatz** — Subsumtion des Sachverhalts unter die Norm
+6. **Ergebnis** — Klares Ergebnis mit Begründung
+
+Berücksichtige dabei einschlägige Rechtsprechung (Schiedssprüche, Urteile) und ordne sie nach Quellenrang ein.`,
+
+ entscheidung: `${BASE_INSTRUCTIONS}
+
+Modus: ENTSCHEIDUNG (Entscheidungsvorhersage)
+
+Analysiere den Sachverhalt und prognostiziere die wahrscheinliche Entscheidung:
+
+1. **Sachverhalt** — Zusammenfassung der relevanten Tatsachen
+2. **Einschlägige Normen** — Anwendbare Vorschriften mit Quellenrang
+3. **Bisherige Rechtsprechung** — Relevante Präzedenzfälle und deren Entscheidungslinien
+4. **Prognose** — Wahrscheinlichste Entscheidung mit Begründung
+5. **Risikofaktoren** — Faktoren, die das Ergebnis beeinflussen könnten
+6. **Empfehlung** — Handlungsempfehlung für den Mandanten
+
+Stütze die Prognose auf konkrete Entscheidungen und deren Leitsätze.`,
+
+ vergleich: `${BASE_INSTRUCTIONS}
+
+Modus: VERGLEICH (Vergleichsvorschlag)
+
+Erarbeite einen Vergleichsvorschlag:
+
+1. **Ausgangslage** — Positionen beider Parteien
+2. **Rechtslage** — Einschlägige Normen und deren Wertung
+3. **Erfolgsaussichten** — Prozentuale Einschätzung für jede Partei (mit Begründung)
+4. **Vergleichsvorschlag** — Konkreter Kompromissvorschlag
+5. **Vor-/Nachteile** — Bewertung des Vorschlags für beide Seiten
+6. **Umsetzung** — Praktische Schritte zur Umsetzung
+
+Beziehe die wirtschaftlichen Interessen beider Seiten ein (Kosten, Zeit, Reputation).`,
+
+ risiko: `${BASE_INSTRUCTIONS}
+
+Modus: RISIKO (Risikoanalyse)
+
+Erstelle eine umfassende Risikoanalyse:
+
+1. **Sachverhalt** — Zusammenfassung der Situation
+2. **Identifizierte Risiken** — Auflistung aller rechtlichen Risiken, jeweils mit:
+ - Beschreibung des Risikos
+ - Eintrittswahrscheinlichkeit (hoch/mittel/gering)
+ - Schadensausmaß (hoch/mittel/gering)
+ - Einschlägige Norm(en) mit Quellenrang
+3. **Risikomatrix** — Tabellarische Übersicht (Wahrscheinlichkeit × Auswirkung)
+4. **Minderungsstrategien** — Konkrete Maßnahmen je Risiko
+5. **Priorisierung** — Dringlichste Handlungsempfehlungen
+
+Bewerte jedes Risiko anhand der aktuellen Rechtslage und Rechtsprechung.`,
+} as const;
+
+export type AnalysisModeKey = keyof typeof SYSTEM_PROMPTS;
+
+/**
+ * Build the user context block that gets prepended to the user query.
+ * Includes relevant norms and decisions sorted by Quellenrang.
+ */
+export function buildContextBlock(
+ norms: Array<{
+ paragraph: string;
+ title: string | null;
+ body: string;
+ instrumentAbbreviation: string;
+ sourceRank: string;
+ }>,
+ decisions: Array<{
+ caseReference: string | null;
+ court: string;
+ decisionDate: string;
+ headnote: string | null;
+ reasoning: string | null;
+ }>,
+): string {
+ const parts: string[] = [];
+
+ if (norms.length > 0) {
+ // Sort norms by Quellenrang (higher rank first)
+ const sorted = [...norms].sort((a, b) => {
+ const idxA = QUELLENRANG_ORDER.indexOf(a.sourceRank as QuellenRang);
+ const idxB = QUELLENRANG_ORDER.indexOf(b.sourceRank as QuellenRang);
+ return idxA - idxB;
+ });
+
+ parts.push('## Einschlägige Normen\n');
+ for (const n of sorted) {
+ const rank = QUELLENRANG_LABELS[n.sourceRank as QuellenRang] ?? n.sourceRank;
+ parts.push(`### ${n.instrumentAbbreviation} ${n.paragraph}${n.title ? ` — ${n.title}` : ''}`);
+ parts.push(`[Quellenrang: ${rank}]\n`);
+ parts.push(n.body);
+ parts.push('');
+ }
+ }
+
+ if (decisions.length > 0) {
+ parts.push('## Relevante Entscheidungen\n');
+ for (const d of decisions) {
+ parts.push(`### ${d.court}${d.caseReference ? ` — Az. ${d.caseReference}` : ''} (${d.decisionDate})`);
+ if (d.headnote) parts.push(`**Leitsatz:** ${d.headnote}`);
+ if (d.reasoning) parts.push(`**Entscheidungsgründe (Auszug):** ${d.reasoning.slice(0, 1000)}${d.reasoning.length > 1000 ? '…' : ''}`);
+ parts.push('');
+ }
+ }
+
+ return parts.join('\n');
+}
diff --git a/src/lib/ai/providers/index.ts b/src/lib/ai/providers/index.ts
index f77feba..fbdf786 100644
--- a/src/lib/ai/providers/index.ts
+++ b/src/lib/ai/providers/index.ts
@@ -1,16 +1,31 @@
-// AI Provider abstraction via Vercel AI SDK
-// Supports: Anthropic, OpenAI, local LLMs
-// Provider configuration is set via environment variables
+// AI Provider abstraction via Vercel AI SDK v6
+// Supports: Anthropic, OpenAI — selected via AI_PROVIDER env var
-export type AIProvider = 'anthropic' | 'openai' | 'local';
+import { anthropic } from '@ai-sdk/anthropic';
+import { openai } from '@ai-sdk/openai';
+import type { LanguageModel } from 'ai';
-export interface AIProviderConfig {
- provider: AIProvider;
- model: string;
- apiKey?: string;
- baseUrl?: string;
+export type AIProvider = 'anthropic' | 'openai';
+
+const DEFAULT_MODELS: Record = {
+ anthropic: 'claude-sonnet-4-20250514',
+ openai: 'gpt-4o',
+};
+
+export function getProvider(): AIProvider {
+ const p = process.env.AI_PROVIDER;
+ if (p === 'openai') return 'openai';
+ return 'anthropic';
}
-export function getDefaultProvider(): AIProvider {
- return (process.env.AI_PROVIDER as AIProvider) || 'anthropic';
+export function getModel(provider?: AIProvider, modelId?: string): LanguageModel {
+ const p = provider ?? getProvider();
+ const id = modelId ?? process.env.AI_MODEL ?? DEFAULT_MODELS[p];
+
+ switch (p) {
+ case 'anthropic':
+ return anthropic(id);
+ case 'openai':
+ return openai(id);
+ }
}
diff --git a/src/lib/ai/structured-analysis.ts b/src/lib/ai/structured-analysis.ts
new file mode 100644
index 0000000..584ecd6
--- /dev/null
+++ b/src/lib/ai/structured-analysis.ts
@@ -0,0 +1,218 @@
+// Structured analysis service — extends the base analysis with JSON output
+// Returns structured data for frontend consumption with Quellenrang integration
+
+import { generateText } from 'ai';
+import { getModel, getProvider } from './providers';
+import { SYSTEM_PROMPTS, buildContextBlock, type AnalysisModeKey } from './prompts';
+import { ANALYSIS_MODES } from './modes';
+import { STRUCTURED_OUTPUT_INSTRUCTION, type StructuredAnalysisOutput } from './structured-output';
+import { AnalyseMode } from '@/types';
+import { db } from '@/lib/db';
+import { norms, normInstruments, decisions, analyses } from '@/lib/db/schema';
+import { eq, and, lte, or, isNull, gte, inArray } from 'drizzle-orm';
+
+interface StructuredAnalysisInput {
+ tenantId: string;
+ userId: string;
+ caseId?: string;
+ mode: AnalyseMode;
+ title: string;
+ query: string;
+ normIds?: string[];
+ decisionIds?: string[];
+ stichtag?: string;
+ /** Additional context from contract analysis or proceedings */
+ additionalContext?: string;
+}
+
+/**
+ * Fetch norms with full Quellenrang metadata for structured output.
+ */
+async function fetchNormContextEnhanced(
+ tenantId: string,
+ normIds?: string[],
+ stichtag?: string,
+) {
+ const date = stichtag ?? new Date().toISOString().split('T')[0];
+
+ const conditions = [
+ lte(norms.validFrom, date),
+ or(isNull(norms.validTo), gte(norms.validTo, date)),
+ or(eq(norms.tenantId, tenantId), isNull(norms.tenantId)),
+ ];
+
+ if (normIds?.length) {
+ conditions.push(inArray(norms.id, normIds));
+ }
+
+ return db
+ .select({
+ id: norms.id,
+ paragraph: norms.paragraph,
+ title: norms.title,
+ body: norms.body,
+ instrumentAbbreviation: normInstruments.abbreviation,
+ sourceRank: normInstruments.sourceRank,
+ instrumentId: normInstruments.id,
+ })
+ .from(norms)
+ .innerJoin(normInstruments, eq(norms.instrumentId, normInstruments.id))
+ .where(and(...conditions))
+ .limit(normIds?.length ? 100 : 20);
+}
+
+/**
+ * Fetch decisions with enhanced metadata for structured output.
+ */
+async function fetchDecisionContextEnhanced(
+ tenantId: string,
+ decisionIds?: string[],
+) {
+ const conditions = [
+ or(eq(decisions.tenantId, tenantId), isNull(decisions.tenantId)),
+ ];
+
+ if (decisionIds?.length) {
+ conditions.push(inArray(decisions.id, decisionIds));
+ }
+
+ return db
+ .select({
+ id: decisions.id,
+ type: decisions.type,
+ caseReference: decisions.caseReference,
+ court: decisions.court,
+ decisionDate: decisions.decisionDate,
+ headnote: decisions.headnote,
+ tenor: decisions.tenor,
+ reasoning: decisions.reasoning,
+ domains: decisions.domains,
+ keywords: decisions.keywords,
+ })
+ .from(decisions)
+ .where(and(...conditions))
+ .limit(decisionIds?.length ? 50 : 10);
+}
+
+/**
+ * Run a structured analysis that returns typed JSON for frontend consumption.
+ * Extends the base analysis with:
+ * - Quellenrang weighting in all outputs
+ * - Structured JSON responses per mode schema
+ * - Optional additional context (contract clauses, proceedings)
+ */
+export async function runStructuredAnalysis(
+ input: StructuredAnalysisInput,
+): Promise<{
+ analysisId: string;
+ result: StructuredAnalysisOutput;
+ sources: { normIds: string[]; decisionIds: string[] };
+}> {
+ const modeConfig = ANALYSIS_MODES[input.mode];
+ const systemPromptKey = modeConfig.systemPromptKey as AnalysisModeKey;
+
+ const [normContext, decisionContext] = await Promise.all([
+ modeConfig.requiresNorms
+ ? fetchNormContextEnhanced(input.tenantId, input.normIds, input.stichtag)
+ : Promise.resolve([]),
+ modeConfig.requiresDecisions
+ ? fetchDecisionContextEnhanced(input.tenantId, input.decisionIds)
+ : Promise.resolve([]),
+ ]);
+
+ const contextBlock = buildContextBlock(normContext, decisionContext);
+
+ // Build enhanced user message with additional context
+ const messageParts: string[] = [];
+ if (contextBlock) messageParts.push(contextBlock);
+ if (input.additionalContext) {
+ messageParts.push(`## Zusätzlicher Kontext\n\n${input.additionalContext}`);
+ }
+ messageParts.push(`## Rechtsfrage\n\n${input.query}`);
+
+ const userMessage = messageParts.join('\n\n---\n\n');
+
+ // Add structured output instruction to system prompt
+ const systemPrompt = SYSTEM_PROMPTS[systemPromptKey] + STRUCTURED_OUTPUT_INSTRUCTION;
+
+ const model = getModel();
+
+ // Create analysis record
+ const [analysis] = await db
+ .insert(analyses)
+ .values({
+ tenantId: input.tenantId,
+ userId: input.userId,
+ caseId: input.caseId ?? null,
+ mode: input.mode,
+ status: 'in_progress',
+ title: input.title,
+ query: input.query,
+ aiProvider: getProvider(),
+ aiModel: process.env.AI_MODEL ?? 'default',
+ sources: {
+ normIds: normContext.map((n) => n.id),
+ decisionIds: decisionContext.map((d) => d.id),
+ otherSources: [],
+ },
+ metadata: { structured: true },
+ })
+ .returning();
+
+ const result = await generateText({
+ model,
+ system: systemPrompt,
+ messages: [{ role: 'user', content: userMessage }],
+ maxOutputTokens: 8192,
+ });
+
+ // Parse structured output
+ let structured: StructuredAnalysisOutput;
+ try {
+ const jsonMatch = result.text.match(/\{[\s\S]*\}/);
+ if (!jsonMatch) throw new Error('No JSON object found');
+ structured = JSON.parse(jsonMatch[0]);
+ structured.mode = input.mode;
+ } catch {
+ // Fallback: store raw text, mark as non-structured
+ await db
+ .update(analyses)
+ .set({
+ status: 'completed',
+ result: result.text,
+ tokenUsage: {
+ inputTokens: result.usage.inputTokens ?? 0,
+ outputTokens: result.usage.outputTokens ?? 0,
+ },
+ metadata: { structured: false, parseError: true },
+ updatedAt: new Date(),
+ })
+ .where(eq(analyses.id, analysis.id));
+
+ throw new Error('KI-Antwort konnte nicht als strukturiertes JSON geparst werden');
+ }
+
+ // Store structured result as JSON string
+ await db
+ .update(analyses)
+ .set({
+ status: 'completed',
+ result: JSON.stringify(structured),
+ tokenUsage: {
+ inputTokens: result.usage.inputTokens ?? 0,
+ outputTokens: result.usage.outputTokens ?? 0,
+ },
+ metadata: { structured: true },
+ updatedAt: new Date(),
+ })
+ .where(eq(analyses.id, analysis.id));
+
+ return {
+ analysisId: analysis.id,
+ result: structured,
+ sources: {
+ normIds: normContext.map((n) => n.id),
+ decisionIds: decisionContext.map((d) => d.id),
+ },
+ };
+}
diff --git a/src/lib/ai/structured-output.ts b/src/lib/ai/structured-output.ts
new file mode 100644
index 0000000..048b4c4
--- /dev/null
+++ b/src/lib/ai/structured-output.ts
@@ -0,0 +1,180 @@
+// Structured output schemas for the 4 analysis modes
+// Defines JSON structures that the AI returns for frontend consumption
+
+/** Source reference with Quellenrang */
+export interface QuellenReference {
+ /** Source text citation (e.g. "§ 53 Abs. 2 NV Bühne") */
+ citation: string;
+ /** Quellenrang (1=Gesetz, 5=Kommentar) */
+ rank: number;
+ /** Rank label for display */
+ rankLabel: string;
+ /** Brief relevance note */
+ relevance: string;
+}
+
+// ---- Gutachten (Legal Opinion) ----
+
+export interface GutachtenOutput {
+ mode: 'gutachten';
+ /** Summary of facts */
+ sachverhalt: string;
+ /** Precise legal question(s) */
+ rechtsfrage: string[];
+ /** Structured examination steps */
+ pruefung: Array<{
+ /** Legal principle / norm */
+ obersatz: string;
+ /** Definition of legal elements */
+ definition: string;
+ /** Application to facts */
+ untersatz: string;
+ /** Intermediate result */
+ ergebnis: string;
+ /** Sources cited */
+ quellen: QuellenReference[];
+ }>;
+ /** Final conclusion */
+ gesamtergebnis: string;
+ /** Confidence level (hoch/mittel/gering) */
+ konfidenz: 'hoch' | 'mittel' | 'gering';
+}
+
+// ---- Entscheidung (Decision Prediction) ----
+
+export interface EntscheidungOutput {
+ mode: 'entscheidung';
+ sachverhalt: string;
+ /** Applicable norms with hierarchy */
+ einschlaegigeNormen: Array<{
+ norm: string;
+ rank: number;
+ relevance: string;
+ }>;
+ /** Relevant precedent decisions */
+ praezedenzfaelle: Array<{
+ gericht: string;
+ aktenzeichen: string;
+ datum: string;
+ leitsatz: string;
+ relevance: string;
+ }>;
+ /** Predicted outcome */
+ prognose: {
+ ergebnis: string;
+ wahrscheinlichkeit: number; // 0-100
+ begruendung: string;
+ };
+ /** Risk factors that could alter the outcome */
+ risikofaktoren: Array<{
+ faktor: string;
+ einfluss: 'positiv' | 'negativ' | 'neutral';
+ gewicht: 'hoch' | 'mittel' | 'gering';
+ }>;
+ /** Recommended action */
+ empfehlung: string;
+ quellen: QuellenReference[];
+}
+
+// ---- Vergleich (Settlement Proposal) ----
+
+export interface VergleichOutput {
+ mode: 'vergleich';
+ /** Starting positions of both parties */
+ ausgangslage: {
+ partei1: { position: string; interessen: string[] };
+ partei2: { position: string; interessen: string[] };
+ };
+ /** Legal assessment of each side */
+ rechtslage: string;
+ /** Success probability per party */
+ erfolgsaussichten: {
+ partei1: { prozent: number; begruendung: string };
+ partei2: { prozent: number; begruendung: string };
+ };
+ /** Concrete settlement proposal */
+ vergleichsvorschlag: {
+ vorschlag: string;
+ vorteilePartei1: string[];
+ vorteilePartei2: string[];
+ nachteilePartei1: string[];
+ nachteilePartei2: string[];
+ };
+ /** Implementation steps */
+ umsetzung: string[];
+ /** Economic considerations */
+ wirtschaftlichkeit: {
+ kostenVerfahren: string;
+ kostenVergleich: string;
+ zeitersparnisWochen: number;
+ };
+ quellen: QuellenReference[];
+}
+
+// ---- Risiko (Risk Assessment) ----
+
+export interface RisikoOutput {
+ mode: 'risiko';
+ sachverhalt: string;
+ /** Identified risks */
+ risiken: Array<{
+ id: string;
+ beschreibung: string;
+ kategorie: string;
+ wahrscheinlichkeit: 'hoch' | 'mittel' | 'gering';
+ auswirkung: 'hoch' | 'mittel' | 'gering';
+ /** Combined risk score 0-100 */
+ riskScore: number;
+ /** Relevant norms */
+ normen: string[];
+ /** Mitigation strategy */
+ minderung: string;
+ }>;
+ /** Risk matrix summary */
+ matrix: {
+ kritisch: number;
+ hoch: number;
+ mittel: number;
+ gering: number;
+ };
+ /** Priority actions */
+ priorisierung: Array<{
+ aktion: string;
+ dringlichkeit: 'sofort' | 'kurzfristig' | 'mittelfristig';
+ risikoId: string;
+ }>;
+ /** Deadline-related risks (Fristversäumnisse) */
+ fristrisiken: Array<{
+ frist: string;
+ ablaufdatum: string;
+ konsequenz: string;
+ riskScore: number;
+ }>;
+ quellen: QuellenReference[];
+}
+
+export type StructuredAnalysisOutput =
+ | GutachtenOutput
+ | EntscheidungOutput
+ | VergleichOutput
+ | RisikoOutput;
+
+/**
+ * System prompt suffix that instructs the AI to return structured JSON.
+ * Appended to each mode's system prompt when structured output is requested.
+ */
+export const STRUCTURED_OUTPUT_INSTRUCTION = `
+
+WICHTIG: Antworte ausschließlich mit einem gültigen JSON-Objekt.
+Das JSON muss dem für diesen Modus definierten Schema entsprechen.
+Kein Fließtext, keine Markdown-Formatierung außerhalb von JSON-Strings.
+Alle Quellenangaben müssen den Quellenrang (1-5) enthalten.`;
+
+/** Map numeric rank to label */
+export const RANK_LABELS: Record = {
+ 1: 'Gesetz',
+ 2: 'Tarifvertrag',
+ 3: 'Schiedsordnung',
+ 4: 'Bühnenpraxis',
+ 5: 'Kommentarliteratur',
+};
diff --git a/src/lib/auth/audit.ts b/src/lib/auth/audit.ts
new file mode 100644
index 0000000..d152a52
--- /dev/null
+++ b/src/lib/auth/audit.ts
@@ -0,0 +1,25 @@
+// DSGVO-compliant audit logging
+// Records all data access and mutations for the audit trail.
+
+import { db } from '@/lib/db';
+import { auditLog } from '@/lib/db/schema';
+import type { TenantContext } from './index';
+
+export async function logAuditEvent(
+ ctx: TenantContext,
+ action: string,
+ entityType: string,
+ entityId: string | null,
+ details?: Record,
+ ipAddress?: string,
+) {
+ await db.insert(auditLog).values({
+ tenantId: ctx.tenantId,
+ userId: ctx.userId,
+ action,
+ entityType,
+ entityId: entityId ?? undefined,
+ details: details ?? undefined,
+ ipAddress: ipAddress ?? undefined,
+ });
+}
diff --git a/src/lib/auth/index.ts b/src/lib/auth/index.ts
index d2f2bb7..fd154f7 100644
--- a/src/lib/auth/index.ts
+++ b/src/lib/auth/index.ts
@@ -1,13 +1,123 @@
// Authentication and tenant context
-// NextAuth.js v5 with tenant-aware session management
+// NextAuth.js v4 with tenant-aware session management
+
+import type { NextAuthOptions } from 'next-auth';
+import CredentialsProvider from 'next-auth/providers/credentials';
+import bcrypt from 'bcryptjs';
+import { eq } from 'drizzle-orm';
+import { db } from '@/lib/db';
+import { users } from '@/lib/db/schema';
export interface TenantContext {
tenantId: string;
userId: string;
- role: string;
+ role: 'admin' | 'attorney' | 'paralegal' | 'viewer';
+ email: string;
+ name: string;
}
-export function getTenantId(): string {
- // Will be populated from session middleware
- throw new Error('Tenant context not initialized — use within authenticated route');
+// Extend NextAuth types for tenant-aware session
+declare module 'next-auth' {
+ interface Session {
+ user: {
+ id: string;
+ tenantId: string;
+ role: 'admin' | 'attorney' | 'paralegal' | 'viewer';
+ email: string;
+ name: string;
+ };
+ }
+
+ interface User {
+ id: string;
+ tenantId: string;
+ role: 'admin' | 'attorney' | 'paralegal' | 'viewer';
+ email: string;
+ name: string;
+ }
}
+
+declare module 'next-auth/jwt' {
+ interface JWT {
+ id: string;
+ tenantId: string;
+ role: 'admin' | 'attorney' | 'paralegal' | 'viewer';
+ email: string;
+ name: string;
+ }
+}
+
+export const authOptions: NextAuthOptions = {
+ providers: [
+ CredentialsProvider({
+ name: 'Credentials',
+ credentials: {
+ email: { label: 'E-Mail', type: 'email' },
+ password: { label: 'Passwort', type: 'password' },
+ },
+ async authorize(credentials) {
+ if (!credentials?.email || !credentials?.password) {
+ return null;
+ }
+
+ const [user] = await db
+ .select()
+ .from(users)
+ .where(eq(users.email, credentials.email))
+ .limit(1);
+
+ if (!user || !user.passwordHash) {
+ return null;
+ }
+
+ const isValid = await bcrypt.compare(credentials.password, user.passwordHash);
+ if (!isValid) {
+ return null;
+ }
+
+ // Update last login timestamp
+ await db
+ .update(users)
+ .set({ lastLoginAt: new Date() })
+ .where(eq(users.id, user.id));
+
+ return {
+ id: user.id,
+ tenantId: user.tenantId,
+ role: user.role,
+ email: user.email,
+ name: user.name,
+ };
+ },
+ }),
+ ],
+ callbacks: {
+ async jwt({ token, user }) {
+ if (user) {
+ token.id = user.id;
+ token.tenantId = user.tenantId;
+ token.role = user.role;
+ token.email = user.email;
+ token.name = user.name;
+ }
+ return token;
+ },
+ async session({ session, token }) {
+ session.user = {
+ id: token.id,
+ tenantId: token.tenantId,
+ role: token.role,
+ email: token.email,
+ name: token.name,
+ };
+ return session;
+ },
+ },
+ pages: {
+ signIn: '/login',
+ },
+ session: {
+ strategy: 'jwt',
+ maxAge: 8 * 60 * 60, // 8 hours — short session for legal data security
+ },
+};
diff --git a/src/lib/auth/rbac.ts b/src/lib/auth/rbac.ts
new file mode 100644
index 0000000..d6cbc02
--- /dev/null
+++ b/src/lib/auth/rbac.ts
@@ -0,0 +1,81 @@
+// Role-based access control for LegalAI
+// Enforces permission checks based on user role from the session.
+
+import { getServerSession } from 'next-auth';
+import { authOptions } from './index';
+import type { TenantContext } from './index';
+
+type Role = 'admin' | 'attorney' | 'paralegal' | 'viewer';
+
+/**
+ * Permission matrix — maps actions to the minimum set of roles allowed.
+ * Follows least-privilege principle per DSGVO requirements.
+ */
+const PERMISSIONS: Record = {
+ // User management
+ 'users:manage': ['admin'],
+
+ // Cases
+ 'cases:create': ['admin', 'attorney'],
+ 'cases:edit': ['admin', 'attorney'],
+ 'cases:read': ['admin', 'attorney', 'paralegal', 'viewer'],
+
+ // Analyses
+ 'analyses:create': ['admin', 'attorney'],
+ 'analyses:edit': ['admin', 'attorney'],
+ 'analyses:read': ['admin', 'attorney', 'paralegal', 'viewer'],
+
+ // Norms / Decisions (reference data)
+ 'norms:write': ['admin', 'attorney'],
+ 'norms:read': ['admin', 'attorney', 'paralegal', 'viewer'],
+ 'decisions:write': ['admin', 'attorney'],
+ 'decisions:read': ['admin', 'attorney', 'paralegal', 'viewer'],
+
+ // Settings
+ 'settings:manage': ['admin'],
+
+ // Audit log
+ 'audit:read': ['admin'],
+} as const;
+
+export type Permission = keyof typeof PERMISSIONS;
+
+/** Check whether a role has a specific permission. */
+export function hasPermission(role: Role, permission: Permission): boolean {
+ const allowed = PERMISSIONS[permission];
+ if (!allowed) return false;
+ return allowed.includes(role);
+}
+
+/**
+ * Get the authenticated tenant context from the current session.
+ * Returns null if not authenticated.
+ */
+export async function getTenantContext(): Promise {
+ const session = await getServerSession(authOptions);
+ if (!session?.user) return null;
+ return {
+ tenantId: session.user.tenantId,
+ userId: session.user.id,
+ role: session.user.role,
+ email: session.user.email,
+ name: session.user.name,
+ };
+}
+
+/**
+ * Require authentication + specific permission for an API route handler.
+ * Returns the tenant context if authorized, or a Response to send back.
+ */
+export async function requirePermission(
+ permission: Permission,
+): Promise<{ ctx: TenantContext } | { response: Response }> {
+ const ctx = await getTenantContext();
+ if (!ctx) {
+ return { response: Response.json({ error: 'Nicht authentifiziert' }, { status: 401 }) };
+ }
+ if (!hasPermission(ctx.role, permission)) {
+ return { response: Response.json({ error: 'Keine Berechtigung' }, { status: 403 }) };
+ }
+ return { ctx };
+}
diff --git a/src/lib/contracts/index.ts b/src/lib/contracts/index.ts
new file mode 100644
index 0000000..5b3b8f8
--- /dev/null
+++ b/src/lib/contracts/index.ts
@@ -0,0 +1,316 @@
+// Contract Analysis Module — document upload, text extraction, clause analysis
+// Handles PDF/DOCX text extraction and AI-powered clause identification
+
+import { db } from '@/lib/db';
+import {
+ contractDocuments,
+ contractClauses,
+ standardClauses,
+ normInstruments,
+} from '@/lib/db/schema';
+import { eq, and, desc } from 'drizzle-orm';
+import { generateText } from 'ai';
+import { getModel } from '@/lib/ai/providers';
+
+const ALLOWED_MIME_TYPES = new Set([
+ 'application/pdf',
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+]);
+
+const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
+
+/** Clause categories recognized by the system */
+export const CLAUSE_CATEGORIES = [
+ 'Vertragsparteien',
+ 'Vertragsdauer',
+ 'Nichtverlängerung',
+ 'Vergütung',
+ 'Arbeitszeit',
+ 'Proben',
+ 'Gastspiele',
+ 'Urlaub',
+ 'Krankheit',
+ 'Kündigung',
+ 'Nebentätigkeit',
+ 'Geheimhaltung',
+ 'Sonstiges',
+] as const;
+
+export type ClauseCategory = (typeof CLAUSE_CATEGORIES)[number];
+
+interface UploadResult {
+ documentId: string;
+ filename: string;
+ status: string;
+}
+
+/**
+ * Validate and store a contract document upload.
+ * Does NOT extract text — that happens asynchronously via analyzeContract().
+ */
+export async function uploadContractDocument(
+ tenantId: string,
+ userId: string,
+ file: File,
+ caseId?: string,
+): Promise {
+ if (!ALLOWED_MIME_TYPES.has(file.type)) {
+ throw new Error(
+ `Ungültiger Dateityp: ${file.type}. Erlaubt sind PDF und DOCX.`,
+ );
+ }
+
+ if (file.size > MAX_FILE_SIZE) {
+ throw new Error(
+ `Datei zu groß: ${(file.size / 1024 / 1024).toFixed(1)} MB. Maximum: ${MAX_FILE_SIZE / 1024 / 1024} MB.`,
+ );
+ }
+
+ // Store file content as base64 in a generated path
+ const buffer = Buffer.from(await file.arrayBuffer());
+ const storagePath = `contracts/${tenantId}/${Date.now()}-${file.name}`;
+
+ // Write file to filesystem
+ const fs = await import('node:fs/promises');
+ const path = await import('node:path');
+ const uploadDir = path.join(process.cwd(), 'uploads', 'contracts', tenantId);
+ 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 db
+ .insert(contractDocuments)
+ .values({
+ tenantId,
+ userId,
+ caseId: caseId ?? null,
+ filename: file.name,
+ mimeType: file.type,
+ fileSizeBytes: file.size,
+ storagePath: filePath,
+ status: 'uploaded',
+ deleteAfter,
+ })
+ .returning();
+
+ return {
+ documentId: doc.id,
+ filename: doc.filename,
+ status: doc.status,
+ };
+}
+
+/**
+ * Extract text from a contract document (PDF or DOCX).
+ * Updates the document record with extracted text.
+ */
+export async function extractDocumentText(documentId: string): Promise {
+ const [doc] = await db
+ .select()
+ .from(contractDocuments)
+ .where(eq(contractDocuments.id, documentId))
+ .limit(1);
+
+ if (!doc) throw new Error('Dokument nicht gefunden');
+
+ await db
+ .update(contractDocuments)
+ .set({ status: 'extracting', updatedAt: new Date() })
+ .where(eq(contractDocuments.id, documentId));
+
+ const fs = await import('node:fs/promises');
+ const fileBuffer = await fs.readFile(doc.storagePath);
+
+ let text: string;
+
+ if (doc.mimeType === 'application/pdf') {
+ // Dynamic import for pdf-parse (optional dependency)
+ const pdfParse = (await import('pdf-parse')).default;
+ const pdfData = await pdfParse(fileBuffer);
+ text = pdfData.text;
+ } else {
+ // DOCX — use mammoth for extraction
+ const mammoth = await import('mammoth');
+ const result = await mammoth.extractRawText({ buffer: fileBuffer });
+ text = result.value;
+ }
+
+ await db
+ .update(contractDocuments)
+ .set({
+ extractedText: text,
+ status: 'extracted',
+ updatedAt: new Date(),
+ })
+ .where(eq(contractDocuments.id, documentId));
+
+ return text;
+}
+
+/**
+ * Run AI-powered clause analysis on an extracted contract document.
+ * Identifies clauses, categorizes them, compares with standards, and rates them.
+ */
+export async function analyzeContractClauses(documentId: string): Promise {
+ const [doc] = await db
+ .select()
+ .from(contractDocuments)
+ .where(eq(contractDocuments.id, documentId))
+ .limit(1);
+
+ if (!doc) throw new Error('Dokument nicht gefunden');
+ if (!doc.extractedText) throw new Error('Text wurde noch nicht extrahiert');
+
+ await db
+ .update(contractDocuments)
+ .set({ status: 'analyzing', updatedAt: new Date() })
+ .where(eq(contractDocuments.id, documentId));
+
+ // Fetch standard clauses for comparison context
+ const standards = await db
+ .select({
+ id: standardClauses.id,
+ category: standardClauses.category,
+ label: standardClauses.label,
+ body: standardClauses.body,
+ instrumentAbbr: normInstruments.abbreviation,
+ })
+ .from(standardClauses)
+ .innerJoin(normInstruments, eq(standardClauses.instrumentId, normInstruments.id));
+
+ const standardsContext = standards.length > 0
+ ? standards
+ .map((s) => `### ${s.category}: ${s.label} (${s.instrumentAbbr})\n${s.body}`)
+ .join('\n\n')
+ : 'Keine Standardklauseln verfügbar.';
+
+ const model = getModel();
+
+ const { text: analysisResult } = await generateText({
+ model,
+ system: `Du bist ein Experte für deutsches Bühnenarbeitsrecht und Vertragsanalyse.
+Du analysierst Bühnenverträge (Normalvertrag Bühne) und identifizierst einzelne Klauseln.
+
+Für jede identifizierte Klausel gibst du ein JSON-Objekt zurück mit:
+- "category": Eine der folgenden Kategorien: ${CLAUSE_CATEGORIES.join(', ')}
+- "extractedText": Der exakte Klauseltext aus dem Vertrag
+- "rating": "standard" (entspricht NV Bühne), "abweichend" (weicht ab), "kritisch" (problematische Abweichung), oder "unbekannt"
+- "analysis": Kurze Bewertung der Klausel (1-3 Sätze)
+- "deviations": Array von Abweichungen vom Standard (leer wenn standard)
+- "riskScore": Risikobewertung 0-100 (0 = kein Risiko, 100 = höchstes Risiko)
+
+Antworte NUR mit einem JSON-Array von Klausel-Objekten. Kein Fließtext.`,
+ messages: [
+ {
+ role: 'user',
+ content: `## Standardklauseln (Referenz)\n\n${standardsContext}\n\n---\n\n## Zu analysierender Vertrag\n\n${doc.extractedText}`,
+ },
+ ],
+ maxOutputTokens: 8192,
+ });
+
+ // Parse AI response
+ let clauses: Array<{
+ category: string;
+ extractedText: string;
+ rating: string;
+ analysis: string;
+ deviations: string[];
+ riskScore: number;
+ }>;
+
+ try {
+ // Extract JSON from response (handle possible markdown code blocks)
+ const jsonMatch = analysisResult.match(/\[[\s\S]*\]/);
+ if (!jsonMatch) throw new Error('No JSON array found in AI response');
+ clauses = JSON.parse(jsonMatch[0]);
+ } catch {
+ await db
+ .update(contractDocuments)
+ .set({
+ status: 'failed',
+ errorMessage: 'KI-Antwort konnte nicht geparst werden',
+ updatedAt: new Date(),
+ })
+ .where(eq(contractDocuments.id, documentId));
+ return;
+ }
+
+ // Find matching standard clauses by category
+ const standardsByCategory = new Map(
+ standards.map((s) => [s.category, s.id]),
+ );
+
+ // Insert extracted clauses
+ const validRatings = new Set(['standard', 'abweichend', 'kritisch', 'unbekannt']);
+ for (const clause of clauses) {
+ const rating = validRatings.has(clause.rating) ? clause.rating : 'unbekannt';
+ const matchedStandardId = standardsByCategory.get(clause.category) ?? null;
+
+ await db.insert(contractClauses).values({
+ documentId,
+ category: clause.category,
+ extractedText: clause.extractedText,
+ standardClauseId: matchedStandardId,
+ rating: rating as 'standard' | 'abweichend' | 'kritisch' | 'unbekannt',
+ analysis: clause.analysis,
+ deviations: clause.deviations ?? [],
+ riskScore: Math.max(0, Math.min(100, clause.riskScore ?? 0)),
+ });
+ }
+
+ await db
+ .update(contractDocuments)
+ .set({ status: 'completed', updatedAt: new Date() })
+ .where(eq(contractDocuments.id, documentId));
+}
+
+/**
+ * Get a contract document with its analyzed clauses.
+ */
+export async function getContractAnalysis(documentId: string) {
+ const [doc] = await db
+ .select()
+ .from(contractDocuments)
+ .where(eq(contractDocuments.id, documentId))
+ .limit(1);
+
+ if (!doc) return null;
+
+ const clauses = await db
+ .select()
+ .from(contractClauses)
+ .where(eq(contractClauses.documentId, documentId))
+ .orderBy(contractClauses.category);
+
+ return { document: doc, clauses };
+}
+
+/**
+ * List contract documents for a tenant.
+ */
+export async function listContractDocuments(
+ tenantId: string,
+ limit = 20,
+ offset = 0,
+) {
+ return db
+ .select({
+ id: contractDocuments.id,
+ filename: contractDocuments.filename,
+ mimeType: contractDocuments.mimeType,
+ fileSizeBytes: contractDocuments.fileSizeBytes,
+ status: contractDocuments.status,
+ caseId: contractDocuments.caseId,
+ createdAt: contractDocuments.createdAt,
+ })
+ .from(contractDocuments)
+ .where(eq(contractDocuments.tenantId, tenantId))
+ .orderBy(desc(contractDocuments.createdAt))
+ .limit(limit)
+ .offset(offset);
+}
diff --git a/src/lib/db/index.ts b/src/lib/db/index.ts
index c3ef9e6..e876ca9 100644
--- a/src/lib/db/index.ts
+++ b/src/lib/db/index.ts
@@ -1,8 +1,8 @@
// Database connection and Drizzle ORM setup
// PostgreSQL with Row-Level Security for tenant isolation
-import { drizzle } from 'drizzle-orm/node-postgres';
-import { Pool } from 'pg';
+import { drizzle, type NodePgDatabase } from 'drizzle-orm/node-postgres';
+import { Pool, type PoolClient } from 'pg';
import * as schema from './schema';
const pool = new Pool({
@@ -11,3 +11,30 @@ const pool = new Pool({
export const db = drizzle(pool, { schema });
export { pool };
+
+/**
+ * Execute a callback with tenant isolation via RLS.
+ *
+ * Acquires a dedicated connection from the pool, sets `app.tenant_id`
+ * so PostgreSQL RLS policies enforce row-level tenant isolation, then
+ * releases the connection after the callback completes.
+ *
+ * This is the ONLY safe way to run tenant-scoped queries — never
+ * use `db` directly for tenant data, as it does not set the tenant context.
+ */
+export async function withTenantDb(
+ tenantId: string,
+ callback: (tenantDb: NodePgDatabase) => Promise,
+): Promise {
+ const client: PoolClient = await pool.connect();
+ try {
+ // Set tenant context for RLS — uses parameterized SET to prevent SQL injection
+ await client.query(`SET LOCAL app.tenant_id = $1`, [tenantId]);
+ const tenantDb = drizzle(client, { schema });
+ return await callback(tenantDb);
+ } finally {
+ // RESET ensures no tenant context leaks to the next user of this connection
+ await client.query(`RESET app.tenant_id`);
+ client.release();
+ }
+}
diff --git a/src/lib/db/tenant.ts b/src/lib/db/tenant.ts
new file mode 100644
index 0000000..0cee838
--- /dev/null
+++ b/src/lib/db/tenant.ts
@@ -0,0 +1,22 @@
+// Tenant context helpers for RLS
+// Sets current_setting('app.tenant_id') on the database connection
+
+import { pool } from "./index";
+import type { PoolClient } from "pg";
+
+/**
+ * Execute a callback within a tenant-scoped database connection.
+ * Sets the RLS tenant_id setting before running queries.
+ */
+export async function withTenant(
+ tenantId: string,
+ fn: (client: PoolClient) => Promise,
+): Promise {
+ const client = await pool.connect();
+ try {
+ await client.query(`SET LOCAL app.tenant_id = '${tenantId}'`);
+ return await fn(client);
+ } finally {
+ client.release();
+ }
+}
diff --git a/src/lib/proceedings/deadlines.ts b/src/lib/proceedings/deadlines.ts
new file mode 100644
index 0000000..df24276
--- /dev/null
+++ b/src/lib/proceedings/deadlines.ts
@@ -0,0 +1,188 @@
+// Deadline calculation utilities for German legal proceedings.
+// Implements BGB §§ 186-193 rules for computing procedural deadlines:
+// - Weekends and public holidays push deadlines to the next working day
+// - German public holidays (nation-wide) are considered
+
+import type { DeadlineTemplate } from "./workflows";
+
+/** German nation-wide public holidays (fixed dates, MM-DD format). */
+const FIXED_HOLIDAYS = [
+ "01-01", // Neujahr
+ "05-01", // Tag der Arbeit
+ "10-03", // Tag der Deutschen Einheit
+ "12-25", // 1. Weihnachtstag
+ "12-26", // 2. Weihnachtstag
+];
+
+/** Compute Easter Sunday for a given year (Gauss/Anonymous algorithm). */
+function easterSunday(year: number): Date {
+ const a = year % 19;
+ const b = Math.floor(year / 100);
+ const c = year % 100;
+ const d = Math.floor(b / 4);
+ const e = b % 4;
+ const f = Math.floor((b + 8) / 25);
+ const g = Math.floor((b - f + 1) / 3);
+ const h = (19 * a + b - d - g + 15) % 30;
+ const i = Math.floor(c / 4);
+ const k = c % 4;
+ const l = (32 + 2 * e + 2 * i - h - k) % 7;
+ const m = Math.floor((a + 11 * h + 22 * l) / 451);
+ const month = Math.floor((h + l - 7 * m + 114) / 31);
+ const day = ((h + l - 7 * m + 114) % 31) + 1;
+ return new Date(year, month - 1, day);
+}
+
+/** Get all movable holidays for a given year (Easter-dependent). */
+function movableHolidays(year: number): Date[] {
+ const easter = easterSunday(year);
+ const offset = (days: number) => {
+ const d = new Date(easter);
+ d.setDate(d.getDate() + days);
+ return d;
+ };
+ return [
+ offset(-2), // Karfreitag
+ offset(1), // Ostermontag
+ offset(39), // Christi Himmelfahrt
+ offset(50), // Pfingstmontag
+ ];
+}
+
+/** Check if a date is a German public holiday (nation-wide only). */
+function isPublicHoliday(d: Date): boolean {
+ const mmdd =
+ String(d.getMonth() + 1).padStart(2, "0") +
+ "-" +
+ String(d.getDate()).padStart(2, "0");
+ if (FIXED_HOLIDAYS.includes(mmdd)) return true;
+
+ const year = d.getFullYear();
+ return movableHolidays(year).some(
+ (h) =>
+ h.getFullYear() === d.getFullYear() &&
+ h.getMonth() === d.getMonth() &&
+ h.getDate() === d.getDate(),
+ );
+}
+
+/** Check if a date falls on a weekend. */
+function isWeekend(d: Date): boolean {
+ const day = d.getDay();
+ return day === 0 || day === 6;
+}
+
+/**
+ * Adjust a deadline to the next working day if it falls on a
+ * weekend or public holiday (§ 193 BGB analog).
+ */
+function nextWorkingDay(d: Date): Date {
+ const result = new Date(d);
+ while (isWeekend(result) || isPublicHoliday(result)) {
+ result.setDate(result.getDate() + 1);
+ }
+ return result;
+}
+
+/** Format a Date as YYYY-MM-DD string. */
+function formatDate(d: Date): string {
+ return (
+ d.getFullYear() +
+ "-" +
+ String(d.getMonth() + 1).padStart(2, "0") +
+ "-" +
+ String(d.getDate()).padStart(2, "0")
+ );
+}
+
+/** Parse YYYY-MM-DD string to a Date at midnight UTC. */
+function parseDate(s: string): Date {
+ const [y, m, d] = s.split("-").map(Number);
+ return new Date(y, m - 1, d);
+}
+
+export interface CalculatedDeadline {
+ label: string;
+ description: string;
+ legalBasis: string;
+ type: "frist" | "termin" | "vorfrist";
+ dueDate: string;
+ warningDate: string | null;
+ warningDaysBefore: number;
+ isCalculated: true;
+ calculationBasis: string;
+}
+
+/**
+ * Calculate concrete deadlines from a template, given a reference date
+ * (typically the date a step becomes active or a filing date).
+ *
+ * Applies German deadline computation rules:
+ * - Calendar days added to reference date
+ * - If result falls on a weekend or public holiday, pushed to next working day (§ 193 BGB)
+ * - Warning dates are computed backwards from the due date
+ */
+export function calculateDeadlines(
+ templates: DeadlineTemplate[],
+ referenceDate: string,
+): CalculatedDeadline[] {
+ const refDate = parseDate(referenceDate);
+
+ return templates.map((t) => {
+ // Add calendar days
+ const rawDue = new Date(refDate);
+ rawDue.setDate(rawDue.getDate() + t.daysFromActivation);
+
+ // Adjust for weekends/holidays (§ 193 BGB)
+ const due = nextWorkingDay(rawDue);
+ const dueDateStr = formatDate(due);
+
+ // Warning date: subtract days before due date
+ let warningDateStr: string | null = null;
+ if (t.warningDaysBefore > 0) {
+ const warning = new Date(due);
+ warning.setDate(warning.getDate() - t.warningDaysBefore);
+ warningDateStr = formatDate(warning);
+ }
+
+ return {
+ label: t.label,
+ description: t.description,
+ legalBasis: t.legalBasis,
+ type: t.type,
+ dueDate: dueDateStr,
+ warningDate: warningDateStr,
+ warningDaysBefore: t.warningDaysBefore,
+ isCalculated: true as const,
+ calculationBasis: `${t.daysFromActivation} Tage ab ${referenceDate} (${t.legalBasis}), ggf. verschoben auf nächsten Werktag gem. § 193 BGB.`,
+ };
+ });
+}
+
+/**
+ * Get upcoming deadlines within a given number of days from today.
+ * Useful for deadline monitoring / dashboard queries.
+ */
+export function isDeadlineUpcoming(
+ dueDate: string,
+ withinDays: number,
+ today?: string,
+): boolean {
+ const ref = today ? parseDate(today) : new Date();
+ const due = parseDate(dueDate);
+ const diffMs = due.getTime() - ref.getTime();
+ const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24));
+ return diffDays >= 0 && diffDays <= withinDays;
+}
+
+/**
+ * Check if a deadline is overdue.
+ */
+export function isDeadlineOverdue(
+ dueDate: string,
+ today?: string,
+): boolean {
+ const ref = today ? parseDate(today) : new Date();
+ const due = parseDate(dueDate);
+ return due.getTime() < ref.getTime();
+}
diff --git a/src/lib/proceedings/index.ts b/src/lib/proceedings/index.ts
new file mode 100644
index 0000000..2cc0509
--- /dev/null
+++ b/src/lib/proceedings/index.ts
@@ -0,0 +1,105 @@
+// Proceedings module — workflow engine for BSchGO and ArbGG proceedings.
+// Provides functions to initialize, advance, and query proceedings.
+
+export { workflowTemplates, type WorkflowTemplate, type StepTemplate } from "./workflows";
+export { calculateDeadlines, isDeadlineUpcoming, isDeadlineOverdue, type CalculatedDeadline } from "./deadlines";
+
+import { workflowTemplates } from "./workflows";
+import { calculateDeadlines } from "./deadlines";
+import type { CalculatedDeadline } from "./deadlines";
+
+export interface InitializedStep {
+ stepKey: string;
+ label: string;
+ description: string;
+ sortOrder: number;
+ status: "ausstehend" | "aktiv";
+ legalBasis: string;
+ responsibleParty: string;
+}
+
+export interface InitializedProceeding {
+ steps: InitializedStep[];
+ deadlines: (CalculatedDeadline & { stepKey: string })[];
+ firstStepKey: string;
+}
+
+/**
+ * Initialize a proceeding with all steps from the workflow template.
+ * The first step is set to "aktiv", all others are "ausstehend".
+ * Deadlines for the first step are pre-calculated from the filing date.
+ */
+export function initializeProceeding(
+ proceedingType: string,
+ filingDate: string,
+): InitializedProceeding {
+ const template = workflowTemplates[proceedingType];
+ if (!template) {
+ throw new Error(`Unknown proceeding type: ${proceedingType}`);
+ }
+
+ const steps: InitializedStep[] = template.steps.map((s, i) => ({
+ stepKey: s.key,
+ label: s.label,
+ description: s.description,
+ sortOrder: i,
+ status: i === 0 ? "aktiv" : "ausstehend",
+ legalBasis: s.legalBasis,
+ responsibleParty: s.responsibleParty,
+ }));
+
+ // Calculate deadlines for the first step
+ const firstStep = template.steps[0];
+ const deadlines = calculateDeadlines(firstStep.deadlines, filingDate).map(
+ (d) => ({ ...d, stepKey: firstStep.key }),
+ );
+
+ return {
+ steps,
+ deadlines,
+ firstStepKey: firstStep.key,
+ };
+}
+
+/**
+ * Advance a proceeding to the next step.
+ * Returns the deadlines to create for the new active step.
+ */
+export function advanceStep(
+ proceedingType: string,
+ currentStepKey: string,
+ activationDate: string,
+): {
+ nextStepKey: string | null;
+ deadlines: (CalculatedDeadline & { stepKey: string })[];
+} {
+ const template = workflowTemplates[proceedingType];
+ if (!template) {
+ throw new Error(`Unknown proceeding type: ${proceedingType}`);
+ }
+
+ const currentIndex = template.steps.findIndex(
+ (s) => s.key === currentStepKey,
+ );
+ if (currentIndex === -1) {
+ throw new Error(
+ `Unknown step "${currentStepKey}" in proceeding type "${proceedingType}".`,
+ );
+ }
+
+ const nextIndex = currentIndex + 1;
+ if (nextIndex >= template.steps.length) {
+ return { nextStepKey: null, deadlines: [] };
+ }
+
+ const nextStep = template.steps[nextIndex];
+ const deadlines = calculateDeadlines(
+ nextStep.deadlines,
+ activationDate,
+ ).map((d) => ({ ...d, stepKey: nextStep.key }));
+
+ return {
+ nextStepKey: nextStep.key,
+ deadlines,
+ };
+}
diff --git a/src/lib/proceedings/workflows.ts b/src/lib/proceedings/workflows.ts
new file mode 100644
index 0000000..c87cea1
--- /dev/null
+++ b/src/lib/proceedings/workflows.ts
@@ -0,0 +1,590 @@
+// Workflow templates for BSchGO and ArbGG proceedings.
+// Each template defines the procedural steps, their order, legal basis,
+// and which deadlines should be auto-generated at each step.
+
+export interface StepTemplate {
+ key: string;
+ label: string;
+ description: string;
+ legalBasis: string;
+ responsibleParty: string;
+ /** Deadlines to auto-create when this step becomes active */
+ deadlines: DeadlineTemplate[];
+}
+
+export interface DeadlineTemplate {
+ label: string;
+ description: string;
+ legalBasis: string;
+ /** Calendar days from step activation to due date */
+ daysFromActivation: number;
+ /** Days before due date for a warning reminder */
+ warningDaysBefore: number;
+ type: "frist" | "termin" | "vorfrist";
+}
+
+export interface WorkflowTemplate {
+ type: string;
+ label: string;
+ description: string;
+ legalBasis: string;
+ steps: StepTemplate[];
+}
+
+/**
+ * BSchGO Bezirksschiedsgericht — regional stage arbitration.
+ * Flow: Antrag -> Schiedsrichterbestellung -> Schriftsatzaustausch -> Verhandlung -> Schiedsspruch
+ * Based on §§ 1-22 BSchGO.
+ */
+export const bschgoBezirkWorkflow: WorkflowTemplate = {
+ type: "bschgo_bezirk",
+ label: "BSchGO Bezirksschiedsgericht",
+ description:
+ "Bühnenschiedsverfahren vor dem Bezirksbühnenschiedsgericht nach der BSchGO.",
+ legalBasis: "§§ 1-22 BSchGO",
+ steps: [
+ {
+ key: "antrag",
+ label: "Antragstellung",
+ description:
+ "Einreichung des Schiedsantrags beim zuständigen Bezirksbühnenschiedsgericht mit Begründung und Beweismitteln.",
+ legalBasis: "§ 9 BSchGO",
+ responsibleParty: "Antragsteller",
+ deadlines: [
+ {
+ label: "Antragsschrift fertigstellen",
+ description: "Frist zur Fertigstellung und Einreichung des Schiedsantrags.",
+ legalBasis: "§ 9 BSchGO",
+ daysFromActivation: 14,
+ warningDaysBefore: 3,
+ type: "frist",
+ },
+ ],
+ },
+ {
+ key: "schiedsrichterbestellung",
+ label: "Schiedsrichterbestellung",
+ description:
+ "Bestellung der Beisitzer durch die Parteien und Bestimmung des Vorsitzenden.",
+ legalBasis: "§§ 4-7 BSchGO",
+ responsibleParty: "Parteien / Geschäftsstelle",
+ deadlines: [
+ {
+ label: "Beisitzerbenennung",
+ description:
+ "Frist zur Benennung der Beisitzer durch die Parteien.",
+ legalBasis: "§ 5 BSchGO",
+ daysFromActivation: 14,
+ warningDaysBefore: 3,
+ type: "frist",
+ },
+ ],
+ },
+ {
+ key: "erwiderung",
+ label: "Erwiderung",
+ description:
+ "Der Antragsgegner reicht seine Erwiderung (Klageerwiderung) ein.",
+ legalBasis: "§ 10 BSchGO",
+ responsibleParty: "Antragsgegner",
+ deadlines: [
+ {
+ label: "Erwiderungsfrist",
+ description:
+ "Frist für den Antragsgegner zur Einreichung der Erwiderung.",
+ legalBasis: "§ 10 BSchGO",
+ daysFromActivation: 21,
+ warningDaysBefore: 5,
+ type: "frist",
+ },
+ ],
+ },
+ {
+ key: "replik_duplik",
+ label: "Schriftsatzaustausch (Replik/Duplik)",
+ description:
+ "Weiterer Schriftsatzaustausch — Replik des Antragstellers und ggf. Duplik.",
+ legalBasis: "§ 10 BSchGO",
+ responsibleParty: "Parteien",
+ deadlines: [
+ {
+ label: "Replikfrist",
+ description: "Frist für die Replik des Antragstellers.",
+ legalBasis: "§ 10 BSchGO",
+ daysFromActivation: 14,
+ warningDaysBefore: 3,
+ type: "frist",
+ },
+ ],
+ },
+ {
+ key: "verhandlung",
+ label: "Mündliche Verhandlung",
+ description:
+ "Mündliche Verhandlung vor dem Bezirksbühnenschiedsgericht mit Beweisaufnahme.",
+ legalBasis: "§§ 12-14 BSchGO",
+ responsibleParty: "Schiedsgericht",
+ deadlines: [
+ {
+ label: "Verhandlungstermin",
+ description: "Termin der mündlichen Verhandlung.",
+ legalBasis: "§ 12 BSchGO",
+ daysFromActivation: 28,
+ warningDaysBefore: 7,
+ type: "termin",
+ },
+ ],
+ },
+ {
+ key: "schiedsspruch",
+ label: "Schiedsspruch",
+ description:
+ "Verkündung/Zustellung des Schiedsspruchs des Bezirksbühnenschiedsgerichts.",
+ legalBasis: "§ 16 BSchGO",
+ responsibleParty: "Schiedsgericht",
+ deadlines: [
+ {
+ label: "Schiedsspruchfrist",
+ description:
+ "Frist zur Zustellung des Schiedsspruchs nach der Verhandlung.",
+ legalBasis: "§ 16 BSchGO",
+ daysFromActivation: 30,
+ warningDaysBefore: 7,
+ type: "frist",
+ },
+ ],
+ },
+ ],
+};
+
+/**
+ * BSchGO Bundesschiedsgericht — federal stage arbitration (appeal).
+ * Flow: Berufung -> Schiedsrichterbestellung -> Schriftsatzwechsel -> Verhandlung -> Schiedsspruch
+ * Based on §§ 18-22 BSchGO.
+ */
+export const bschgoBundWorkflow: WorkflowTemplate = {
+ type: "bschgo_bund",
+ label: "BSchGO Bundesbühnenschiedsgericht (Berufung)",
+ description:
+ "Berufungsverfahren vor dem Bundesbühnenschiedsgericht nach §§ 18-22 BSchGO.",
+ legalBasis: "§§ 18-22 BSchGO",
+ steps: [
+ {
+ key: "berufung",
+ label: "Berufungseinlegung",
+ description:
+ "Einlegung der Berufung gegen den Schiedsspruch des Bezirksbühnenschiedsgerichts.",
+ legalBasis: "§ 18 BSchGO",
+ responsibleParty: "Berufungsführer",
+ deadlines: [
+ {
+ label: "Berufungsfrist",
+ description:
+ "Frist zur Einlegung der Berufung nach Zustellung des Schiedsspruchs (1 Monat).",
+ legalBasis: "§ 18 Abs. 2 BSchGO",
+ daysFromActivation: 30,
+ warningDaysBefore: 7,
+ type: "frist",
+ },
+ ],
+ },
+ {
+ key: "berufungsbegruendung",
+ label: "Berufungsbegründung",
+ description: "Einreichung der Berufungsbegründung.",
+ legalBasis: "§ 19 BSchGO",
+ responsibleParty: "Berufungsführer",
+ deadlines: [
+ {
+ label: "Berufungsbegründungsfrist",
+ description: "Frist zur Begründung der Berufung.",
+ legalBasis: "§ 19 BSchGO",
+ daysFromActivation: 30,
+ warningDaysBefore: 7,
+ type: "frist",
+ },
+ ],
+ },
+ {
+ key: "berufungserwiderung",
+ label: "Berufungserwiderung",
+ description: "Erwiderung des Berufungsgegners.",
+ legalBasis: "§ 19 BSchGO",
+ responsibleParty: "Berufungsgegner",
+ deadlines: [
+ {
+ label: "Berufungserwiderungsfrist",
+ description: "Frist für die Berufungserwiderung.",
+ legalBasis: "§ 19 BSchGO",
+ daysFromActivation: 21,
+ warningDaysBefore: 5,
+ type: "frist",
+ },
+ ],
+ },
+ {
+ key: "verhandlung",
+ label: "Mündliche Verhandlung",
+ description:
+ "Mündliche Verhandlung vor dem Bundesbühnenschiedsgericht.",
+ legalBasis: "§ 20 BSchGO",
+ responsibleParty: "Schiedsgericht",
+ deadlines: [
+ {
+ label: "Verhandlungstermin",
+ description: "Termin der mündlichen Verhandlung.",
+ legalBasis: "§ 20 BSchGO",
+ daysFromActivation: 42,
+ warningDaysBefore: 7,
+ type: "termin",
+ },
+ ],
+ },
+ {
+ key: "schiedsspruch",
+ label: "Schiedsspruch",
+ description: "Schiedsspruch des Bundesbühnenschiedsgerichts.",
+ legalBasis: "§ 21 BSchGO",
+ responsibleParty: "Schiedsgericht",
+ deadlines: [
+ {
+ label: "Schiedsspruchfrist",
+ description: "Frist zur Zustellung des Schiedsspruchs.",
+ legalBasis: "§ 21 BSchGO",
+ daysFromActivation: 30,
+ warningDaysBefore: 7,
+ type: "frist",
+ },
+ ],
+ },
+ ],
+};
+
+/**
+ * ArbGG Erste Instanz — labor court first instance.
+ * Flow: Klage -> Gütetermin -> Kammertermin -> Urteil
+ * Based on §§ 46-62 ArbGG, §§ 495 ff. ZPO.
+ */
+export const arbggErsteInstanzWorkflow: WorkflowTemplate = {
+ type: "arbgg_erste_instanz",
+ label: "ArbGG Arbeitsgericht (1. Instanz)",
+ description:
+ "Arbeitsgerichtliches Verfahren erster Instanz (Urteilsverfahren) nach §§ 46-62 ArbGG.",
+ legalBasis: "§§ 46-62 ArbGG",
+ steps: [
+ {
+ key: "klage",
+ label: "Klageerhebung",
+ description:
+ "Einreichung der Klageschrift beim zuständigen Arbeitsgericht.",
+ legalBasis: "§ 46 Abs. 2 ArbGG i.V.m. § 253 ZPO",
+ responsibleParty: "Kläger",
+ deadlines: [
+ {
+ label: "Klageschrift einreichen",
+ description: "Frist zur Fertigstellung und Einreichung der Klageschrift.",
+ legalBasis: "§ 46 Abs. 2 ArbGG",
+ daysFromActivation: 14,
+ warningDaysBefore: 3,
+ type: "frist",
+ },
+ ],
+ },
+ {
+ key: "zustellung",
+ label: "Zustellung der Klage",
+ description:
+ "Zustellung der Klageschrift an den Beklagten durch das Gericht.",
+ legalBasis: "§ 46 Abs. 2 ArbGG i.V.m. § 271 ZPO",
+ responsibleParty: "Gericht",
+ deadlines: [],
+ },
+ {
+ key: "klageerwiderung",
+ label: "Klageerwiderung",
+ description: "Einreichung der Klageerwiderung durch den Beklagten.",
+ legalBasis: "§ 46 Abs. 2 ArbGG i.V.m. § 277 ZPO",
+ responsibleParty: "Beklagter",
+ deadlines: [
+ {
+ label: "Klageerwiderungsfrist",
+ description: "Frist des Beklagten zur Erwiderung auf die Klage.",
+ legalBasis: "§ 46 Abs. 2 ArbGG i.V.m. § 277 ZPO",
+ daysFromActivation: 14,
+ warningDaysBefore: 3,
+ type: "frist",
+ },
+ ],
+ },
+ {
+ key: "guetetermin",
+ label: "Gütetermin",
+ description:
+ "Obligatorische Güteverhandlung vor dem Vorsitzenden allein (§ 54 ArbGG). " +
+ "Ziel ist die gütliche Einigung; Erscheinen ist Pflicht.",
+ legalBasis: "§ 54 ArbGG",
+ responsibleParty: "Gericht (Vorsitzender)",
+ deadlines: [
+ {
+ label: "Gütetermin",
+ description:
+ "Güteverhandlung — soll innerhalb von 2 Wochen nach Klageerhebung anberaumt werden.",
+ legalBasis: "§ 54 Abs. 1 ArbGG",
+ daysFromActivation: 14,
+ warningDaysBefore: 3,
+ type: "termin",
+ },
+ ],
+ },
+ {
+ key: "kammertermin",
+ label: "Kammertermin",
+ description:
+ "Verhandlung vor der vollbesetzten Kammer (Vorsitzender + 2 ehrenamtliche Richter).",
+ legalBasis: "§ 55 ArbGG",
+ responsibleParty: "Gericht (Kammer)",
+ deadlines: [
+ {
+ label: "Kammertermin",
+ description: "Termin der Kammerverhandlung.",
+ legalBasis: "§ 55 ArbGG",
+ daysFromActivation: 42,
+ warningDaysBefore: 7,
+ type: "termin",
+ },
+ ],
+ },
+ {
+ key: "urteil",
+ label: "Urteil",
+ description:
+ "Verkündung des Urteils nach der Kammerverhandlung.",
+ legalBasis: "§ 60 ArbGG",
+ responsibleParty: "Gericht",
+ deadlines: [
+ {
+ label: "Urteilsverkündung",
+ description: "Termin der Urteilsverkündung (i.d.R. im Anschluss an die Verhandlung).",
+ legalBasis: "§ 60 ArbGG",
+ daysFromActivation: 21,
+ warningDaysBefore: 5,
+ type: "termin",
+ },
+ ],
+ },
+ ],
+};
+
+/**
+ * ArbGG Berufung — labor court appeal (LAG).
+ * Flow: Berufungseinlegung -> Berufungsbegründung -> Berufungserwiderung -> Verhandlung -> Urteil
+ * Based on §§ 64-72 ArbGG.
+ */
+export const arbggBerufungWorkflow: WorkflowTemplate = {
+ type: "arbgg_berufung",
+ label: "ArbGG Landesarbeitsgericht (Berufung)",
+ description:
+ "Berufungsverfahren vor dem Landesarbeitsgericht nach §§ 64-72 ArbGG.",
+ legalBasis: "§§ 64-72 ArbGG",
+ steps: [
+ {
+ key: "berufung",
+ label: "Berufungseinlegung",
+ description:
+ "Einlegung der Berufung beim LAG innerhalb eines Monats nach Zustellung des Urteils.",
+ legalBasis: "§ 66 Abs. 1 ArbGG",
+ responsibleParty: "Berufungsführer",
+ deadlines: [
+ {
+ label: "Berufungsfrist",
+ description:
+ "Frist zur Einlegung der Berufung (1 Monat ab Urteilszustellung).",
+ legalBasis: "§ 66 Abs. 1 S. 1 ArbGG",
+ daysFromActivation: 30,
+ warningDaysBefore: 7,
+ type: "frist",
+ },
+ ],
+ },
+ {
+ key: "berufungsbegruendung",
+ label: "Berufungsbegründung",
+ description:
+ "Einreichung der Berufungsbegründung innerhalb von zwei Monaten nach Urteilszustellung.",
+ legalBasis: "§ 66 Abs. 1 S. 1 ArbGG",
+ responsibleParty: "Berufungsführer",
+ deadlines: [
+ {
+ label: "Berufungsbegründungsfrist",
+ description:
+ "Frist zur Begründung der Berufung (2 Monate ab Urteilszustellung).",
+ legalBasis: "§ 66 Abs. 1 S. 1 ArbGG",
+ daysFromActivation: 60,
+ warningDaysBefore: 14,
+ type: "frist",
+ },
+ ],
+ },
+ {
+ key: "berufungserwiderung",
+ label: "Berufungserwiderung",
+ description: "Erwiderung des Berufungsgegners.",
+ legalBasis: "§ 66 Abs. 1 ArbGG",
+ responsibleParty: "Berufungsgegner",
+ deadlines: [
+ {
+ label: "Berufungserwiderungsfrist",
+ description: "Frist für die Berufungserwiderung.",
+ legalBasis: "§ 66 Abs. 1 ArbGG",
+ daysFromActivation: 30,
+ warningDaysBefore: 7,
+ type: "frist",
+ },
+ ],
+ },
+ {
+ key: "verhandlung",
+ label: "Mündliche Verhandlung",
+ description: "Mündliche Verhandlung vor dem LAG.",
+ legalBasis: "§ 64 Abs. 7 ArbGG",
+ responsibleParty: "Gericht",
+ deadlines: [
+ {
+ label: "Verhandlungstermin",
+ description: "Termin der Berufungsverhandlung.",
+ legalBasis: "§ 64 Abs. 7 ArbGG",
+ daysFromActivation: 56,
+ warningDaysBefore: 7,
+ type: "termin",
+ },
+ ],
+ },
+ {
+ key: "urteil",
+ label: "Urteil",
+ description: "Urteil des Landesarbeitsgerichts.",
+ legalBasis: "§ 64 Abs. 7 ArbGG",
+ responsibleParty: "Gericht",
+ deadlines: [
+ {
+ label: "Urteilsverkündung",
+ description: "Termin der Urteilsverkündung.",
+ legalBasis: "§ 64 Abs. 7 ArbGG",
+ daysFromActivation: 21,
+ warningDaysBefore: 5,
+ type: "termin",
+ },
+ ],
+ },
+ ],
+};
+
+/**
+ * ArbGG Revision — revision to federal labor court (BAG).
+ * Flow: Revisionseinlegung -> Revisionsbegründung -> Revisionserwiderung -> Verhandlung -> Urteil
+ * Based on §§ 72-77 ArbGG.
+ */
+export const arbggRevisionWorkflow: WorkflowTemplate = {
+ type: "arbgg_revision",
+ label: "ArbGG Bundesarbeitsgericht (Revision)",
+ description:
+ "Revisionsverfahren vor dem Bundesarbeitsgericht nach §§ 72-77 ArbGG.",
+ legalBasis: "§§ 72-77 ArbGG",
+ steps: [
+ {
+ key: "revision",
+ label: "Revisionseinlegung",
+ description:
+ "Einlegung der Revision beim BAG innerhalb eines Monats nach Zustellung des LAG-Urteils.",
+ legalBasis: "§ 74 Abs. 1 ArbGG",
+ responsibleParty: "Revisionskläger",
+ deadlines: [
+ {
+ label: "Revisionsfrist",
+ description: "Frist zur Einlegung der Revision (1 Monat).",
+ legalBasis: "§ 74 Abs. 1 S. 1 ArbGG",
+ daysFromActivation: 30,
+ warningDaysBefore: 7,
+ type: "frist",
+ },
+ ],
+ },
+ {
+ key: "revisionsbegruendung",
+ label: "Revisionsbegründung",
+ description: "Einreichung der Revisionsbegründung.",
+ legalBasis: "§ 74 Abs. 1 ArbGG",
+ responsibleParty: "Revisionskläger",
+ deadlines: [
+ {
+ label: "Revisionsbegründungsfrist",
+ description: "Frist zur Begründung der Revision (2 Monate).",
+ legalBasis: "§ 74 Abs. 1 S. 1 ArbGG",
+ daysFromActivation: 60,
+ warningDaysBefore: 14,
+ type: "frist",
+ },
+ ],
+ },
+ {
+ key: "revisionserwiderung",
+ label: "Revisionserwiderung",
+ description: "Erwiderung des Revisionsbeklagten.",
+ legalBasis: "§ 74 ArbGG",
+ responsibleParty: "Revisionsbeklagter",
+ deadlines: [
+ {
+ label: "Revisionserwiderungsfrist",
+ description: "Frist für die Revisionserwiderung.",
+ legalBasis: "§ 74 ArbGG",
+ daysFromActivation: 30,
+ warningDaysBefore: 7,
+ type: "frist",
+ },
+ ],
+ },
+ {
+ key: "verhandlung",
+ label: "Mündliche Verhandlung",
+ description: "Mündliche Verhandlung vor dem BAG.",
+ legalBasis: "§ 75 ArbGG",
+ responsibleParty: "Gericht",
+ deadlines: [
+ {
+ label: "Verhandlungstermin",
+ description: "Termin der Revisionsverhandlung.",
+ legalBasis: "§ 75 ArbGG",
+ daysFromActivation: 90,
+ warningDaysBefore: 14,
+ type: "termin",
+ },
+ ],
+ },
+ {
+ key: "urteil",
+ label: "Urteil",
+ description: "Urteil des Bundesarbeitsgerichts.",
+ legalBasis: "§ 75 ArbGG",
+ responsibleParty: "Gericht",
+ deadlines: [
+ {
+ label: "Urteilsverkündung",
+ description: "Termin der Urteilsverkündung.",
+ legalBasis: "§ 75 ArbGG",
+ daysFromActivation: 21,
+ warningDaysBefore: 5,
+ type: "termin",
+ },
+ ],
+ },
+ ],
+};
+
+/** All available workflow templates indexed by proceeding type */
+export const workflowTemplates: Record = {
+ bschgo_bezirk: bschgoBezirkWorkflow,
+ bschgo_bund: bschgoBundWorkflow,
+ arbgg_erste_instanz: arbggErsteInstanzWorkflow,
+ arbgg_berufung: arbggBerufungWorkflow,
+ arbgg_revision: arbggRevisionWorkflow,
+};
diff --git a/src/middleware.ts b/src/middleware.ts
new file mode 100644
index 0000000..c7618f1
--- /dev/null
+++ b/src/middleware.ts
@@ -0,0 +1,46 @@
+// Next.js middleware — authentication gate
+// Redirects unauthenticated users to login for protected routes.
+// Runs at the edge before the page/api handler.
+
+import { NextResponse } from 'next/server';
+import type { NextRequest } from 'next/server';
+import { getToken } from 'next-auth/jwt';
+
+const PUBLIC_PATHS = new Set(['/', '/login', '/register']);
+
+export async function middleware(request: NextRequest) {
+ const { pathname } = request.nextUrl;
+
+ // Allow public paths, NextAuth API, and static assets
+ if (
+ PUBLIC_PATHS.has(pathname) ||
+ pathname.startsWith('/api/auth') ||
+ pathname.startsWith('/_next') ||
+ pathname.includes('.')
+ ) {
+ return NextResponse.next();
+ }
+
+ const token = await getToken({ req: request });
+
+ if (!token) {
+ const loginUrl = new URL('/login', request.url);
+ loginUrl.searchParams.set('callbackUrl', pathname);
+ return NextResponse.redirect(loginUrl);
+ }
+
+ // Inject tenant context into request headers so server components
+ // and API routes can read it without re-decoding the JWT.
+ const response = NextResponse.next();
+ response.headers.set('x-tenant-id', token.tenantId as string);
+ response.headers.set('x-user-id', token.id as string);
+ response.headers.set('x-user-role', token.role as string);
+ return response;
+}
+
+export const config = {
+ matcher: [
+ // Match all paths except static files and _next
+ '/((?!_next/static|_next/image|favicon.ico).*)',
+ ],
+};
diff --git a/src/types/mammoth.d.ts b/src/types/mammoth.d.ts
new file mode 100644
index 0000000..16d8718
--- /dev/null
+++ b/src/types/mammoth.d.ts
@@ -0,0 +1,14 @@
+declare module 'mammoth' {
+ interface ConversionResult {
+ value: string;
+ messages: Array<{ type: string; message: string }>;
+ }
+
+ interface Options {
+ buffer?: Buffer;
+ path?: string;
+ }
+
+ export function extractRawText(options: Options): Promise;
+ export function convertToHtml(options: Options): Promise;
+}
diff --git a/src/types/pdf-parse.d.ts b/src/types/pdf-parse.d.ts
new file mode 100644
index 0000000..12e5dc7
--- /dev/null
+++ b/src/types/pdf-parse.d.ts
@@ -0,0 +1,14 @@
+declare module 'pdf-parse' {
+ interface PdfData {
+ numpages: number;
+ numrender: number;
+ info: Record;
+ metadata: Record;
+ text: string;
+ version: string;
+ }
+
+ function pdfParse(dataBuffer: Buffer, options?: Record): Promise;
+
+ export default pdfParse;
+}