feat: Phase 4 — WebApp-Frontend für Bühnenrecht (AIIA-27)

Complete frontend implementation with Next.js App Router:

- Dashboard with case/analysis/proceeding stats and quick actions
- Normen-Browser with Quellenrang hierarchy and instrument detail
- Entscheidungssuche with full-text search and detail view
- Analysemodus with streaming AI analysis (4 modes: Gutachten, Entscheidung, Vergleich, Risiko)
- Vertragsanalyse with file upload (PDF/DOCX)
- Verfahren overview (BSchGO/ArbGG)
- Auth pages (Login/Register)
- Mandantenfähigkeit: tenant switcher, RBAC-based settings
- Responsive sidebar navigation with Tailwind CSS
- Dashboard layout with session-based auth guard
- Installed missing runtime deps (pdf-parse, mammoth, devDependencies)

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
CTO (LegalAI)
2026-04-09 00:23:09 +00:00
parent 3c16fdc30f
commit 78ccf64948
64 changed files with 9541 additions and 100 deletions

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

487
package-lock.json generated
View File

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

View File

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

View File

@@ -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 (
<div className="flex min-h-screen items-center justify-center bg-background px-4">
<div className="w-full max-w-sm">
<div className="text-center mb-8">
<div className="inline-flex items-center justify-center w-14 h-14 rounded-xl bg-primary mb-4">
<svg className="w-8 h-8 text-white" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path d="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" />
</svg>
</div>
<h1 className="text-2xl font-bold text-foreground">LegalAI</h1>
<p className="text-sm text-muted mt-1">Bühnenrecht-Plattform</p>
</div>
<form onSubmit={handleSubmit} className="bg-card-bg rounded-xl border border-card-border p-6 space-y-4">
<h2 className="text-lg font-semibold text-foreground">Anmelden</h2>
{error && (
<div className="text-sm text-danger bg-danger/10 rounded-lg px-3 py-2">
{error}
</div>
)}
<div>
<label htmlFor="email" className="block text-sm font-medium text-foreground mb-1">
E-Mail
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full rounded-lg border border-card-border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary"
placeholder="anwalt@kanzlei.de"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-foreground mb-1">
Passwort
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="w-full rounded-lg border border-card-border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full bg-primary text-white rounded-lg py-2.5 text-sm font-medium hover:bg-primary-light transition-colors disabled:opacity-50"
>
{loading ? 'Wird angemeldet...' : 'Anmelden'}
</button>
<p className="text-center text-sm text-muted">
Noch kein Konto?{' '}
<Link href="/register" className="text-primary font-medium hover:underline">
Registrieren
</Link>
</p>
</form>
</div>
</div>
);
}

View File

@@ -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 (
<div className="flex min-h-screen items-center justify-center bg-background px-4">
<div className="w-full max-w-sm">
<div className="text-center mb-8">
<div className="inline-flex items-center justify-center w-14 h-14 rounded-xl bg-primary mb-4">
<svg className="w-8 h-8 text-white" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path d="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" />
</svg>
</div>
<h1 className="text-2xl font-bold text-foreground">LegalAI</h1>
<p className="text-sm text-muted mt-1">Konto erstellen</p>
</div>
<form onSubmit={handleSubmit} className="bg-card-bg rounded-xl border border-card-border p-6 space-y-4">
<h2 className="text-lg font-semibold text-foreground">Registrieren</h2>
{error && (
<div className="text-sm text-danger bg-danger/10 rounded-lg px-3 py-2">{error}</div>
)}
<div>
<label htmlFor="tenantName" className="block text-sm font-medium text-foreground mb-1">Kanzleiname</label>
<input id="tenantName" type="text" value={form.tenantName} onChange={(e) => updateForm('tenantName', e.target.value)} required
className="w-full rounded-lg border border-card-border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary"
placeholder="Kanzlei Muster" />
</div>
<div>
<label htmlFor="name" className="block text-sm font-medium text-foreground mb-1">Name</label>
<input id="name" type="text" value={form.name} onChange={(e) => updateForm('name', e.target.value)} required
className="w-full rounded-lg border border-card-border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary"
placeholder="Max Mustermann" />
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium text-foreground mb-1">E-Mail</label>
<input id="email" type="email" value={form.email} onChange={(e) => updateForm('email', e.target.value)} required
className="w-full rounded-lg border border-card-border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary"
placeholder="anwalt@kanzlei.de" />
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-foreground mb-1">Passwort</label>
<input id="password" type="password" value={form.password} onChange={(e) => updateForm('password', e.target.value)} required minLength={8}
className="w-full rounded-lg border border-card-border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary" />
</div>
<button type="submit" disabled={loading}
className="w-full bg-primary text-white rounded-lg py-2.5 text-sm font-medium hover:bg-primary-light transition-colors disabled:opacity-50">
{loading ? 'Wird erstellt...' : 'Konto erstellen'}
</button>
<p className="text-center text-sm text-muted">
Bereits registriert?{' '}
<Link href="/login" className="text-primary font-medium hover:underline">Anmelden</Link>
</p>
</form>
</div>
</div>
);
}

View File

@@ -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<string>('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 (
<div className="space-y-6">
<form onSubmit={handleSubmit} className="bg-card-bg border border-card-border rounded-xl p-6 space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-foreground mb-1">Analysemodus</label>
<select
value={mode}
onChange={(e) => setMode(e.target.value)}
className="w-full rounded-lg border border-card-border px-3 py-2.5 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary"
>
{MODES.map((m) => (
<option key={m.key} value={m.key}>{m.label}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1">Fall (optional)</label>
<select
value={caseId}
onChange={(e) => setCaseId(e.target.value)}
className="w-full rounded-lg border border-card-border px-3 py-2.5 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary"
>
<option value="">Kein Fall ausgewählt</option>
{cases.map((c) => (
<option key={c.id} value={c.id}>{c.caseNumber} {c.title}</option>
))}
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1">Sachverhalt / Rechtsfrage</label>
<textarea
value={question}
onChange={(e) => setQuestion(e.target.value)}
rows={6}
required
className="w-full rounded-lg border border-card-border px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary resize-y"
placeholder="Beschreiben Sie den Sachverhalt und die zu prüfende Rechtsfrage..."
/>
</div>
{error && (
<div className="text-sm text-danger bg-danger/10 rounded-lg px-3 py-2">{error}</div>
)}
<button
type="submit"
disabled={loading || !question.trim()}
className="px-6 py-2.5 bg-primary text-white rounded-lg text-sm font-medium hover:bg-primary-light transition-colors disabled:opacity-50"
>
{loading ? 'Analyse läuft...' : 'Analyse starten'}
</button>
</form>
{result && (
<div className="bg-card-bg border border-card-border rounded-xl p-6">
<div className="flex items-center gap-2 mb-4">
<h3 className="text-sm font-semibold text-foreground">Ergebnis</h3>
<span className="text-xs px-2 py-0.5 rounded-full bg-primary/10 text-primary font-medium">
{MODES.find((m) => m.key === mode)?.label}
</span>
</div>
<div className="text-sm text-foreground leading-relaxed whitespace-pre-wrap">
{result}
</div>
</div>
)}
</div>
);
}

View File

@@ -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 (
<div className="space-y-8">
<div>
<h3 className="text-lg font-semibold text-foreground mb-2">Neue Analyse</h3>
<p className="text-sm text-muted">
Wählen Sie einen Analysemodus und beschreiben Sie den Sachverhalt.
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{MODE_INFO.map((mode) => (
<div
key={mode.key}
className="bg-card-bg border border-card-border rounded-xl p-4"
>
<svg className="w-6 h-6 text-primary mb-2" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d={mode.icon} />
</svg>
<h4 className="text-sm font-semibold text-foreground">{mode.label}</h4>
<p className="text-xs text-muted mt-1 leading-relaxed">{mode.description}</p>
</div>
))}
</div>
<AnalyseForm cases={tenantCases} />
{recentAnalyses.length > 0 && (
<div>
<h3 className="text-lg font-semibold text-foreground mb-4">Bisherige Analysen</h3>
<div className="bg-card-bg border border-card-border rounded-xl divide-y divide-card-border">
{recentAnalyses.map((a) => (
<div key={a.id} className="px-5 py-4 flex items-center justify-between">
<div>
<p className="text-sm font-medium text-foreground">{a.title || 'Ohne Titel'}</p>
<p className="text-xs text-muted mt-0.5">
{MODE_INFO.find((m) => m.key === a.mode)?.label ?? a.mode}
</p>
</div>
<span className="text-xs text-muted">
{new Date(a.createdAt).toLocaleDateString('de-DE')}
</span>
</div>
))}
</div>
</div>
)}
</div>
);
}

View File

@@ -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<string, string> = {
gutachten: 'Gutachten',
entscheidung: 'Entscheidungsprognose',
vergleich: 'Vergleichsanalyse',
risiko: 'Risikoanalyse',
};
const statusBadge: Record<string, string> = {
completed: 'bg-success/10 text-success',
in_progress: 'bg-warning/10 text-warning',
draft: 'bg-muted/10 text-muted',
};
return (
<div className="space-y-8">
<div>
<h3 className="text-lg font-semibold text-foreground mb-4">Übersicht</h3>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
{stats.map((stat) => (
<Link
key={stat.label}
href={stat.href}
className="bg-card-bg border border-card-border rounded-xl p-5 hover:shadow-md transition-shadow"
>
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-lg ${stat.color} flex items-center justify-center text-white text-lg font-bold`}>
{stat.value}
</div>
<span className="text-sm font-medium text-foreground">{stat.label}</span>
</div>
</Link>
))}
</div>
</div>
<div>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-foreground">Letzte Analysen</h3>
<Link href="/analyse" className="text-sm text-primary font-medium hover:underline">
Alle anzeigen
</Link>
</div>
{recentAnalyses.length === 0 ? (
<div className="bg-card-bg border border-card-border rounded-xl p-8 text-center">
<p className="text-muted text-sm">Noch keine Analysen erstellt.</p>
<Link href="/analyse" className="inline-block mt-3 text-sm text-primary font-medium hover:underline">
Erste Analyse starten
</Link>
</div>
) : (
<div className="bg-card-bg border border-card-border rounded-xl divide-y divide-card-border">
{recentAnalyses.map((a) => (
<div key={a.id} className="px-5 py-4 flex items-center justify-between">
<div>
<p className="text-sm font-medium text-foreground">{a.title || 'Ohne Titel'}</p>
<p className="text-xs text-muted mt-0.5">{modeLabels[a.mode] ?? a.mode}</p>
</div>
<div className="flex items-center gap-3">
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${statusBadge[a.status] ?? 'bg-muted/10 text-muted'}`}>
{a.status === 'completed' ? 'Fertig' : a.status === 'in_progress' ? 'In Bearbeitung' : 'Entwurf'}
</span>
<span className="text-xs text-muted">
{new Date(a.createdAt).toLocaleDateString('de-DE')}
</span>
</div>
</div>
))}
</div>
)}
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="bg-card-bg border border-card-border rounded-xl p-5">
<h4 className="text-sm font-semibold text-foreground mb-3">Schnellzugriff</h4>
<div className="space-y-2">
<Link href="/analyse" className="flex items-center gap-2 text-sm text-primary hover:underline">
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
</svg>
Neue Analyse erstellen
</Link>
<Link href="/normen" className="flex items-center gap-2 text-sm text-primary hover:underline">
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
Normen durchsuchen
</Link>
<Link href="/entscheidungen" className="flex items-center gap-2 text-sm text-primary hover:underline">
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
Entscheidungen suchen
</Link>
</div>
</div>
<div className="bg-card-bg border border-card-border rounded-xl p-5">
<h4 className="text-sm font-semibold text-foreground mb-3">Analysemodi</h4>
<div className="space-y-2 text-sm text-muted">
<p><span className="font-medium text-foreground">Gutachten:</span> Systematische Rechtsprüfung nach Gutachtenstil</p>
<p><span className="font-medium text-foreground">Entscheidung:</span> Prognose zu Gerichts-/Schiedsentscheidungen</p>
<p><span className="font-medium text-foreground">Vergleich:</span> Bewertung von Vergleichsoptionen</p>
<p><span className="font-medium text-foreground">Risiko:</span> Risikoanalyse und Priorisierung</p>
</div>
</div>
</div>
</div>
);
}

View File

@@ -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<string, string> = {
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 (
<div className="space-y-8 max-w-3xl">
<div className="bg-card-bg border border-card-border rounded-xl p-6">
<h3 className="text-sm font-semibold text-foreground mb-4">Mandanten-Information</h3>
<div className="space-y-3 text-sm">
<div className="flex justify-between">
<span className="text-muted">Name</span>
<span className="font-medium">{tenant[0]?.name ?? '—'}</span>
</div>
<div className="flex justify-between">
<span className="text-muted">Mandanten-ID</span>
<span className="font-mono text-xs">{user.tenantId}</span>
</div>
</div>
</div>
<div className="bg-card-bg border border-card-border rounded-xl p-6">
<h3 className="text-sm font-semibold text-foreground mb-4">Ihr Profil</h3>
<div className="space-y-3 text-sm">
<div className="flex justify-between">
<span className="text-muted">Name</span>
<span className="font-medium">{user.name}</span>
</div>
<div className="flex justify-between">
<span className="text-muted">E-Mail</span>
<span>{user.email}</span>
</div>
<div className="flex justify-between">
<span className="text-muted">Rolle</span>
<span className="font-medium">{ROLE_LABELS[user.role] ?? user.role}</span>
</div>
</div>
</div>
{isAdmin && tenantUsers.length > 0 && (
<div className="bg-card-bg border border-card-border rounded-xl p-6">
<h3 className="text-sm font-semibold text-foreground mb-4">Benutzer</h3>
<div className="divide-y divide-card-border">
{tenantUsers.map((u) => (
<div key={u.id} className="py-3 flex items-center justify-between">
<div>
<p className="text-sm font-medium text-foreground">{u.name}</p>
<p className="text-xs text-muted">{u.email}</p>
</div>
<span className="text-xs px-2 py-0.5 rounded-full bg-primary/10 text-primary font-medium">
{ROLE_LABELS[u.role] ?? u.role}
</span>
</div>
))}
</div>
</div>
)}
{!isAdmin && (
<div className="bg-card-bg border border-card-border rounded-xl p-6 text-center">
<p className="text-sm text-muted">
Für die Verwaltung von Benutzern und Mandanten-Einstellungen wenden Sie sich an Ihren Administrator.
</p>
</div>
)}
</div>
);
}

View File

@@ -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 (
<div className="space-y-6">
<div className="flex items-center gap-2 text-sm text-muted">
<Link href="/entscheidungen" className="hover:text-primary">Entscheidungen</Link>
<span>/</span>
<span className="text-foreground font-medium">{decision.caseReference || decision.court}</span>
</div>
<div className="bg-card-bg border border-card-border rounded-xl p-6">
<h2 className="text-xl font-bold text-foreground mb-3">
{decision.court}{decision.caseReference ? `${decision.caseReference}` : ''}
</h2>
<div className="flex flex-wrap gap-4 text-sm text-muted mb-4">
{decision.court && <span>Gericht: {decision.court}</span>}
{decision.caseReference && <span>Az.: {decision.caseReference}</span>}
{decision.decisionDate && <span>Datum: {new Date(decision.decisionDate).toLocaleDateString('de-DE')}</span>}
</div>
{decision.headnote && (
<div className="mb-4">
<h3 className="text-sm font-semibold text-foreground mb-1">Leitsatz</h3>
<p className="text-sm text-muted leading-relaxed bg-primary/5 rounded-lg p-4">{decision.headnote}</p>
</div>
)}
{decision.fullText && (
<div>
<h3 className="text-sm font-semibold text-foreground mb-1">Volltext</h3>
<div className="text-sm text-muted leading-relaxed whitespace-pre-wrap max-h-96 overflow-y-auto">
{decision.fullText}
</div>
</div>
)}
</div>
{appliedNorms.length > 0 && (
<div className="bg-card-bg border border-card-border rounded-xl p-6">
<h3 className="text-sm font-semibold text-foreground mb-3">Angewandte Normen</h3>
<div className="flex flex-wrap gap-2">
{appliedNorms.map((n) => (
<span
key={n.normId}
className="text-xs px-2.5 py-1 rounded-full bg-primary/10 text-primary font-medium"
>
{n.instrumentAbbreviation || n.instrumentTitle} {n.paragraph}
{n.title ? ` (${n.title})` : ''}
</span>
))}
</div>
</div>
)}
</div>
);
}

View File

@@ -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<string, string> = {
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 (
<div className="space-y-6">
<div>
<p className="text-sm text-muted">
Entscheidungsdatenbank für Bühnenrecht, Schiedssprüche und Arbeitsgerichtsurteile.
</p>
</div>
<form className="flex gap-2">
<input
name="q"
type="search"
defaultValue={q ?? ''}
placeholder="Suche nach Gericht, Aktenzeichen, Leitsatz..."
className="flex-1 rounded-lg border border-card-border px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary bg-card-bg"
/>
<button
type="submit"
className="px-4 py-2.5 bg-primary text-white rounded-lg text-sm font-medium hover:bg-primary-light transition-colors"
>
Suchen
</button>
</form>
{results.length === 0 ? (
<div className="bg-card-bg border border-card-border rounded-xl p-8 text-center">
<p className="text-muted text-sm">
{q ? `Keine Entscheidungen für "${q}" gefunden.` : 'Noch keine Entscheidungen in der Datenbank.'}
</p>
</div>
) : (
<div className="space-y-3">
{results.map((d) => (
<Link
key={d.id}
href={`/entscheidungen/${d.id}`}
className="block bg-card-bg border border-card-border rounded-xl p-5 hover:shadow-md transition-shadow"
>
<div className="flex items-start justify-between mb-2">
<h3 className="text-sm font-semibold text-foreground">
{d.court}{d.caseReference ? `${d.caseReference}` : ''}
</h3>
<div className="flex gap-2 shrink-0 ml-4">
{d.type && (
<span className="text-xs px-2 py-0.5 rounded-full bg-primary/10 text-primary font-medium">
{DECISION_TYPE_LABELS[d.type] ?? d.type}
</span>
)}
</div>
</div>
<div className="flex items-center gap-4 text-xs text-muted mb-2">
{d.court && <span>{d.court}</span>}
{d.caseReference && <span>Az. {d.caseReference}</span>}
{d.decisionDate && <span>{new Date(d.decisionDate).toLocaleDateString('de-DE')}</span>}
</div>
{d.headnote && (
<p className="text-sm text-muted line-clamp-2">{d.headnote}</p>
)}
</Link>
))}
</div>
)}
{results.length === pageSize && (
<div className="flex justify-center">
<Link
href={`/entscheidungen?${q ? `q=${encodeURIComponent(q)}&` : ''}page=${currentPage + 1}`}
className="text-sm text-primary font-medium hover:underline"
>
Weitere Ergebnisse laden
</Link>
</div>
)}
</div>
);
}

View File

@@ -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 (
<div className="flex h-full min-h-screen">
<Sidebar />
<div className="flex-1 flex flex-col min-w-0">
<Header userName={session.user.name} userRole={session.user.role} />
<main className="flex-1 p-8 overflow-auto">
{children}
</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,10 @@
export default function Loading() {
return (
<div className="flex items-center justify-center py-20">
<div className="flex flex-col items-center gap-4">
<div className="w-8 h-8 border-2 border-primary border-t-transparent rounded-full animate-spin" />
<p className="text-sm text-muted">Laden...</p>
</div>
</div>
);
}

View File

@@ -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<string, string> = {
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 (
<div className="space-y-6">
<div className="flex items-center gap-2 text-sm text-muted">
<Link href="/normen" className="hover:text-primary">Normen</Link>
<span>/</span>
<span className="text-foreground font-medium">{inst.abbreviation || inst.fullTitle}</span>
</div>
<div className="bg-card-bg border border-card-border rounded-xl p-6">
<div className="flex items-start justify-between">
<div>
<h2 className="text-xl font-bold text-foreground">{inst.fullTitle}</h2>
{inst.abbreviation && (
<p className="text-sm text-muted mt-1">Abkürzung: {inst.abbreviation}</p>
)}
</div>
<span className="text-xs px-2.5 py-1 rounded-full font-medium bg-primary/10 text-primary">
{QUELLENRANG_LABELS[inst.sourceRank] ?? inst.sourceRank}
</span>
</div>
<div className="flex gap-4 mt-3 text-xs text-muted">
<span>{normList.length} Vorschriften</span>
{inst.enactedAt && <span>Gültig ab: {new Date(inst.enactedAt).toLocaleDateString('de-DE')}</span>}
</div>
</div>
{normList.length === 0 ? (
<div className="bg-card-bg border border-card-border rounded-xl p-8 text-center">
<p className="text-muted text-sm">Keine Vorschriften für dieses Regelwerk hinterlegt.</p>
</div>
) : (
<div className="space-y-3">
{normList.map((norm) => (
<div key={norm.id} className="bg-card-bg border border-card-border rounded-xl p-5">
<div className="flex items-start justify-between mb-2">
<h3 className="text-sm font-semibold text-foreground">
{norm.paragraph}{norm.title ? `${norm.title}` : ''}
</h3>
<div className="flex gap-2 text-xs text-muted shrink-0 ml-4">
{norm.validFrom && <span>ab {new Date(norm.validFrom).toLocaleDateString('de-DE')}</span>}
{norm.validTo && <span>bis {new Date(norm.validTo).toLocaleDateString('de-DE')}</span>}
</div>
</div>
{norm.body && (
<p className="text-sm text-muted leading-relaxed whitespace-pre-wrap">
{norm.body.length > 500 ? norm.body.slice(0, 500) + '...' : norm.body}
</p>
)}
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -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<string, string> = {
gesetz: 'Gesetz',
tarif: 'Tarifvertrag',
schiedsordnung: 'Schiedsordnung',
praxis: 'Praxis',
kommentar: 'Kommentar',
};
const QUELLENRANG_COLORS: Record<string, string> = {
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 (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted">
Rechtsquellen nach Quellenrang geordnet. Höherrangige Normen gehen vor.
</p>
</div>
</div>
<div className="flex flex-wrap gap-2">
{Object.entries(QUELLENRANG_LABELS).map(([key, label]) => (
<span key={key} className={`text-xs px-2.5 py-1 rounded-full font-medium ${QUELLENRANG_COLORS[key]}`}>
{label}
</span>
))}
</div>
{instruments.length === 0 ? (
<div className="bg-card-bg border border-card-border rounded-xl p-8 text-center">
<p className="text-muted text-sm">Noch keine Normen importiert.</p>
<p className="text-xs text-muted mt-1">Verwenden Sie die API zum Importieren: POST /api/norms/import</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{instruments.map((inst) => (
<Link
key={inst.id}
href={`/normen/${inst.id}`}
className="bg-card-bg border border-card-border rounded-xl p-5 hover:shadow-md transition-shadow"
>
<div className="flex items-start justify-between mb-3">
<div>
<h3 className="text-sm font-semibold text-foreground">{inst.abbreviation || inst.fullTitle}</h3>
{inst.abbreviation && (
<p className="text-xs text-muted mt-0.5">{inst.fullTitle}</p>
)}
</div>
<span className={`text-xs px-2 py-0.5 rounded-full font-medium shrink-0 ${QUELLENRANG_COLORS[inst.sourceRank] ?? 'bg-muted/10 text-muted'}`}>
{QUELLENRANG_LABELS[inst.sourceRank] ?? inst.sourceRank}
</span>
</div>
<div className="flex items-center gap-4 text-xs text-muted">
<span>{inst.normCount} Normen</span>
{inst.enactedAt && (
<span>ab {new Date(inst.enactedAt).toLocaleDateString('de-DE')}</span>
)}
</div>
</Link>
))}
</div>
)}
</div>
);
}

View File

@@ -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<string, string> = {
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<string, string> = {
vorbereitung: 'Vorbereitung',
eingereicht: 'Eingereicht',
laufend: 'Laufend',
verhandlung: 'Verhandlung',
entschieden: 'Entschieden',
abgeschlossen: 'Abgeschlossen',
ruht: 'Ruht',
};
const STATUS_COLORS: Record<string, string> = {
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 (
<div className="space-y-6">
<div>
<p className="text-sm text-muted">
Bühnenschiedsverfahren (BSchGO) und Arbeitsgerichtsverfahren (ArbGG) verwalten.
</p>
</div>
{proceedingList.length === 0 ? (
<div className="bg-card-bg border border-card-border rounded-xl p-8 text-center">
<p className="text-muted text-sm">Noch keine Verfahren angelegt.</p>
<p className="text-xs text-muted mt-1">Erstellen Sie ein Verfahren über die API: POST /api/proceedings</p>
</div>
) : (
<div className="space-y-3">
{proceedingList.map((p) => (
<div
key={p.id}
className="bg-card-bg border border-card-border rounded-xl p-5"
>
<div className="flex items-start justify-between mb-2">
<div>
<h3 className="text-sm font-semibold text-foreground">
{p.caseNumber} {p.caseTitle}
</h3>
<p className="text-xs text-muted mt-0.5">
{TYPE_LABELS[p.type] ?? p.type}
</p>
</div>
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${STATUS_COLORS[p.status] ?? 'bg-muted/10 text-muted'}`}>
{STATUS_LABELS[p.status] ?? p.status}
</span>
</div>
{p.filingDate && (
<p className="text-xs text-muted">
Eingereicht: {new Date(p.filingDate).toLocaleDateString('de-DE')}
</p>
)}
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -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<string, string> = {
uploaded: 'Hochgeladen',
extracting: 'Wird extrahiert',
extracted: 'Extrahiert',
analyzing: 'Wird analysiert',
completed: 'Abgeschlossen',
failed: 'Fehlgeschlagen',
};
const STATUS_COLORS: Record<string, string> = {
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 (
<div className="space-y-6">
<div>
<p className="text-sm text-muted">
Vertragsdokumente hochladen und KI-gestützt auf Klauseln analysieren.
</p>
</div>
<VertragUpload />
{documents.length === 0 ? (
<div className="bg-card-bg border border-card-border rounded-xl p-8 text-center">
<p className="text-muted text-sm">Noch keine Vertragsdokumente hochgeladen.</p>
</div>
) : (
<div className="bg-card-bg border border-card-border rounded-xl divide-y divide-card-border">
{documents.map((doc) => (
<div key={doc.id} className="px-5 py-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<svg className="w-8 h-8 text-muted" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
</svg>
<div>
<p className="text-sm font-medium text-foreground">{doc.filename}</p>
<p className="text-xs text-muted">{doc.mimeType?.includes('pdf') ? 'PDF' : 'DOCX'} {new Date(doc.createdAt).toLocaleDateString('de-DE')}</p>
</div>
</div>
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${STATUS_COLORS[doc.status] ?? STATUS_COLORS.uploaded}`}>
{STATUS_LABELS[doc.status] ?? doc.status}
</span>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -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<HTMLInputElement>(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 (
<form onSubmit={handleUpload} className="bg-card-bg border border-card-border rounded-xl p-5">
<h3 className="text-sm font-semibold text-foreground mb-3">Vertrag hochladen</h3>
<div className="flex gap-3 items-end">
<div className="flex-1">
<input
ref={fileRef}
type="file"
accept=".pdf,.docx"
required
className="block w-full text-sm text-muted file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-medium file:bg-primary/10 file:text-primary hover:file:bg-primary/20"
/>
<p className="text-xs text-muted mt-1">PDF oder DOCX, max. 10 MB</p>
</div>
<button
type="submit"
disabled={uploading}
className="px-4 py-2.5 bg-primary text-white rounded-lg text-sm font-medium hover:bg-primary-light transition-colors disabled:opacity-50 shrink-0"
>
{uploading ? 'Wird hochgeladen...' : 'Hochladen'}
</button>
</div>
{error && <p className="text-sm text-danger mt-2">{error}</p>}
{success && <p className="text-sm text-success mt-2">{success}</p>}
</form>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<string, unknown>;
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<string, unknown> = {};
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 });
}

View File

@@ -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<number>`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<string, unknown>;
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 });
}

View File

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

View File

@@ -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<string, (typeof allNorms)[number]>();
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,
});
}

View File

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

View File

@@ -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<string, unknown> = {};
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: [],
});
}

View File

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

View File

@@ -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<string, unknown>;
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<string, unknown> = {};
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 });
}

View File

@@ -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<string, unknown>;
try {
body = await request.json();
} catch {
return Response.json({ error: "Invalid JSON body." }, { status: 400 });
}
const updates: Record<string, unknown> = {};
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 });
}

View File

@@ -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<number>`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<string, unknown>;
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 },
);
}

View File

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

View File

@@ -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 (
<html
lang="en"
lang="de"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">{children}</body>

View File

@@ -1,65 +1,5 @@
import Image from "next/image";
import { redirect } from 'next/navigation';
export default function Home() {
return (
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div>
</main>
</div>
);
redirect('/dashboard');
}

View File

@@ -0,0 +1,43 @@
'use client';
import { usePathname } from 'next/navigation';
const PAGE_TITLES: Record<string, string> = {
'/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<string, string> = {
admin: 'Administrator',
attorney: 'Rechtsanwalt',
paralegal: 'Paralegal',
viewer: 'Leser',
};
return (
<header className="flex items-center justify-between px-8 py-4 bg-card-bg border-b border-card-border">
<h2 className="text-xl font-semibold text-foreground">{title}</h2>
<div className="flex items-center gap-4">
<div className="text-right">
<p className="text-sm font-medium text-foreground">{userName}</p>
<p className="text-xs text-muted">{roleLabel[userRole] ?? userRole}</p>
</div>
<div className="w-9 h-9 rounded-full bg-primary flex items-center justify-center text-white text-sm font-medium">
{userName.charAt(0).toUpperCase()}
</div>
</div>
</header>
);
}

View File

@@ -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 (
<aside className="flex flex-col w-64 min-h-screen bg-sidebar-bg text-sidebar-text">
<div className="flex items-center gap-3 px-6 py-5 border-b border-white/10">
<svg className="w-8 h-8 text-accent" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="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" />
</svg>
<div>
<h1 className="text-lg font-bold tracking-tight">LegalAI</h1>
<p className="text-xs text-sidebar-text/60">Bühnenrecht</p>
</div>
</div>
<nav className="flex-1 px-3 py-4 space-y-1">
{NAV_ITEMS.map((item) => {
const isActive = pathname === item.href || pathname.startsWith(item.href + '/');
return (
<Link
key={item.href}
href={item.href}
className={`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors ${
isActive
? 'bg-white/15 text-white'
: 'text-sidebar-text/80 hover:bg-sidebar-hover hover:text-white'
}`}
>
<svg className="w-5 h-5 shrink-0" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d={item.icon} />
</svg>
{item.label}
</Link>
);
})}
</nav>
<div className="px-3 py-4 border-t border-white/10" id="tenant-switcher-slot" />
</aside>
);
}

View File

@@ -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 (
<div className="px-3 py-2 text-sm text-sidebar-text/70">
<p className="text-xs text-sidebar-text/50">Mandant</p>
<p className="font-medium truncate">{current?.name ?? 'Kein Mandant'}</p>
</div>
);
}
return (
<div className="relative">
<button
onClick={() => setOpen(!open)}
className="w-full flex items-center justify-between px-3 py-2 rounded-lg text-sm hover:bg-sidebar-hover transition-colors"
>
<div className="text-left">
<p className="text-xs text-sidebar-text/50">Mandant</p>
<p className="font-medium truncate">{current?.name ?? 'Auswählen...'}</p>
</div>
<svg className="w-4 h-4 text-sidebar-text/50" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M8 9l4-4 4 4m0 6l-4 4-4-4" />
</svg>
</button>
{open && (
<div className="absolute bottom-full left-0 right-0 mb-1 bg-white rounded-lg shadow-lg border border-card-border py-1 z-50">
{tenants.map((tenant) => (
<button
key={tenant.id}
className={`w-full text-left px-3 py-2 text-sm hover:bg-gray-100 transition-colors ${
tenant.id === currentTenantId ? 'text-primary font-medium' : 'text-foreground'
}`}
onClick={() => {
setOpen(false);
}}
>
{tenant.name}
</button>
))}
</div>
)}
</div>
);
}

235
src/lib/ai/analysis.ts Normal file
View File

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

View File

@@ -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<AnalyseMode, AnalysisModeConfig> = {
@@ -16,23 +20,31 @@ export const ANALYSIS_MODES: Record<AnalyseMode, AnalysisModeConfig> = {
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',
},
};

153
src/lib/ai/prompts.ts Normal file
View File

@@ -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, string> = {
[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');
}

View File

@@ -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<AIProvider, string> = {
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);
}
}

View File

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

View File

@@ -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<number, string> = {
1: 'Gesetz',
2: 'Tarifvertrag',
3: 'Schiedsordnung',
4: 'Bühnenpraxis',
5: 'Kommentarliteratur',
};

25
src/lib/auth/audit.ts Normal file
View File

@@ -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<string, unknown>,
ipAddress?: string,
) {
await db.insert(auditLog).values({
tenantId: ctx.tenantId,
userId: ctx.userId,
action,
entityType,
entityId: entityId ?? undefined,
details: details ?? undefined,
ipAddress: ipAddress ?? undefined,
});
}

View File

@@ -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
},
};

81
src/lib/auth/rbac.ts Normal file
View File

@@ -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<string, readonly Role[]> = {
// 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<TenantContext | null> {
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 };
}

316
src/lib/contracts/index.ts Normal file
View File

@@ -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<UploadResult> {
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<string> {
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<void> {
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);
}

View File

@@ -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<T>(
tenantId: string,
callback: (tenantDb: NodePgDatabase<typeof schema>) => Promise<T>,
): Promise<T> {
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();
}
}

22
src/lib/db/tenant.ts Normal file
View File

@@ -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<T>(
tenantId: string,
fn: (client: PoolClient) => Promise<T>,
): Promise<T> {
const client = await pool.connect();
try {
await client.query(`SET LOCAL app.tenant_id = '${tenantId}'`);
return await fn(client);
} finally {
client.release();
}
}

View File

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

View File

@@ -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,
};
}

View File

@@ -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<string, WorkflowTemplate> = {
bschgo_bezirk: bschgoBezirkWorkflow,
bschgo_bund: bschgoBundWorkflow,
arbgg_erste_instanz: arbggErsteInstanzWorkflow,
arbgg_berufung: arbggBerufungWorkflow,
arbgg_revision: arbggRevisionWorkflow,
};

46
src/middleware.ts Normal file
View File

@@ -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).*)',
],
};

14
src/types/mammoth.d.ts vendored Normal file
View File

@@ -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<ConversionResult>;
export function convertToHtml(options: Options): Promise<ConversionResult>;
}

14
src/types/pdf-parse.d.ts vendored Normal file
View File

@@ -0,0 +1,14 @@
declare module 'pdf-parse' {
interface PdfData {
numpages: number;
numrender: number;
info: Record<string, unknown>;
metadata: Record<string, unknown>;
text: string;
version: string;
}
function pdfParse(dataBuffer: Buffer, options?: Record<string, unknown>): Promise<PdfData>;
export default pdfParse;
}