feat: vollständiges Datenmodell für Normen und Entscheidungen (AIIA-15)

Replaces placeholder schema with full legal data model:
- Normen with temporal versioning (valid_from/valid_to), source rank hierarchy,
  immutable version chains, and norm-to-norm cross-references
- Entscheidungen with structured metadata (Aktenzeichen, Gremium, Leitsatz,
  Tenor, Tatbestand, Entscheidungsgründe), decision-norm links with Stichtag
- NV Bühne Fachgruppen and BSchGO Arbitration Tribunals
- Cases, Analyses, and DSGVO Audit Log
- Mandantentrennung via tenant_id + PostgreSQL Row-Level Security policies
- Initial Drizzle migration and RLS migration

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
CTO (LegalAI)
2026-04-08 21:16:40 +00:00
parent 1d61b2ad8a
commit a7245001ad
6 changed files with 2745 additions and 43 deletions

View File

@@ -0,0 +1,231 @@
CREATE TYPE "public"."analysis_mode" AS ENUM('gutachten', 'entscheidung', 'vergleich', 'risiko');--> statement-breakpoint
CREATE TYPE "public"."analysis_status" AS ENUM('draft', 'in_progress', 'completed', 'archived');--> statement-breakpoint
CREATE TYPE "public"."decision_type" AS ENUM('schiedsspruch', 'urteil', 'beschluss', 'vergleich', 'einstweilige_verfuegung');--> statement-breakpoint
CREATE TYPE "public"."legal_domain" AS ENUM('buehnenrecht', 'arbeitsrecht', 'tarifrecht', 'urheberrecht', 'sozialrecht', 'vertragsrecht', 'prozessrecht');--> statement-breakpoint
CREATE TYPE "public"."norm_type" AS ENUM('gesetz', 'tarifvertrag', 'schiedsordnung', 'verordnung', 'satzung', 'richtlinie');--> statement-breakpoint
CREATE TYPE "public"."source_rank" AS ENUM('gesetz', 'tarif', 'schiedsordnung', 'praxis', 'kommentar');--> statement-breakpoint
CREATE TYPE "public"."user_role" AS ENUM('admin', 'attorney', 'paralegal', 'viewer');--> statement-breakpoint
CREATE TABLE "analyses" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"tenant_id" uuid NOT NULL,
"case_id" uuid,
"user_id" uuid NOT NULL,
"mode" "analysis_mode" NOT NULL,
"status" "analysis_status" DEFAULT 'draft' NOT NULL,
"title" varchar(500) NOT NULL,
"query" text NOT NULL,
"result" text,
"sources" jsonb,
"ai_provider" varchar(50),
"ai_model" varchar(100),
"token_usage" jsonb,
"metadata" jsonb,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "arbitration_tribunals" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" varchar(255) NOT NULL,
"level" varchar(20) NOT NULL,
"seat" varchar(255),
"bschgo_ref" varchar(100),
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "audit_log" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"tenant_id" uuid NOT NULL,
"user_id" uuid,
"action" varchar(100) NOT NULL,
"entity_type" varchar(100) NOT NULL,
"entity_id" uuid,
"details" jsonb,
"ip_address" varchar(45),
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "cases" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"tenant_id" uuid NOT NULL,
"case_number" varchar(100) NOT NULL,
"title" varchar(500) NOT NULL,
"description" text,
"client_name" varchar(255),
"opposing_party" varchar(255),
"venue" varchar(255),
"fachgruppe_id" uuid,
"domains" jsonb DEFAULT '[]'::jsonb,
"status" varchar(50) DEFAULT 'active' NOT NULL,
"filing_date" date,
"hearing_date" date,
"closed_at" timestamp with time zone,
"metadata" jsonb,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "decision_norms" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"decision_id" uuid NOT NULL,
"norm_id" uuid NOT NULL,
"application_type" varchar(50) DEFAULT 'angewendet' NOT NULL,
"passage" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "decision_references" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"source_decision_id" uuid NOT NULL,
"target_decision_id" uuid NOT NULL,
"reference_type" varchar(50) NOT NULL,
"description" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "decisions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"tenant_id" uuid,
"type" "decision_type" NOT NULL,
"case_reference" varchar(100),
"decision_date" date NOT NULL,
"court" varchar(255) NOT NULL,
"tribunal_id" uuid,
"chamber" varchar(100),
"headnote" text,
"tenor" text,
"facts" text,
"reasoning" text,
"full_text" text,
"domains" jsonb DEFAULT '[]'::jsonb,
"keywords" jsonb DEFAULT '[]'::jsonb,
"publication_source" text,
"is_published" boolean DEFAULT false,
"is_anonymized" boolean DEFAULT false,
"metadata" jsonb,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "norm_instruments" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"tenant_id" uuid,
"type" "norm_type" NOT NULL,
"source_rank" "source_rank" NOT NULL,
"abbreviation" varchar(50) NOT NULL,
"full_title" text NOT NULL,
"enacted_at" date,
"issuing_body" varchar(255),
"citation" text,
"metadata" jsonb,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "norm_references" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"source_norm_id" uuid NOT NULL,
"target_norm_id" uuid NOT NULL,
"reference_type" varchar(50) DEFAULT 'verweist_auf' NOT NULL,
"description" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "norms" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"tenant_id" uuid,
"instrument_id" uuid NOT NULL,
"paragraph" varchar(50) NOT NULL,
"subsection" varchar(100),
"title" varchar(500),
"body" text NOT NULL,
"valid_from" date NOT NULL,
"valid_to" date,
"previous_version_id" uuid,
"version_number" integer DEFAULT 1 NOT NULL,
"domains" jsonb DEFAULT '[]'::jsonb,
"notes" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "nv_buehne_fachgruppen" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" varchar(255) NOT NULL,
"abbreviation" varchar(20),
"section_ref" varchar(100),
"description" text,
"special_provisions" jsonb DEFAULT '[]'::jsonb,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "tenants" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" varchar(255) NOT NULL,
"slug" varchar(100) NOT NULL,
"address" text,
"phone" varchar(50),
"email" varchar(255),
"retention_days" integer DEFAULT 3650,
"settings" jsonb,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "tenants_slug_unique" UNIQUE("slug")
);
--> statement-breakpoint
CREATE TABLE "users" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"tenant_id" uuid NOT NULL,
"email" varchar(255) NOT NULL,
"name" varchar(255) NOT NULL,
"role" "user_role" DEFAULT 'viewer' NOT NULL,
"password_hash" text,
"email_verified_at" timestamp with time zone,
"last_login_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
ALTER TABLE "analyses" ADD CONSTRAINT "analyses_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "analyses" ADD CONSTRAINT "analyses_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 "analyses" ADD CONSTRAINT "analyses_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 "audit_log" ADD CONSTRAINT "audit_log_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "audit_log" ADD CONSTRAINT "audit_log_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 "cases" ADD CONSTRAINT "cases_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "cases" ADD CONSTRAINT "cases_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 "decision_norms" ADD CONSTRAINT "decision_norms_decision_id_decisions_id_fk" FOREIGN KEY ("decision_id") REFERENCES "public"."decisions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "decision_norms" ADD CONSTRAINT "decision_norms_norm_id_norms_id_fk" FOREIGN KEY ("norm_id") REFERENCES "public"."norms"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "decision_references" ADD CONSTRAINT "decision_references_source_decision_id_decisions_id_fk" FOREIGN KEY ("source_decision_id") REFERENCES "public"."decisions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "decision_references" ADD CONSTRAINT "decision_references_target_decision_id_decisions_id_fk" FOREIGN KEY ("target_decision_id") REFERENCES "public"."decisions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "decisions" ADD CONSTRAINT "decisions_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "decisions" ADD CONSTRAINT "decisions_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 "norm_instruments" ADD CONSTRAINT "norm_instruments_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "norm_references" ADD CONSTRAINT "norm_references_source_norm_id_norms_id_fk" FOREIGN KEY ("source_norm_id") REFERENCES "public"."norms"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "norm_references" ADD CONSTRAINT "norm_references_target_norm_id_norms_id_fk" FOREIGN KEY ("target_norm_id") REFERENCES "public"."norms"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "norms" ADD CONSTRAINT "norms_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "norms" ADD CONSTRAINT "norms_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 "norms" ADD CONSTRAINT "norms_previous_version_id_norms_id_fk" FOREIGN KEY ("previous_version_id") REFERENCES "public"."norms"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "users" ADD CONSTRAINT "users_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "analyses_tenant_idx" ON "analyses" USING btree ("tenant_id");--> statement-breakpoint
CREATE INDEX "analyses_case_idx" ON "analyses" USING btree ("case_id");--> statement-breakpoint
CREATE INDEX "analyses_mode_idx" ON "analyses" USING btree ("mode");--> statement-breakpoint
CREATE INDEX "audit_log_tenant_idx" ON "audit_log" USING btree ("tenant_id");--> statement-breakpoint
CREATE INDEX "audit_log_entity_idx" ON "audit_log" USING btree ("entity_type","entity_id");--> statement-breakpoint
CREATE INDEX "audit_log_created_idx" ON "audit_log" USING btree ("created_at");--> statement-breakpoint
CREATE UNIQUE INDEX "cases_tenant_number_idx" ON "cases" USING btree ("tenant_id","case_number");--> statement-breakpoint
CREATE INDEX "cases_tenant_idx" ON "cases" USING btree ("tenant_id");--> statement-breakpoint
CREATE INDEX "cases_status_idx" ON "cases" USING btree ("status");--> statement-breakpoint
CREATE UNIQUE INDEX "decision_norms_unique_idx" ON "decision_norms" USING btree ("decision_id","norm_id");--> statement-breakpoint
CREATE UNIQUE INDEX "decision_refs_unique_idx" ON "decision_references" USING btree ("source_decision_id","target_decision_id","reference_type");--> statement-breakpoint
CREATE INDEX "decisions_type_idx" ON "decisions" USING btree ("type");--> statement-breakpoint
CREATE INDEX "decisions_date_idx" ON "decisions" USING btree ("decision_date");--> statement-breakpoint
CREATE INDEX "decisions_court_idx" ON "decisions" USING btree ("court");--> statement-breakpoint
CREATE INDEX "decisions_tenant_idx" ON "decisions" USING btree ("tenant_id");--> statement-breakpoint
CREATE INDEX "decisions_case_ref_idx" ON "decisions" USING btree ("case_reference");--> statement-breakpoint
CREATE INDEX "norm_instruments_type_idx" ON "norm_instruments" USING btree ("type");--> statement-breakpoint
CREATE INDEX "norm_instruments_tenant_idx" ON "norm_instruments" USING btree ("tenant_id");--> statement-breakpoint
CREATE UNIQUE INDEX "norm_refs_unique_idx" ON "norm_references" USING btree ("source_norm_id","target_norm_id","reference_type");--> statement-breakpoint
CREATE INDEX "norms_instrument_idx" ON "norms" USING btree ("instrument_id");--> statement-breakpoint
CREATE INDEX "norms_paragraph_idx" ON "norms" USING btree ("instrument_id","paragraph");--> statement-breakpoint
CREATE INDEX "norms_valid_from_idx" ON "norms" USING btree ("valid_from");--> statement-breakpoint
CREATE INDEX "norms_tenant_idx" ON "norms" USING btree ("tenant_id");--> statement-breakpoint
CREATE UNIQUE INDEX "users_tenant_email_idx" ON "users" USING btree ("tenant_id","email");

View File

@@ -0,0 +1,122 @@
-- ============================================================
-- Row-Level Security (RLS) for Mandantentrennung
-- ============================================================
-- Every tenant-scoped table gets RLS policies.
-- The application sets current_setting('app.tenant_id') via
-- middleware before executing any query.
-- ============================================================
-- Enable RLS on all tenant-scoped tables
ALTER TABLE tenants ENABLE ROW LEVEL SECURITY;
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
ALTER TABLE norm_instruments ENABLE ROW LEVEL SECURITY;
ALTER TABLE norms ENABLE ROW LEVEL SECURITY;
ALTER TABLE decisions ENABLE ROW LEVEL SECURITY;
ALTER TABLE cases ENABLE ROW LEVEL SECURITY;
ALTER TABLE analyses ENABLE ROW LEVEL SECURITY;
ALTER TABLE audit_log ENABLE ROW LEVEL SECURITY;
-- ============================================================
-- Tenants: users can only see their own tenant
-- ============================================================
CREATE POLICY tenant_isolation ON tenants
USING (id = current_setting('app.tenant_id', true)::uuid);
-- ============================================================
-- Users: scoped to tenant
-- ============================================================
CREATE POLICY users_tenant_isolation ON users
USING (tenant_id = current_setting('app.tenant_id', true)::uuid);
-- ============================================================
-- Norm Instruments: tenant-scoped OR shared (tenant_id IS NULL)
-- Shared norm instruments (system reference data) are visible
-- to all tenants but only writable by superusers.
-- ============================================================
CREATE POLICY norm_instruments_read ON norm_instruments
FOR SELECT
USING (
tenant_id IS NULL
OR tenant_id = current_setting('app.tenant_id', true)::uuid
);
CREATE POLICY norm_instruments_write ON norm_instruments
FOR ALL
USING (tenant_id = current_setting('app.tenant_id', true)::uuid)
WITH CHECK (tenant_id = current_setting('app.tenant_id', true)::uuid);
-- ============================================================
-- Norms: follow instrument visibility (tenant-scoped OR shared)
-- ============================================================
CREATE POLICY norms_read ON norms
FOR SELECT
USING (
tenant_id IS NULL
OR tenant_id = current_setting('app.tenant_id', true)::uuid
);
CREATE POLICY norms_write ON norms
FOR ALL
USING (tenant_id = current_setting('app.tenant_id', true)::uuid)
WITH CHECK (tenant_id = current_setting('app.tenant_id', true)::uuid);
-- ============================================================
-- Decisions: tenant-scoped OR shared (published/anonymized)
-- ============================================================
CREATE POLICY decisions_read ON decisions
FOR SELECT
USING (
tenant_id IS NULL
OR tenant_id = current_setting('app.tenant_id', true)::uuid
OR (is_published = true AND is_anonymized = true)
);
CREATE POLICY decisions_write ON decisions
FOR ALL
USING (tenant_id = current_setting('app.tenant_id', true)::uuid)
WITH CHECK (tenant_id = current_setting('app.tenant_id', true)::uuid);
-- ============================================================
-- Cases: strictly tenant-scoped (contains client PII)
-- ============================================================
CREATE POLICY cases_tenant_isolation ON cases
USING (tenant_id = current_setting('app.tenant_id', true)::uuid);
-- ============================================================
-- Analyses: strictly tenant-scoped
-- ============================================================
CREATE POLICY analyses_tenant_isolation ON analyses
USING (tenant_id = current_setting('app.tenant_id', true)::uuid);
-- ============================================================
-- Audit Log: strictly tenant-scoped
-- ============================================================
CREATE POLICY audit_log_tenant_isolation ON audit_log
USING (tenant_id = current_setting('app.tenant_id', true)::uuid);
-- ============================================================
-- Application role setup
-- ============================================================
-- The app connects as 'legalai_app' which has RLS enforced.
-- Migrations and seed data run as the superuser which bypasses RLS.
DO $$
BEGIN
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'legalai_app') THEN
CREATE ROLE legalai_app LOGIN;
END IF;
END
$$;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO legalai_app;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO legalai_app;
-- Force RLS for the app role (even table owners are subject to policies)
ALTER TABLE tenants FORCE ROW LEVEL SECURITY;
ALTER TABLE users FORCE ROW LEVEL SECURITY;
ALTER TABLE norm_instruments FORCE ROW LEVEL SECURITY;
ALTER TABLE norms FORCE ROW LEVEL SECURITY;
ALTER TABLE decisions FORCE ROW LEVEL SECURITY;
ALTER TABLE cases FORCE ROW LEVEL SECURITY;
ALTER TABLE analyses FORCE ROW LEVEL SECURITY;
ALTER TABLE audit_log FORCE ROW LEVEL SECURITY;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1775682934077,
"tag": "0000_peaceful_amazoness",
"breakpoints": true
}
]
}

View File

@@ -3,10 +3,11 @@
import { drizzle } from 'drizzle-orm/node-postgres';
import { Pool } from 'pg';
import * as schema from './schema';
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
export const db = drizzle(pool);
export const db = drizzle(pool, { schema });
export { pool };

View File

@@ -1,51 +1,526 @@
// LegalAI Database Schema
// PostgreSQL with Row-Level Security for tenant isolation
// Full data model will be designed by Legal Engineer (AIIA subtask)
import {
pgTable,
pgEnum,
uuid,
text,
varchar,
timestamp,
date,
boolean,
integer,
jsonb,
index,
uniqueIndex,
primaryKey,
} from "drizzle-orm/pg-core";
import { relations } from "drizzle-orm";
import { pgTable, uuid, text, timestamp, varchar } from 'drizzle-orm/pg-core';
// ============================================================
// Enums
// ============================================================
// Base tenant table — all tenant-scoped tables reference this
export const tenants = pgTable('tenants', {
id: uuid('id').primaryKey().defaultRandom(),
name: varchar('name', { length: 255 }).notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
/** Quellenrang — legal source hierarchy for norm precedence */
export const sourceRankEnum = pgEnum("source_rank", [
"gesetz", // Gesetz (statutory law) — highest
"tarif", // Tarifvertrag (collective agreement, e.g. NV Bühne)
"schiedsordnung", // Schiedsordnung (arbitration rules, e.g. BSchGO)
"praxis", // Bühnenpraxis / Gewohnheitsrecht
"kommentar", // Kommentarliteratur / doctrine
]);
/** Type of legal norm document */
export const normTypeEnum = pgEnum("norm_type", [
"gesetz", // Statute (e.g. ArbGG, KSchG, BGB)
"tarifvertrag", // Collective agreement (e.g. NV Bühne)
"schiedsordnung", // Arbitration rules (e.g. BSchGO)
"verordnung", // Regulation / decree
"satzung", // Bylaws (e.g. GDBA-Satzung)
"richtlinie", // Guideline / directive
]);
/** Type of court/arbitration decision */
export const decisionTypeEnum = pgEnum("decision_type", [
"schiedsspruch", // Arbitration award (Bühnenschiedsgericht)
"urteil", // Court judgment
"beschluss", // Court order / resolution
"vergleich", // Settlement
"einstweilige_verfuegung", // Interim injunction
]);
/** Legal domain / Rechtsgebiet */
export const legalDomainEnum = pgEnum("legal_domain", [
"buehnenrecht", // Stage law (core domain)
"arbeitsrecht", // Employment law
"tarifrecht", // Collective bargaining law
"urheberrecht", // Copyright law
"sozialrecht", // Social security law
"vertragsrecht", // Contract law
"prozessrecht", // Procedural law
]);
/** Role of a user within a tenant */
export const userRoleEnum = pgEnum("user_role", [
"admin", // Tenant administrator (the attorney)
"attorney", // Licensed attorney
"paralegal", // Paralegal / Referendar
"viewer", // Read-only access
]);
/** Status of an analysis */
export const analysisStatusEnum = pgEnum("analysis_status", [
"draft",
"in_progress",
"completed",
"archived",
]);
/** AI analysis mode */
export const analysisModeEnum = pgEnum("analysis_mode", [
"gutachten", // Structured legal opinion
"entscheidung", // Decision proposal based on precedent
"vergleich", // Comparative analysis of norms/decisions
"risiko", // Risk assessment with probability
]);
// ============================================================
// Core tables: Tenants & Users
// ============================================================
/** Mandanten — law firm or attorney office as tenant */
export const tenants = pgTable("tenants", {
id: uuid("id").primaryKey().defaultRandom(),
name: varchar("name", { length: 255 }).notNull(),
slug: varchar("slug", { length: 100 }).notNull().unique(),
/** Kanzlei details */
address: text("address"),
phone: varchar("phone", { length: 50 }),
email: varchar("email", { length: 255 }),
/** DSGVO: data retention policy in days (default 10 years / 3650 days) */
retentionDays: integer("retention_days").default(3650),
settings: jsonb("settings").$type<Record<string, unknown>>(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
});
// Users table with tenant association
export const users = pgTable('users', {
id: uuid('id').primaryKey().defaultRandom(),
tenantId: uuid('tenant_id').references(() => tenants.id).notNull(),
email: varchar('email', { length: 255 }).notNull().unique(),
name: varchar('name', { length: 255 }),
role: varchar('role', { length: 50 }).notNull().default('user'),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
/** Nutzer — users belong to a tenant */
export const users = pgTable(
"users",
{
id: uuid("id").primaryKey().defaultRandom(),
tenantId: uuid("tenant_id").notNull().references(() => tenants.id, { onDelete: "cascade" }),
email: varchar("email", { length: 255 }).notNull(),
name: varchar("name", { length: 255 }).notNull(),
role: userRoleEnum("role").notNull().default("viewer"),
passwordHash: text("password_hash"),
emailVerifiedAt: timestamp("email_verified_at", { withTimezone: true }),
lastLoginAt: timestamp("last_login_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
},
(t) => [
uniqueIndex("users_tenant_email_idx").on(t.tenantId, t.email),
],
);
// ============================================================
// Normen (Legal norms with temporal versioning)
// ============================================================
/**
* Normenwerke — top-level legal instruments (e.g. "NV Bühne", "ArbGG", "BSchGO").
* Groups individual norm paragraphs.
*/
export const normInstruments = pgTable(
"norm_instruments",
{
id: uuid("id").primaryKey().defaultRandom(),
tenantId: uuid("tenant_id").references(() => tenants.id, { onDelete: "cascade" }),
/** null tenantId = system-wide (shared reference data) */
type: normTypeEnum("type").notNull(),
sourceRank: sourceRankEnum("source_rank").notNull(),
/** Official short name, e.g. "NV Bühne", "ArbGG" */
abbreviation: varchar("abbreviation", { length: 50 }).notNull(),
/** Full title */
fullTitle: text("full_title").notNull(),
/** Date of enactment / Inkrafttreten */
enactedAt: date("enacted_at"),
/** Issuing body, e.g. "Deutscher Bundestag", "GDBA/DBV" */
issuingBody: varchar("issuing_body", { length: 255 }),
/** Official gazette citation / Fundstelle */
citation: text("citation"),
metadata: jsonb("metadata").$type<Record<string, unknown>>(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
},
(t) => [
index("norm_instruments_type_idx").on(t.type),
index("norm_instruments_tenant_idx").on(t.tenantId),
],
);
/**
* Normen — individual norm provisions (paragraphs/articles) with temporal versioning.
* Each row is an immutable version; changes create a new row with previousVersionId.
*/
export const norms = pgTable(
"norms",
{
id: uuid("id").primaryKey().defaultRandom(),
tenantId: uuid("tenant_id").references(() => tenants.id, { onDelete: "cascade" }),
instrumentId: uuid("instrument_id").notNull().references(() => normInstruments.id, { onDelete: "cascade" }),
/** Paragraph/Article designation, e.g. "§ 53", "Art. 12" */
paragraph: varchar("paragraph", { length: 50 }).notNull(),
/** Optional subsection, e.g. "Abs. 2 S. 3" */
subsection: varchar("subsection", { length: 100 }),
/** Title/heading of the provision */
title: varchar("title", { length: 500 }),
/** Full text of the provision (this version) */
body: text("body").notNull(),
/** Temporal validity window */
validFrom: date("valid_from").notNull(),
validTo: date("valid_to"),
/** Immutable versioning chain */
previousVersionId: uuid("previous_version_id").references((): any => norms.id),
/** Version number within this paragraph lineage */
versionNumber: integer("version_number").notNull().default(1),
/** Legal domains this norm covers */
domains: jsonb("domains").$type<string[]>().default([]),
/** Notes / editorial remarks */
notes: text("notes"),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
},
(t) => [
index("norms_instrument_idx").on(t.instrumentId),
index("norms_paragraph_idx").on(t.instrumentId, t.paragraph),
index("norms_valid_from_idx").on(t.validFrom),
index("norms_tenant_idx").on(t.tenantId),
],
);
/**
* Norm cross-references — directed links between norms (e.g. NV Bühne § 53 -> ArbGG § 110).
*/
export const normReferences = pgTable(
"norm_references",
{
id: uuid("id").primaryKey().defaultRandom(),
sourceNormId: uuid("source_norm_id").notNull().references(() => norms.id, { onDelete: "cascade" }),
targetNormId: uuid("target_norm_id").notNull().references(() => norms.id, { onDelete: "cascade" }),
/** Type of reference relationship */
referenceType: varchar("reference_type", { length: 50 }).notNull().default("verweist_auf"),
/** Optional description of the relationship */
description: text("description"),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
},
(t) => [
uniqueIndex("norm_refs_unique_idx").on(t.sourceNormId, t.targetNormId, t.referenceType),
],
);
// ============================================================
// NV Bühne specific: Collective agreement structures
// ============================================================
/**
* NV Bühne Fachgruppen — professional groups under the NV Bühne collective agreement.
* (Solo, Chor, Tanz, Bühnentechnik, etc.)
*/
export const nvBuehneFachgruppen = pgTable("nv_buehne_fachgruppen", {
id: uuid("id").primaryKey().defaultRandom(),
name: varchar("name", { length: 255 }).notNull(),
abbreviation: varchar("abbreviation", { length: 20 }),
/** NV Bühne section reference */
sectionRef: varchar("section_ref", { length: 100 }),
description: text("description"),
/** Applicable special provisions as JSONB array */
specialProvisions: jsonb("special_provisions").$type<string[]>().default([]),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
});
// Placeholder tables — full schema will be defined by Legal Engineer
// See subtask: Datenmodell für Normen und Entscheidungen
// ============================================================
// BSchGO specific: Arbitration procedure structures
// ============================================================
export const norms = pgTable('norms', {
id: uuid('id').primaryKey().defaultRandom(),
tenantId: uuid('tenant_id').references(() => tenants.id).notNull(),
title: varchar('title', { length: 500 }).notNull(),
body: text('body'),
quellenRang: varchar('quellen_rang', { length: 50 }).notNull(),
validFrom: timestamp('valid_from').notNull(),
validTo: timestamp('valid_to'),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
/**
* Bühnenschiedsgerichte — arbitration tribunals under BSchGO.
* Tracks the specific tribunals (Bezirk, Bundes) and their composition.
*/
export const arbitrationTribunals = pgTable("arbitration_tribunals", {
id: uuid("id").primaryKey().defaultRandom(),
name: varchar("name", { length: 255 }).notNull(),
/** bezirk = regional, bund = federal */
level: varchar("level", { length: 20 }).notNull(),
/** Seat / location of the tribunal */
seat: varchar("seat", { length: 255 }),
/** Applicable BSchGO version/section */
bschgoRef: varchar("bschgo_ref", { length: 100 }),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
});
export const decisions = pgTable('decisions', {
id: uuid('id').primaryKey().defaultRandom(),
tenantId: uuid('tenant_id').references(() => tenants.id).notNull(),
title: varchar('title', { length: 500 }).notNull(),
body: text('body'),
decisionDate: timestamp('decision_date'),
aktenzeichen: varchar('aktenzeichen', { length: 100 }),
gremium: varchar('gremium', { length: 255 }),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
});
// ============================================================
// Entscheidungen (Decisions / Rulings)
// ============================================================
/**
* Entscheidungen — court judgments, arbitration awards, settlements.
* Core entity for the decision database.
*/
export const decisions = pgTable(
"decisions",
{
id: uuid("id").primaryKey().defaultRandom(),
tenantId: uuid("tenant_id").references(() => tenants.id, { onDelete: "cascade" }),
type: decisionTypeEnum("type").notNull(),
/** Aktenzeichen / case reference number */
caseReference: varchar("case_reference", { length: 100 }),
/** Date of the decision */
decisionDate: date("decision_date").notNull(),
/** Court or tribunal that issued the decision */
court: varchar("court", { length: 255 }).notNull(),
/** Arbitration tribunal reference (for Schiedssprüche) */
tribunalId: uuid("tribunal_id").references(() => arbitrationTribunals.id),
/** Chamber / Kammer / Senat */
chamber: varchar("chamber", { length: 100 }),
/** Leitsatz — headnote / guiding principle */
headnote: text("headnote"),
/** Tenor — operative part of the decision */
tenor: text("tenor"),
/** Tatbestand — statement of facts */
facts: text("facts"),
/** Entscheidungsgründe — reasoning */
reasoning: text("reasoning"),
/** Full text (if available) */
fullText: text("full_text"),
/** Legal domains covered */
domains: jsonb("domains").$type<string[]>().default([]),
/** Keywords / Schlagworte for search */
keywords: jsonb("keywords").$type<string[]>().default([]),
/** Publication source / Fundstelle */
publicationSource: text("publication_source"),
/** Whether the decision is published / anonymized */
isPublished: boolean("is_published").default(false),
/** DSGVO: anonymization status */
isAnonymized: boolean("is_anonymized").default(false),
metadata: jsonb("metadata").$type<Record<string, unknown>>(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
},
(t) => [
index("decisions_type_idx").on(t.type),
index("decisions_date_idx").on(t.decisionDate),
index("decisions_court_idx").on(t.court),
index("decisions_tenant_idx").on(t.tenantId),
index("decisions_case_ref_idx").on(t.caseReference),
],
);
/**
* Decision-Norm links — which norms were applied in a decision.
* Captures the norm version valid at the time of the decision (Stichtag).
*/
export const decisionNorms = pgTable(
"decision_norms",
{
id: uuid("id").primaryKey().defaultRandom(),
decisionId: uuid("decision_id").notNull().references(() => decisions.id, { onDelete: "cascade" }),
normId: uuid("norm_id").notNull().references(() => norms.id, { onDelete: "cascade" }),
/** How the norm was applied: "angewendet", "zitiert", "ausgelegt", "verworfen" */
applicationType: varchar("application_type", { length: 50 }).notNull().default("angewendet"),
/** Specific passage in the decision referencing this norm */
passage: text("passage"),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
},
(t) => [
uniqueIndex("decision_norms_unique_idx").on(t.decisionId, t.normId),
],
);
/**
* Decision cross-references — links between decisions (Präzedenzfälle, Abweichungen).
*/
export const decisionReferences = pgTable(
"decision_references",
{
id: uuid("id").primaryKey().defaultRandom(),
sourceDecisionId: uuid("source_decision_id").notNull().references(() => decisions.id, { onDelete: "cascade" }),
targetDecisionId: uuid("target_decision_id").notNull().references(() => decisions.id, { onDelete: "cascade" }),
/** Relationship: "bestaetigt", "abweicht", "aufgehoben", "zitiert" */
referenceType: varchar("reference_type", { length: 50 }).notNull(),
description: text("description"),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
},
(t) => [
uniqueIndex("decision_refs_unique_idx").on(t.sourceDecisionId, t.targetDecisionId, t.referenceType),
],
);
// ============================================================
// Fälle (Cases) & Analysen (Analyses)
// ============================================================
/** Fälle — client cases managed by the attorney */
export const cases = pgTable(
"cases",
{
id: uuid("id").primaryKey().defaultRandom(),
tenantId: uuid("tenant_id").notNull().references(() => tenants.id, { onDelete: "cascade" }),
/** Internal case number */
caseNumber: varchar("case_number", { length: 100 }).notNull(),
title: varchar("title", { length: 500 }).notNull(),
description: text("description"),
/** Client (Mandant) name — may be pseudonymized */
clientName: varchar("client_name", { length: 255 }),
/** Opposing party */
opposingParty: varchar("opposing_party", { length: 255 }),
/** Associated theater/venue */
venue: varchar("venue", { length: 255 }),
/** NV Bühne Fachgruppe if applicable */
fachgruppeId: uuid("fachgruppe_id").references(() => nvBuehneFachgruppen.id),
domains: jsonb("domains").$type<string[]>().default([]),
status: varchar("status", { length: 50 }).notNull().default("active"),
/** Key dates */
filingDate: date("filing_date"),
hearingDate: date("hearing_date"),
closedAt: timestamp("closed_at", { withTimezone: true }),
metadata: jsonb("metadata").$type<Record<string, unknown>>(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
},
(t) => [
uniqueIndex("cases_tenant_number_idx").on(t.tenantId, t.caseNumber),
index("cases_tenant_idx").on(t.tenantId),
index("cases_status_idx").on(t.status),
],
);
/** Analysen — AI-assisted legal analyses linked to a case */
export const analyses = pgTable(
"analyses",
{
id: uuid("id").primaryKey().defaultRandom(),
tenantId: uuid("tenant_id").notNull().references(() => tenants.id, { onDelete: "cascade" }),
caseId: uuid("case_id").references(() => cases.id, { onDelete: "set null" }),
userId: uuid("user_id").notNull().references(() => users.id),
mode: analysisModeEnum("mode").notNull(),
status: analysisStatusEnum("status").notNull().default("draft"),
title: varchar("title", { length: 500 }).notNull(),
/** Input query / legal question */
query: text("query").notNull(),
/** AI-generated analysis result (markdown) */
result: text("result"),
/** Source references cited in the analysis */
sources: jsonb("sources").$type<{
normIds: string[];
decisionIds: string[];
otherSources: string[];
}>(),
/** AI provider and model used */
aiProvider: varchar("ai_provider", { length: 50 }),
aiModel: varchar("ai_model", { length: 100 }),
/** Token usage for billing/audit */
tokenUsage: jsonb("token_usage").$type<{
inputTokens: number;
outputTokens: number;
}>(),
metadata: jsonb("metadata").$type<Record<string, unknown>>(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
},
(t) => [
index("analyses_tenant_idx").on(t.tenantId),
index("analyses_case_idx").on(t.caseId),
index("analyses_mode_idx").on(t.mode),
],
);
// ============================================================
// DSGVO / Audit
// ============================================================
/** Audit log — tracks data access for DSGVO compliance */
export const auditLog = pgTable(
"audit_log",
{
id: uuid("id").primaryKey().defaultRandom(),
tenantId: uuid("tenant_id").notNull().references(() => tenants.id, { onDelete: "cascade" }),
userId: uuid("user_id").references(() => users.id),
action: varchar("action", { length: 100 }).notNull(),
entityType: varchar("entity_type", { length: 100 }).notNull(),
entityId: uuid("entity_id"),
/** What changed (for update/delete) */
details: jsonb("details").$type<Record<string, unknown>>(),
ipAddress: varchar("ip_address", { length: 45 }),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
},
(t) => [
index("audit_log_tenant_idx").on(t.tenantId),
index("audit_log_entity_idx").on(t.entityType, t.entityId),
index("audit_log_created_idx").on(t.createdAt),
],
);
// ============================================================
// Relations (Drizzle ORM relation definitions)
// ============================================================
export const tenantsRelations = relations(tenants, ({ many }) => ({
users: many(users),
cases: many(cases),
analyses: many(analyses),
decisions: many(decisions),
normInstruments: many(normInstruments),
}));
export const usersRelations = relations(users, ({ one, many }) => ({
tenant: one(tenants, { fields: [users.tenantId], references: [tenants.id] }),
analyses: many(analyses),
}));
export const normInstrumentsRelations = relations(normInstruments, ({ one, many }) => ({
tenant: one(tenants, { fields: [normInstruments.tenantId], references: [tenants.id] }),
norms: many(norms),
}));
export const normsRelations = relations(norms, ({ one, many }) => ({
instrument: one(normInstruments, { fields: [norms.instrumentId], references: [normInstruments.id] }),
tenant: one(tenants, { fields: [norms.tenantId], references: [tenants.id] }),
previousVersion: one(norms, { fields: [norms.previousVersionId], references: [norms.id], relationName: "normVersions" }),
outgoingReferences: many(normReferences, { relationName: "sourceNorm" }),
incomingReferences: many(normReferences, { relationName: "targetNorm" }),
decisionNorms: many(decisionNorms),
}));
export const normReferencesRelations = relations(normReferences, ({ one }) => ({
sourceNorm: one(norms, { fields: [normReferences.sourceNormId], references: [norms.id], relationName: "sourceNorm" }),
targetNorm: one(norms, { fields: [normReferences.targetNormId], references: [norms.id], relationName: "targetNorm" }),
}));
export const decisionsRelations = relations(decisions, ({ one, many }) => ({
tenant: one(tenants, { fields: [decisions.tenantId], references: [tenants.id] }),
tribunal: one(arbitrationTribunals, { fields: [decisions.tribunalId], references: [arbitrationTribunals.id] }),
appliedNorms: many(decisionNorms),
outgoingReferences: many(decisionReferences, { relationName: "sourceDecision" }),
incomingReferences: many(decisionReferences, { relationName: "targetDecision" }),
}));
export const decisionNormsRelations = relations(decisionNorms, ({ one }) => ({
decision: one(decisions, { fields: [decisionNorms.decisionId], references: [decisions.id] }),
norm: one(norms, { fields: [decisionNorms.normId], references: [norms.id] }),
}));
export const decisionReferencesRelations = relations(decisionReferences, ({ one }) => ({
sourceDecision: one(decisions, { fields: [decisionReferences.sourceDecisionId], references: [decisions.id], relationName: "sourceDecision" }),
targetDecision: one(decisions, { fields: [decisionReferences.targetDecisionId], references: [decisions.id], relationName: "targetDecision" }),
}));
export const casesRelations = relations(cases, ({ one, many }) => ({
tenant: one(tenants, { fields: [cases.tenantId], references: [tenants.id] }),
fachgruppe: one(nvBuehneFachgruppen, { fields: [cases.fachgruppeId], references: [nvBuehneFachgruppen.id] }),
analyses: many(analyses),
}));
export const analysesRelations = relations(analyses, ({ one }) => ({
tenant: one(tenants, { fields: [analyses.tenantId], references: [tenants.id] }),
case: one(cases, { fields: [analyses.caseId], references: [cases.id] }),
user: one(users, { fields: [analyses.userId], references: [users.id] }),
}));