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:
148
drizzle/0001_curved_fabian_cortez.sql
Normal file
148
drizzle/0001_curved_fabian_cortez.sql
Normal 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");
|
||||
87
drizzle/0002_contract_analysis.sql
Normal file
87
drizzle/0002_contract_analysis.sql
Normal 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)
|
||||
3070
drizzle/meta/0001_snapshot.json
Normal file
3070
drizzle/meta/0001_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
487
package-lock.json
generated
@@ -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",
|
||||
|
||||
12
package.json
12
package.json
@@ -11,24 +11,28 @@
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "^3.0.68",
|
||||
"@ai-sdk/openai": "^3.0.52",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"ai": "^6.0.154",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"mammoth": "^1.12.0",
|
||||
"next": "16.2.3",
|
||||
"next-auth": "^4.24.13",
|
||||
"pdf-parse": "^2.4.5",
|
||||
"pg": "^8.20.0",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@tailwindcss/postcss": "^4.2.2",
|
||||
"@types/node": "^20",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@types/react": "^19",
|
||||
"@types/react": "19.2.14",
|
||||
"@types/react-dom": "^19",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.3",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
"tailwindcss": "^4.2.2",
|
||||
"typescript": "5.9.3"
|
||||
}
|
||||
}
|
||||
|
||||
104
src/app/(auth)/login/page.tsx
Normal file
104
src/app/(auth)/login/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
102
src/app/(auth)/register/page.tsx
Normal file
102
src/app/(auth)/register/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
140
src/app/(dashboard)/analyse/analyse-form.tsx
Normal file
140
src/app/(dashboard)/analyse/analyse-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
109
src/app/(dashboard)/analyse/page.tsx
Normal file
109
src/app/(dashboard)/analyse/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
145
src/app/(dashboard)/dashboard/page.tsx
Normal file
145
src/app/(dashboard)/dashboard/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
101
src/app/(dashboard)/einstellungen/page.tsx
Normal file
101
src/app/(dashboard)/einstellungen/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
92
src/app/(dashboard)/entscheidungen/[id]/page.tsx
Normal file
92
src/app/(dashboard)/entscheidungen/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
125
src/app/(dashboard)/entscheidungen/page.tsx
Normal file
125
src/app/(dashboard)/entscheidungen/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
28
src/app/(dashboard)/layout.tsx
Normal file
28
src/app/(dashboard)/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
10
src/app/(dashboard)/loading.tsx
Normal file
10
src/app/(dashboard)/loading.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
101
src/app/(dashboard)/normen/[instrumentId]/page.tsx
Normal file
101
src/app/(dashboard)/normen/[instrumentId]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
92
src/app/(dashboard)/normen/page.tsx
Normal file
92
src/app/(dashboard)/normen/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
99
src/app/(dashboard)/verfahren/page.tsx
Normal file
99
src/app/(dashboard)/verfahren/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
78
src/app/(dashboard)/vertraege/page.tsx
Normal file
78
src/app/(dashboard)/vertraege/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
69
src/app/(dashboard)/vertraege/vertrag-upload.tsx
Normal file
69
src/app/(dashboard)/vertraege/vertrag-upload.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
75
src/app/api/analyses/[id]/route.ts
Normal file
75
src/app/api/analyses/[id]/route.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
79
src/app/api/analyses/route.ts
Normal file
79
src/app/api/analyses/route.ts
Normal 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);
|
||||
}
|
||||
66
src/app/api/analyses/structured/route.ts
Normal file
66
src/app/api/analyses/structured/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
5
src/app/api/auth/[...nextauth]/route.ts
Normal file
5
src/app/api/auth/[...nextauth]/route.ts
Normal 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 };
|
||||
73
src/app/api/contracts/[id]/analyze/route.ts
Normal file
73
src/app/api/contracts/[id]/analyze/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
46
src/app/api/contracts/[id]/route.ts
Normal file
46
src/app/api/contracts/[id]/route.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
67
src/app/api/contracts/route.ts
Normal file
67
src/app/api/contracts/route.ts
Normal 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);
|
||||
}
|
||||
92
src/app/api/decisions/[id]/norms/route.ts
Normal file
92
src/app/api/decisions/[id]/norms/route.ts
Normal 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 });
|
||||
}
|
||||
121
src/app/api/decisions/[id]/references/route.ts
Normal file
121
src/app/api/decisions/[id]/references/route.ts
Normal 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 });
|
||||
}
|
||||
128
src/app/api/decisions/[id]/route.ts
Normal file
128
src/app/api/decisions/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
161
src/app/api/decisions/route.ts
Normal file
161
src/app/api/decisions/route.ts
Normal 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 });
|
||||
}
|
||||
86
src/app/api/norms/[instrumentId]/[paragraph]/route.ts
Normal file
86
src/app/api/norms/[instrumentId]/[paragraph]/route.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
73
src/app/api/norms/[instrumentId]/route.ts
Normal file
73
src/app/api/norms/[instrumentId]/route.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
124
src/app/api/norms/import/route.ts
Normal file
124
src/app/api/norms/import/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
161
src/app/api/proceedings/[id]/advance/route.ts
Normal file
161
src/app/api/proceedings/[id]/advance/route.ts
Normal 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: [],
|
||||
});
|
||||
}
|
||||
72
src/app/api/proceedings/[id]/deadlines/route.ts
Normal file
72
src/app/api/proceedings/[id]/deadlines/route.ts
Normal 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 });
|
||||
}
|
||||
113
src/app/api/proceedings/[id]/route.ts
Normal file
113
src/app/api/proceedings/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
48
src/app/api/proceedings/[id]/steps/[stepId]/route.ts
Normal file
48
src/app/api/proceedings/[id]/steps/[stepId]/route.ts
Normal 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 });
|
||||
}
|
||||
192
src/app/api/proceedings/route.ts
Normal file
192
src/app/api/proceedings/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
43
src/components/layout/header.tsx
Normal file
43
src/components/layout/header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
56
src/components/layout/sidebar.tsx
Normal file
56
src/components/layout/sidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
63
src/components/layout/tenant-switcher.tsx
Normal file
63
src/components/layout/tenant-switcher.tsx
Normal 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
235
src/lib/ai/analysis.ts
Normal 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),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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
153
src/lib/ai/prompts.ts
Normal 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');
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
218
src/lib/ai/structured-analysis.ts
Normal file
218
src/lib/ai/structured-analysis.ts
Normal 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),
|
||||
},
|
||||
};
|
||||
}
|
||||
180
src/lib/ai/structured-output.ts
Normal file
180
src/lib/ai/structured-output.ts
Normal 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
25
src/lib/auth/audit.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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
81
src/lib/auth/rbac.ts
Normal 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
316
src/lib/contracts/index.ts
Normal 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);
|
||||
}
|
||||
@@ -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
22
src/lib/db/tenant.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
188
src/lib/proceedings/deadlines.ts
Normal file
188
src/lib/proceedings/deadlines.ts
Normal 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();
|
||||
}
|
||||
105
src/lib/proceedings/index.ts
Normal file
105
src/lib/proceedings/index.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
590
src/lib/proceedings/workflows.ts
Normal file
590
src/lib/proceedings/workflows.ts
Normal 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
46
src/middleware.ts
Normal 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
14
src/types/mammoth.d.ts
vendored
Normal 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
14
src/types/pdf-parse.d.ts
vendored
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user