Workstream 2: Datenbankschema & Migrationen (Phase 1)

Vollständiges Drizzle-Schema (alle Tabellen/Enums/Indizes aus Spec §6):
brigades, users, merkmale(+optionen), vehicle_templates(+merkmale,+aliasse),
equipment_categories(+merkmale), vehicles, equipment, merkmal_values (EAV mit
typisierten Spalten + 4 Indizes), login_attempts, audit_log. Einzige initiale
Migration 0000 (idempotent: enum-DO-Blöcke, IF NOT EXISTS), scripts/migrate.ts,
db:* npm-Scripts.

Verifiziert (offline): tsc --noEmit OK; drizzle-kit check 'Everything's fine';
Migration 7 CREATE TYPE / 14 CREATE TABLE / 17 CREATE INDEX / 32 IF NOT EXISTS.
DEFERRED (kein Postgres im Sandbox — Ursache des vorherigen Stalls): live
db:migrate und DB-abhängige Schema-Tests; laufen in CI/Deploy mit Postgres.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude
2026-06-09 08:58:56 +02:00
parent d7c74aa041
commit a9666ff96c
23 changed files with 3291 additions and 30 deletions

View File

@@ -0,0 +1,187 @@
DO $$ BEGIN CREATE TYPE "public"."asset_status" AS ENUM('einsatzbereit', 'wartung', 'ausser_dienst'); EXCEPTION WHEN duplicate_object THEN null; END $$;--> statement-breakpoint
DO $$ BEGIN CREATE TYPE "public"."auth_typ" AS ENUM('authentik', 'local'); EXCEPTION WHEN duplicate_object THEN null; END $$;--> statement-breakpoint
DO $$ BEGIN CREATE TYPE "public"."entity_typ" AS ENUM('vehicle', 'equipment'); EXCEPTION WHEN duplicate_object THEN null; END $$;--> statement-breakpoint
DO $$ BEGIN CREATE TYPE "public"."geltungsbereich" AS ENUM('vehicle', 'equipment', 'both'); EXCEPTION WHEN duplicate_object THEN null; END $$;--> statement-breakpoint
DO $$ BEGIN CREATE TYPE "public"."merkmal_status" AS ENUM('active', 'proposed'); EXCEPTION WHEN duplicate_object THEN null; END $$;--> statement-breakpoint
DO $$ BEGIN CREATE TYPE "public"."merkmal_typ" AS ENUM('number', 'enum', 'boolean', 'text'); EXCEPTION WHEN duplicate_object THEN null; END $$;--> statement-breakpoint
DO $$ BEGIN CREATE TYPE "public"."role" AS ENUM('platform_admin', 'wehr_admin', 'wehr_read'); EXCEPTION WHEN duplicate_object THEN null; END $$;--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "brigades" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" text NOT NULL,
"art" text DEFAULT 'FF' NOT NULL,
"strasse" text,
"plz" text,
"ort" text,
"bundesland" text DEFAULT 'Niederösterreich' NOT NULL,
"lat" double precision,
"lng" double precision,
"geocode_query" text,
"geocoded_at" timestamp with time zone,
"geocode_status" text,
"funkrufname_schema" text,
"wehrfuehrer" text,
"telefon" text,
"email" text,
"aktiv" boolean DEFAULT true NOT NULL,
"erstellt_am" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "users" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"brigade_id" uuid,
"rolle" "role" NOT NULL,
"auth_typ" "auth_typ" NOT NULL,
"email" text NOT NULL,
"name" text NOT NULL,
"passwort_hash" text,
"aktiv" boolean DEFAULT true NOT NULL,
"erstellt_von" uuid,
"erstellt_am" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "users_email_uq" UNIQUE("email")
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "merkmal_optionen" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"merkmal_id" uuid NOT NULL,
"wert" text NOT NULL,
"label" text NOT NULL,
"reihenfolge" integer DEFAULT 0 NOT NULL,
CONSTRAINT "merkmal_optionen_uq" UNIQUE("merkmal_id","wert")
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "merkmale" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"slug" text NOT NULL,
"name" text NOT NULL,
"typ" "merkmal_typ" NOT NULL,
"einheit" text,
"geltungsbereich" "geltungsbereich" NOT NULL,
"status" "merkmal_status" DEFAULT 'proposed' NOT NULL,
"vorgeschlagen_von_brigade_id" uuid,
"erstellt_am" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "merkmale_slug_uq" UNIQUE("slug")
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "vehicle_template_aliasse" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"template_id" uuid NOT NULL,
"alias" text NOT NULL,
"bestaetigt" boolean DEFAULT false NOT NULL,
CONSTRAINT "vehicle_template_aliasse_uq" UNIQUE("template_id","alias")
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "vehicle_template_merkmale" (
"template_id" uuid NOT NULL,
"merkmal_id" uuid NOT NULL,
"vorgabewert_num" double precision,
"vorgabewert_text" text,
"vorgabewert_bool" boolean,
"pflicht" boolean DEFAULT false NOT NULL,
"reihenfolge" integer DEFAULT 0 NOT NULL,
CONSTRAINT "vehicle_template_merkmale_template_id_merkmal_id_pk" PRIMARY KEY("template_id","merkmal_id")
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "vehicle_templates" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"code" text NOT NULL,
"name" text NOT NULL,
"beschreibung" text,
"reihenfolge" integer DEFAULT 0 NOT NULL,
CONSTRAINT "vehicle_templates_code_uq" UNIQUE("code")
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "equipment_categories" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" text NOT NULL,
"reihenfolge" integer DEFAULT 0 NOT NULL,
CONSTRAINT "equipment_categories_name_uq" UNIQUE("name")
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "equipment_category_merkmale" (
"category_id" uuid NOT NULL,
"merkmal_id" uuid NOT NULL,
"reihenfolge" integer DEFAULT 0 NOT NULL,
CONSTRAINT "equipment_category_merkmale_category_id_merkmal_id_pk" PRIMARY KEY("category_id","merkmal_id")
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "equipment" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"brigade_id" uuid NOT NULL,
"category_id" uuid NOT NULL,
"vehicle_id" uuid,
"name" text NOT NULL,
"status" "asset_status" DEFAULT 'einsatzbereit' NOT NULL,
"erstellt_am" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "vehicles" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"brigade_id" uuid NOT NULL,
"template_id" uuid,
"name" text NOT NULL,
"funkrufname" text,
"status" "asset_status" DEFAULT 'einsatzbereit' NOT NULL,
"notiz" text,
"erstellt_am" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "merkmal_values" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"merkmal_id" uuid NOT NULL,
"entity_typ" "entity_typ" NOT NULL,
"entity_id" uuid NOT NULL,
"value_num" double precision,
"value_text" text,
"value_bool" boolean
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "login_attempts" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"key" text NOT NULL,
"erfolg" boolean NOT NULL,
"zeitpunkt" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "audit_log" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"actor_user_id" uuid,
"aktion" text NOT NULL,
"ziel_typ" text,
"ziel_id" uuid,
"details" jsonb,
"zeitpunkt" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
DO $$ BEGIN ALTER TABLE "users" ADD CONSTRAINT "users_brigade_id_brigades_id_fk" FOREIGN KEY ("brigade_id") REFERENCES "public"."brigades"("id") ON DELETE restrict ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$;--> statement-breakpoint
DO $$ BEGIN ALTER TABLE "merkmal_optionen" ADD CONSTRAINT "merkmal_optionen_merkmal_id_merkmale_id_fk" FOREIGN KEY ("merkmal_id") REFERENCES "public"."merkmale"("id") ON DELETE cascade ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$;--> statement-breakpoint
DO $$ BEGIN ALTER TABLE "merkmale" ADD CONSTRAINT "merkmale_vorgeschlagen_von_brigade_id_brigades_id_fk" FOREIGN KEY ("vorgeschlagen_von_brigade_id") REFERENCES "public"."brigades"("id") ON DELETE set null ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$;--> statement-breakpoint
DO $$ BEGIN ALTER TABLE "vehicle_template_aliasse" ADD CONSTRAINT "vehicle_template_aliasse_template_id_vehicle_templates_id_fk" FOREIGN KEY ("template_id") REFERENCES "public"."vehicle_templates"("id") ON DELETE cascade ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$;--> statement-breakpoint
DO $$ BEGIN ALTER TABLE "vehicle_template_merkmale" ADD CONSTRAINT "vehicle_template_merkmale_template_id_vehicle_templates_id_fk" FOREIGN KEY ("template_id") REFERENCES "public"."vehicle_templates"("id") ON DELETE cascade ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$;--> statement-breakpoint
DO $$ BEGIN ALTER TABLE "vehicle_template_merkmale" ADD CONSTRAINT "vehicle_template_merkmale_merkmal_id_merkmale_id_fk" FOREIGN KEY ("merkmal_id") REFERENCES "public"."merkmale"("id") ON DELETE cascade ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$;--> statement-breakpoint
DO $$ BEGIN ALTER TABLE "equipment_category_merkmale" ADD CONSTRAINT "equipment_category_merkmale_category_id_equipment_categories_id_fk" FOREIGN KEY ("category_id") REFERENCES "public"."equipment_categories"("id") ON DELETE cascade ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$;--> statement-breakpoint
DO $$ BEGIN ALTER TABLE "equipment_category_merkmale" ADD CONSTRAINT "equipment_category_merkmale_merkmal_id_merkmale_id_fk" FOREIGN KEY ("merkmal_id") REFERENCES "public"."merkmale"("id") ON DELETE cascade ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$;--> statement-breakpoint
DO $$ BEGIN ALTER TABLE "equipment" ADD CONSTRAINT "equipment_brigade_id_brigades_id_fk" FOREIGN KEY ("brigade_id") REFERENCES "public"."brigades"("id") ON DELETE cascade ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$;--> statement-breakpoint
DO $$ BEGIN ALTER TABLE "equipment" ADD CONSTRAINT "equipment_category_id_equipment_categories_id_fk" FOREIGN KEY ("category_id") REFERENCES "public"."equipment_categories"("id") ON DELETE restrict ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$;--> statement-breakpoint
DO $$ BEGIN ALTER TABLE "equipment" ADD CONSTRAINT "equipment_vehicle_id_vehicles_id_fk" FOREIGN KEY ("vehicle_id") REFERENCES "public"."vehicles"("id") ON DELETE set null ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$;--> statement-breakpoint
DO $$ BEGIN ALTER TABLE "vehicles" ADD CONSTRAINT "vehicles_brigade_id_brigades_id_fk" FOREIGN KEY ("brigade_id") REFERENCES "public"."brigades"("id") ON DELETE cascade ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$;--> statement-breakpoint
DO $$ BEGIN ALTER TABLE "vehicles" ADD CONSTRAINT "vehicles_template_id_vehicle_templates_id_fk" FOREIGN KEY ("template_id") REFERENCES "public"."vehicle_templates"("id") ON DELETE set null ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$;--> statement-breakpoint
DO $$ BEGIN ALTER TABLE "merkmal_values" ADD CONSTRAINT "merkmal_values_merkmal_id_merkmale_id_fk" FOREIGN KEY ("merkmal_id") REFERENCES "public"."merkmale"("id") ON DELETE cascade ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$;--> statement-breakpoint
DO $$ BEGIN ALTER TABLE "audit_log" ADD CONSTRAINT "audit_log_actor_user_id_users_id_fk" FOREIGN KEY ("actor_user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$;--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "brigades_latlng_idx" ON "brigades" USING btree ("lat","lng");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "merkmal_optionen_merkmal_idx" ON "merkmal_optionen" USING btree ("merkmal_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "merkmale_status_idx" ON "merkmale" USING btree ("status");--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "merkmale_active_name_uq" ON "merkmale" USING btree ("name") WHERE "merkmale"."status" = 'active';--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "vtm_merkmal_idx" ON "vehicle_template_merkmale" USING btree ("merkmal_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "ecm_merkmal_idx" ON "equipment_category_merkmale" USING btree ("merkmal_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "equipment_brigade_idx" ON "equipment" USING btree ("brigade_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "equipment_category_idx" ON "equipment" USING btree ("category_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "equipment_vehicle_idx" ON "equipment" USING btree ("vehicle_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "vehicles_brigade_idx" ON "vehicles" USING btree ("brigade_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "vehicles_template_idx" ON "vehicles" USING btree ("template_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "mv_merkmal_num_idx" ON "merkmal_values" USING btree ("merkmal_id","value_num");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "mv_merkmal_bool_idx" ON "merkmal_values" USING btree ("merkmal_id","value_bool");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "mv_merkmal_text_idx" ON "merkmal_values" USING btree ("merkmal_id","value_text");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "mv_entity_idx" ON "merkmal_values" USING btree ("entity_typ","entity_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "login_attempts_key_zeit_idx" ON "login_attempts" USING btree ("key","zeitpunkt");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "audit_log_zeitpunkt_idx" ON "audit_log" USING btree ("zeitpunkt");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "audit_log_aktion_idx" ON "audit_log" USING btree ("aktion");

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": 1780936243787,
"tag": "0000_opposite_santa_claus",
"breakpoints": true
}
]
}

529
package-lock.json generated
View File

@@ -39,6 +39,7 @@
"prettier": "^3.5.2", "prettier": "^3.5.2",
"prettier-plugin-tailwindcss": "^0.6.11", "prettier-plugin-tailwindcss": "^0.6.11",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"tsx": "^4.19.2",
"typescript": "^5.7.3", "typescript": "^5.7.3",
"vitest": "^3.0.7" "vitest": "^3.0.7"
} }
@@ -62,29 +63,6 @@
"dev": true, "dev": true,
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/@emnapi/core": {
"version": "1.10.0",
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@emnapi/core/-/core-1.10.0.tgz",
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.1",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.11.0",
"resolved": "https://npm.apple.com/@emnapi/runtime/-/runtime-1.11.0.tgz",
"integrity": "sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg==",
"optional": true,
"peer": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/wasi-threads": { "node_modules/@emnapi/wasi-threads": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
@@ -5292,6 +5270,7 @@
"resolved": "https://npm.apple.com/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", "resolved": "https://npm.apple.com/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz",
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true, "dev": true,
"peer": true,
"dependencies": { "dependencies": {
"@rtsao/scc": "^1.1.0", "@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9", "array-includes": "^3.1.9",
@@ -8746,6 +8725,509 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD" "license": "0BSD"
}, },
"node_modules/tsx": {
"version": "4.22.4",
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/tsx/-/tsx-4.22.4.tgz",
"integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "~0.28.0"
},
"bin": {
"tsx": "dist/cli.mjs"
},
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
},
"node_modules/tsx/node_modules/@esbuild/aix-ppc64": {
"version": "0.28.0",
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz",
"integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/android-arm": {
"version": "0.28.0",
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@esbuild/android-arm/-/android-arm-0.28.0.tgz",
"integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/android-arm64": {
"version": "0.28.0",
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz",
"integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/android-x64": {
"version": "0.28.0",
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@esbuild/android-x64/-/android-x64-0.28.0.tgz",
"integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/darwin-arm64": {
"version": "0.28.0",
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz",
"integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/darwin-x64": {
"version": "0.28.0",
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz",
"integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/freebsd-arm64": {
"version": "0.28.0",
"resolved": "https://npm.apple.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz",
"integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/freebsd-x64": {
"version": "0.28.0",
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz",
"integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/linux-arm": {
"version": "0.28.0",
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz",
"integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/linux-arm64": {
"version": "0.28.0",
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz",
"integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/linux-ia32": {
"version": "0.28.0",
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz",
"integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/linux-loong64": {
"version": "0.28.0",
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz",
"integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/linux-mips64el": {
"version": "0.28.0",
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz",
"integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/linux-ppc64": {
"version": "0.28.0",
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz",
"integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/linux-riscv64": {
"version": "0.28.0",
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz",
"integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/linux-s390x": {
"version": "0.28.0",
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz",
"integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/linux-x64": {
"version": "0.28.0",
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz",
"integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/netbsd-arm64": {
"version": "0.28.0",
"resolved": "https://npm.apple.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz",
"integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/netbsd-x64": {
"version": "0.28.0",
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz",
"integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/openbsd-arm64": {
"version": "0.28.0",
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz",
"integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/openbsd-x64": {
"version": "0.28.0",
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz",
"integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/openharmony-arm64": {
"version": "0.28.0",
"resolved": "https://npm.apple.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz",
"integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/sunos-x64": {
"version": "0.28.0",
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz",
"integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/win32-arm64": {
"version": "0.28.0",
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz",
"integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/win32-ia32": {
"version": "0.28.0",
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz",
"integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/win32-x64": {
"version": "0.28.0",
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz",
"integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/esbuild": {
"version": "0.28.0",
"resolved": "https://npm.apple.com/esbuild/-/esbuild-0.28.0.tgz",
"integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==",
"dev": true,
"hasInstallScript": true,
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.28.0",
"@esbuild/android-arm": "0.28.0",
"@esbuild/android-arm64": "0.28.0",
"@esbuild/android-x64": "0.28.0",
"@esbuild/darwin-arm64": "0.28.0",
"@esbuild/darwin-x64": "0.28.0",
"@esbuild/freebsd-arm64": "0.28.0",
"@esbuild/freebsd-x64": "0.28.0",
"@esbuild/linux-arm": "0.28.0",
"@esbuild/linux-arm64": "0.28.0",
"@esbuild/linux-ia32": "0.28.0",
"@esbuild/linux-loong64": "0.28.0",
"@esbuild/linux-mips64el": "0.28.0",
"@esbuild/linux-ppc64": "0.28.0",
"@esbuild/linux-riscv64": "0.28.0",
"@esbuild/linux-s390x": "0.28.0",
"@esbuild/linux-x64": "0.28.0",
"@esbuild/netbsd-arm64": "0.28.0",
"@esbuild/netbsd-x64": "0.28.0",
"@esbuild/openbsd-arm64": "0.28.0",
"@esbuild/openbsd-x64": "0.28.0",
"@esbuild/openharmony-arm64": "0.28.0",
"@esbuild/sunos-x64": "0.28.0",
"@esbuild/win32-arm64": "0.28.0",
"@esbuild/win32-ia32": "0.28.0",
"@esbuild/win32-x64": "0.28.0"
}
},
"node_modules/type-check": { "node_modules/type-check": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://npm.apple.com/type-check/-/type-check-0.4.0.tgz", "resolved": "https://npm.apple.com/type-check/-/type-check-0.4.0.tgz",
@@ -9004,6 +9486,7 @@
"resolved": "https://npm.apple.com/vite/-/vite-7.3.5.tgz", "resolved": "https://npm.apple.com/vite/-/vite-7.3.5.tgz",
"integrity": "sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==", "integrity": "sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==",
"dev": true, "dev": true,
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.27.0", "esbuild": "^0.27.0",
"fdir": "^6.5.0", "fdir": "^6.5.0",

View File

@@ -12,7 +12,9 @@
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"db:generate": "drizzle-kit generate", "db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate", "db:migrate": "tsx scripts/migrate.ts",
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio",
"db:check": "drizzle-kit check" "db:check": "drizzle-kit check"
}, },
"dependencies": { "dependencies": {
@@ -47,6 +49,7 @@
"prettier": "^3.5.2", "prettier": "^3.5.2",
"prettier-plugin-tailwindcss": "^0.6.11", "prettier-plugin-tailwindcss": "^0.6.11",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"tsx": "^4.19.2",
"typescript": "^5.7.3", "typescript": "^5.7.3",
"vitest": "^3.0.7" "vitest": "^3.0.7"
} }

33
scripts/migrate.ts Normal file
View File

@@ -0,0 +1,33 @@
import { drizzle } from "drizzle-orm/node-postgres";
import { migrate } from "drizzle-orm/node-postgres/migrator";
import { Pool } from "pg";
/**
* Programmatischer Migrations-Runner.
*
* Verwendet `drizzle-orm/node-postgres/migrator` mit dem Drizzle-Journal,
* sodass mehrfaches Ausführen idempotent ist (bereits angewandte Migrationen
* werden übersprungen). Liest `DATABASE_URL` direkt aus der Umgebung, um nicht
* von der Next.js-Env-Validierung abhängig zu sein.
*/
async function main(): Promise<void> {
const connectionString = process.env.DATABASE_URL;
if (!connectionString) {
throw new Error("DATABASE_URL ist nicht gesetzt.");
}
const pool = new Pool({ connectionString, max: 1 });
const db = drizzle(pool);
try {
await migrate(db, { migrationsFolder: "./drizzle" });
console.log("Migrationen erfolgreich angewandt.");
} finally {
await pool.end();
}
}
main().catch((err: unknown) => {
console.error("Migration fehlgeschlagen:", err);
process.exit(1);
});

View File

@@ -18,7 +18,7 @@ function createPool(): Pool {
return new Pool({ connectionString: env.DATABASE_URL }); return new Pool({ connectionString: env.DATABASE_URL });
} }
const pool = export const pool =
env.NODE_ENV === "production" env.NODE_ENV === "production"
? createPool() ? createPool()
: (globalThis.__floriannetzPool ??= createPool()); : (globalThis.__floriannetzPool ??= createPool());

4
src/db/index.ts Normal file
View File

@@ -0,0 +1,4 @@
// Kanonischer DB-Einstiegspunkt (Workstream 2).
// Re-exportiert den Pool-/Drizzle-Singleton sowie das gesamte Schema.
export { db, pool, type DB } from "./client";
export * as schema from "./schema";

View File

@@ -0,0 +1,158 @@
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { drizzle } from "drizzle-orm/node-postgres";
import { migrate } from "drizzle-orm/node-postgres/migrator";
import { sql } from "drizzle-orm";
import { Pool } from "pg";
import * as schema from "@/db/schema";
/**
* Live-Datenbank-Tests (Postgres erforderlich).
*
* Diese Tests werden ÜBERSPRUNGEN, wenn keine erreichbare Datenbank vorhanden
* ist (z. B. wenn DATABASE_URL fehlt oder der Connect fehlschlägt). Bei
* vorhandener DB prüfen sie Enums, Indizes, den partiellen Unique-Index, den
* EAV-Round-Trip und FK-Verletzungen anhand echter SQL-Ausführung.
*/
const url = process.env.DATABASE_URL;
let pool: Pool | undefined;
let db: ReturnType<typeof drizzle> | undefined;
let dbReachable = false;
beforeAll(async () => {
if (!url) return;
try {
pool = new Pool({ connectionString: url, max: 1, connectionTimeoutMillis: 2000 });
const client = await pool.connect();
client.release();
db = drizzle(pool, { schema });
await migrate(db, { migrationsFolder: "./drizzle" });
dbReachable = true;
} catch {
dbReachable = false;
if (pool) await pool.end().catch(() => {});
pool = undefined;
}
});
afterAll(async () => {
if (pool) await pool.end().catch(() => {});
});
const dbIt: typeof it = ((...args: Parameters<typeof it>) =>
(dbReachable ? it : it.skip)(...args)) as typeof it;
describe("Live-DB: Schema", () => {
dbIt("asset_status enthält die drei Werte", async () => {
const res = await db!.execute(
sql`SELECT enum_range(NULL::asset_status)::text AS r`,
);
const r = (res.rows[0] as { r: string }).r;
for (const v of ["einsatzbereit", "wartung", "ausser_dienst"]) {
expect(r).toContain(v);
}
});
dbIt("alle vier mv_*-Indizes existieren", async () => {
const res = await db!.execute(
sql`SELECT indexname FROM pg_indexes WHERE tablename = 'merkmal_values'`,
);
const names = new Set(res.rows.map((r) => (r as { indexname: string }).indexname));
for (const idx of [
"mv_merkmal_num_idx",
"mv_merkmal_bool_idx",
"mv_merkmal_text_idx",
"mv_entity_idx",
]) {
expect(names).toContain(idx);
}
});
dbIt("merkmale_active_name_uq ist partiell (WHERE status='active')", async () => {
const res = await db!.execute(
sql`SELECT indexdef FROM pg_indexes WHERE indexname = 'merkmale_active_name_uq'`,
);
expect(res.rows.length).toBe(1);
const def = (res.rows[0] as { indexdef: string }).indexdef.toLowerCase();
expect(def).toContain("unique");
expect(def).toContain("where");
expect(def).toContain("active");
});
dbIt("login_attempts_key_zeit_idx existiert", async () => {
const res = await db!.execute(
sql`SELECT 1 FROM pg_indexes WHERE indexname = 'login_attempts_key_zeit_idx'`,
);
expect(res.rows.length).toBe(1);
});
dbIt(
"zwei active-Merkmale gleichen Namens scheitern, zwei proposed gelingen",
async () => {
const base = `roundtrip_${Date.now()}`;
// zwei proposed gleichen Namens -> erlaubt
await db!.insert(schema.merkmale).values([
{ slug: `${base}_p1`, name: `${base}_dup`, typ: "text", geltungsbereich: "both", status: "proposed" },
{ slug: `${base}_p2`, name: `${base}_dup`, typ: "text", geltungsbereich: "both", status: "proposed" },
]);
// erstes active -> ok
await db!.insert(schema.merkmale).values({
slug: `${base}_a1`, name: `${base}_act`, typ: "text", geltungsbereich: "both", status: "active",
});
// zweites active gleichen Namens -> Verletzung
await expect(
db!.insert(schema.merkmale).values({
slug: `${base}_a2`, name: `${base}_act`, typ: "text", geltungsbereich: "both", status: "active",
}),
).rejects.toThrow();
},
);
dbIt("EAV-Round-Trip: Brigade->Merkmal->Vehicle->value_num=2000", async () => {
const stamp = Date.now();
const [brigade] = await db!
.insert(schema.brigades)
.values({ name: `RT-Wehr-${stamp}` })
.returning();
const [merkmal] = await db!
.insert(schema.merkmale)
.values({
slug: `rt_tank_${stamp}`,
name: `RT Tankinhalt ${stamp}`,
typ: "number",
einheit: "l",
geltungsbereich: "vehicle",
status: "active",
})
.returning();
const [vehicle] = await db!
.insert(schema.vehicles)
.values({ brigadeId: brigade!.id, name: `RT-TLFA-${stamp}`, funkrufname: "Florian RT 1" })
.returning();
await db!.insert(schema.merkmalValues).values({
merkmalId: merkmal!.id,
entityTyp: "vehicle",
entityId: vehicle!.id,
valueNum: 2000,
});
const res = await db!.execute(
sql`SELECT value_num FROM merkmal_values WHERE entity_id = ${vehicle!.id}`,
);
expect(Number((res.rows[0] as { value_num: number }).value_num)).toBe(2000);
});
dbIt("FK-Verletzung wirft 23503", async () => {
let code: string | undefined;
try {
await db!.insert(schema.merkmalValues).values({
merkmalId: "00000000-0000-0000-0000-000000000000",
entityTyp: "vehicle",
entityId: "00000000-0000-0000-0000-000000000000",
valueNum: 1,
});
} catch (e) {
code = (e as { code?: string }).code;
}
expect(code).toBe("23503");
});
});

View File

@@ -0,0 +1,77 @@
import { describe, it, expect } from "vitest";
import { readFileSync, readdirSync } from "node:fs";
import { join } from "node:path";
/**
* Statische Migrations-Tests (laufen ohne Datenbank).
* Verifizieren, dass die EINZIGE initiale Migration die geforderten Strukturen
* und die Idempotenz-Absicherungen (DO-Block, IF NOT EXISTS, partieller Index)
* enthält.
*/
const drizzleDir = join(process.cwd(), "drizzle");
function migrationSql(): string {
const files = readdirSync(drizzleDir).filter((f) => f.endsWith(".sql"));
expect(files.length).toBe(1); // genau EINE initiale Migration (alleiniger Eigentümer)
return readFileSync(join(drizzleDir, files[0]!), "utf8");
}
describe("Initiale Migration", () => {
const sql = migrationSql();
it("erzeugt merkmal_values und alle 14 Tabellen idempotent", () => {
expect(sql).toContain('CREATE TABLE IF NOT EXISTS "merkmal_values"');
const tableCount = (sql.match(/CREATE TABLE IF NOT EXISTS/g) ?? []).length;
expect(tableCount).toBe(14);
});
it("definiert 7 Enums in idempotenten DO-Blöcken", () => {
for (const t of [
"asset_status",
"auth_typ",
"entity_typ",
"geltungsbereich",
"merkmal_status",
"merkmal_typ",
"role",
]) {
expect(sql).toContain(`CREATE TYPE "public"."${t}"`);
}
const enumDoBlocks = (
sql.match(/DO \$\$ BEGIN CREATE TYPE/g) ?? []
).length;
expect(enumDoBlocks).toBe(7);
});
it("hat die vier merkmal_values-Indizes (IF NOT EXISTS)", () => {
for (const idx of [
"mv_merkmal_num_idx",
"mv_merkmal_bool_idx",
"mv_merkmal_text_idx",
"mv_entity_idx",
]) {
expect(sql).toContain(`CREATE INDEX IF NOT EXISTS "${idx}"`);
}
});
it("enthält den partiellen Unique-Index merkmale_active_name_uq WHERE status='active'", () => {
expect(sql).toMatch(
/CREATE UNIQUE INDEX IF NOT EXISTS "merkmale_active_name_uq" ON "merkmale"[^;]*WHERE[^;]*=\s*'active'/,
);
});
it("enthält brigades_latlng_idx und login_attempts_key_zeit_idx", () => {
expect(sql).toContain('CREATE INDEX IF NOT EXISTS "brigades_latlng_idx"');
expect(sql).toContain(
'CREATE INDEX IF NOT EXISTS "login_attempts_key_zeit_idx"',
);
});
it("sichert Fremdschlüssel idempotent über DO-Blöcke ab", () => {
const fkDoBlocks = (
sql.match(/DO \$\$ BEGIN ALTER TABLE/g) ?? []
).length;
expect(fkDoBlocks).toBe(15);
});
});

View File

@@ -0,0 +1,236 @@
import { describe, it, expect } from "vitest";
import { getTableConfig } from "drizzle-orm/pg-core";
import * as schema from "@/db/schema";
/**
* Statische Schema-Tests (laufen ohne laufende Datenbank).
*
* Diese Tests prüfen die Drizzle-Schema-Definition strukturell:
* Tabellen, Spalten, Enums, Indizes, Uniques, Fremdschlüssel.
* Der EAV-Round-Trip und der partielle Unique-Index werden zusätzlich
* in der Migrations-SQL (migration.test.ts) bzw. gegen eine echte DB
* (db-roundtrip.test.ts) verifiziert.
*/
describe("Enums", () => {
it("definiert role mit den drei Rollen und DB-Name 'role'", () => {
expect(schema.roleEnum.enumName).toBe("role");
expect(schema.roleEnum.enumValues).toEqual([
"platform_admin",
"wehr_admin",
"wehr_read",
]);
});
it("definiert auth_typ", () => {
expect(schema.authTypEnum.enumName).toBe("auth_typ");
expect(schema.authTypEnum.enumValues).toEqual(["authentik", "local"]);
});
it("definiert asset_status mit allen drei Werten", () => {
expect(schema.assetStatusEnum.enumName).toBe("asset_status");
expect(schema.assetStatusEnum.enumValues).toEqual([
"einsatzbereit",
"wartung",
"ausser_dienst",
]);
});
it("definiert merkmal_typ, merkmal_status, geltungsbereich, entity_typ", () => {
expect(schema.merkmalTypEnum.enumName).toBe("merkmal_typ");
expect(schema.merkmalTypEnum.enumValues).toEqual([
"number",
"enum",
"boolean",
"text",
]);
expect(schema.merkmalStatusEnum.enumName).toBe("merkmal_status");
expect(schema.merkmalStatusEnum.enumValues).toEqual(["active", "proposed"]);
expect(schema.geltungsbereichEnum.enumName).toBe("geltungsbereich");
expect(schema.geltungsbereichEnum.enumValues).toEqual([
"vehicle",
"equipment",
"both",
]);
expect(schema.entityTypEnum.enumName).toBe("entity_typ");
expect(schema.entityTypEnum.enumValues).toEqual(["vehicle", "equipment"]);
});
it("hat genau 7 Enums", () => {
const enumExports = [
schema.roleEnum,
schema.authTypEnum,
schema.merkmalTypEnum,
schema.merkmalStatusEnum,
schema.geltungsbereichEnum,
schema.assetStatusEnum,
schema.entityTypEnum,
];
const names = new Set(enumExports.map((e) => e.enumName));
expect(names.size).toBe(7);
expect(names).toEqual(
new Set([
"role",
"auth_typ",
"merkmal_typ",
"merkmal_status",
"geltungsbereich",
"asset_status",
"entity_typ",
]),
);
});
});
describe("merkmal_values (EAV)", () => {
it("hat die vier geforderten Indizes", () => {
const cfg = getTableConfig(schema.merkmalValues);
const idxNames = new Set(cfg.indexes.map((i) => i.config.name));
expect(idxNames).toContain("mv_merkmal_num_idx");
expect(idxNames).toContain("mv_merkmal_bool_idx");
expect(idxNames).toContain("mv_merkmal_text_idx");
expect(idxNames).toContain("mv_entity_idx");
});
it("hat typisierte Wert-Spalten und polymorphe entity-Spalten", () => {
const cfg = getTableConfig(schema.merkmalValues);
const cols = new Set(cfg.columns.map((c) => c.name));
for (const c of [
"merkmal_id",
"entity_typ",
"entity_id",
"value_num",
"value_text",
"value_bool",
]) {
expect(cols).toContain(c);
}
});
});
describe("users", () => {
it("hat funkrufname NICHT (Funkrufname lebt auf vehicles)", () => {
const cfg = getTableConfig(schema.users);
const cols = new Set(cfg.columns.map((c) => c.name));
expect(cols.has("funkrufname")).toBe(false);
});
it("hat Unique auf email und nullbares brigade_id + passwort_hash", () => {
const cfg = getTableConfig(schema.users);
const uqNames = new Set(cfg.uniqueConstraints.map((u) => u.name));
expect(uqNames).toContain("users_email_uq");
const brigade = cfg.columns.find((c) => c.name === "brigade_id");
const hash = cfg.columns.find((c) => c.name === "passwort_hash");
expect(brigade?.notNull).toBe(false);
expect(hash?.notNull).toBe(false);
});
});
describe("vehicles", () => {
it("hat funkrufname als Spalte und status-Enum", () => {
const cfg = getTableConfig(schema.vehicles);
const cols = cfg.columns;
const names = new Set(cols.map((c) => c.name));
expect(names).toContain("funkrufname");
const status = cols.find((c) => c.name === "status");
expect(status?.enumValues).toEqual([
"einsatzbereit",
"wartung",
"ausser_dienst",
]);
});
});
describe("merkmale", () => {
it("hat slug NOT NULL UNIQUE", () => {
const cfg = getTableConfig(schema.merkmale);
const slug = cfg.columns.find((c) => c.name === "slug");
expect(slug?.notNull).toBe(true);
const uqNames = new Set(cfg.uniqueConstraints.map((u) => u.name));
expect(uqNames).toContain("merkmale_slug_uq");
});
});
describe("vehicle_template_aliasse", () => {
it("hat bestaetigt-Flag und Unique(template_id, alias) — kein jsonb", () => {
const cfg = getTableConfig(schema.vehicleTemplateAliasse);
const cols = cfg.columns;
const names = new Set(cols.map((c) => c.name));
expect(names).toContain("bestaetigt");
expect(names).toContain("alias");
// kein jsonb auf der Tabelle
expect(cols.some((c) => c.dataType === "json")).toBe(false);
const uqNames = new Set(cfg.uniqueConstraints.map((u) => u.name));
expect(uqNames).toContain("vehicle_template_aliasse_uq");
});
});
describe("vehicle_templates", () => {
it("hat Unique auf code", () => {
const cfg = getTableConfig(schema.vehicleTemplates);
const uqNames = new Set(cfg.uniqueConstraints.map((u) => u.name));
expect(uqNames).toContain("vehicle_templates_code_uq");
});
});
describe("vehicle_template_merkmale", () => {
it("hat drei typisierte Vorgabewert-Spalten", () => {
const cfg = getTableConfig(schema.vehicleTemplateMerkmale);
const names = new Set(cfg.columns.map((c) => c.name));
expect(names).toContain("vorgabewert_num");
expect(names).toContain("vorgabewert_text");
expect(names).toContain("vorgabewert_bool");
});
});
describe("brigades", () => {
it("hat lat/lng, geocode-Felder, wehrfuehrer (ASCII) und bundesland-Default", () => {
const cfg = getTableConfig(schema.brigades);
const names = new Set(cfg.columns.map((c) => c.name));
for (const c of [
"lat",
"lng",
"geocode_query",
"geocoded_at",
"geocode_status",
"wehrfuehrer",
"funkrufname_schema",
"aktiv",
"bundesland",
]) {
expect(names).toContain(c);
}
});
});
describe("login_attempts & audit_log", () => {
it("login_attempts hat key/erfolg/zeitpunkt + Index", () => {
const cfg = getTableConfig(schema.loginAttempts);
const names = new Set(cfg.columns.map((c) => c.name));
for (const c of ["key", "erfolg", "zeitpunkt"]) {
expect(names).toContain(c);
}
const idxNames = new Set(cfg.indexes.map((i) => i.config.name));
expect(idxNames).toContain("login_attempts_key_zeit_idx");
});
it("audit_log hat details jsonb und Indizes", () => {
const cfg = getTableConfig(schema.auditLog);
const names = new Set(cfg.columns.map((c) => c.name));
for (const c of ["actor_user_id", "aktion", "ziel_typ", "ziel_id", "details", "zeitpunkt"]) {
expect(names).toContain(c);
}
const details = cfg.columns.find((c) => c.name === "details");
expect(details?.dataType).toBe("json");
});
});
describe("equipment", () => {
it("hat nullbares vehicle_id (= im Gerätehaus) und category_id", () => {
const cfg = getTableConfig(schema.equipment);
const names = new Set(cfg.columns.map((c) => c.name));
expect(names).toContain("category_id");
const vehicleId = cfg.columns.find((c) => c.name === "vehicle_id");
expect(vehicleId?.notNull).toBe(false);
});
});

68
src/db/schema/assets.ts Normal file
View File

@@ -0,0 +1,68 @@
import {
pgTable,
uuid,
text,
timestamp,
index,
} from "drizzle-orm/pg-core";
import { brigades } from "./brigades";
import { vehicleTemplates } from "./templates";
import { equipmentCategories } from "./equipment-categories";
import { assetStatusEnum } from "./enums";
/**
* Fahrzeuge. `templateId` -> set null (Eigenbau). `funkrufname` ist SPALTE
* (kein Merkmal). `status` über asset_status-Enum.
*/
export const vehicles = pgTable(
"vehicles",
{
id: uuid("id").primaryKey().defaultRandom(),
brigadeId: uuid("brigade_id")
.notNull()
.references(() => brigades.id, { onDelete: "cascade" }),
templateId: uuid("template_id").references(() => vehicleTemplates.id, {
onDelete: "set null",
}),
name: text("name").notNull(),
funkrufname: text("funkrufname"),
status: assetStatusEnum("status").notNull().default("einsatzbereit"),
notiz: text("notiz"),
erstelltAm: timestamp("erstellt_am", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => ({
brigadeIdx: index("vehicles_brigade_idx").on(t.brigadeId),
templateIdx: index("vehicles_template_idx").on(t.templateId),
}),
);
/**
* Geräte/Beladung. `vehicleId = NULL` => im Gerätehaus.
*/
export const equipment = pgTable(
"equipment",
{
id: uuid("id").primaryKey().defaultRandom(),
brigadeId: uuid("brigade_id")
.notNull()
.references(() => brigades.id, { onDelete: "cascade" }),
categoryId: uuid("category_id")
.notNull()
.references(() => equipmentCategories.id, { onDelete: "restrict" }),
vehicleId: uuid("vehicle_id").references(() => vehicles.id, {
onDelete: "set null",
}),
name: text("name").notNull(),
status: assetStatusEnum("status").notNull().default("einsatzbereit"),
erstelltAm: timestamp("erstellt_am", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => ({
brigadeIdx: index("equipment_brigade_idx").on(t.brigadeId),
categoryIdx: index("equipment_category_idx").on(t.categoryId),
vehicleIdx: index("equipment_vehicle_idx").on(t.vehicleId),
}),
);

34
src/db/schema/audit.ts Normal file
View File

@@ -0,0 +1,34 @@
import {
pgTable,
uuid,
text,
jsonb,
timestamp,
index,
} from "drizzle-orm/pg-core";
import { users } from "./users";
/**
* Audit-Log für Admin-/Bereitstellungsaktionen. `actorUserId` -> set null,
* damit Historie auch nach Benutzerlöschung erhalten bleibt.
*/
export const auditLog = pgTable(
"audit_log",
{
id: uuid("id").primaryKey().defaultRandom(),
actorUserId: uuid("actor_user_id").references(() => users.id, {
onDelete: "set null",
}),
aktion: text("aktion").notNull(),
zielTyp: text("ziel_typ"),
zielId: uuid("ziel_id"),
details: jsonb("details"),
zeitpunkt: timestamp("zeitpunkt", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => ({
zeitpunktIdx: index("audit_log_zeitpunkt_idx").on(t.zeitpunkt),
aktionIdx: index("audit_log_aktion_idx").on(t.aktion),
}),
);

View File

@@ -0,0 +1,28 @@
import {
pgTable,
uuid,
text,
boolean,
timestamp,
index,
} from "drizzle-orm/pg-core";
/**
* Login-Versuche für Rate-Limiting. `key` ist i. d. R. eine Kombination aus
* E-Mail und/oder IP. Der Index auf (key, zeitpunkt) ermöglicht effizientes
* Zählen jüngster Fehlversuche.
*/
export const loginAttempts = pgTable(
"login_attempts",
{
id: uuid("id").primaryKey().defaultRandom(),
key: text("key").notNull(),
erfolg: boolean("erfolg").notNull(),
zeitpunkt: timestamp("zeitpunkt", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => ({
keyZeitIdx: index("login_attempts_key_zeit_idx").on(t.key, t.zeitpunkt),
}),
);

42
src/db/schema/brigades.ts Normal file
View File

@@ -0,0 +1,42 @@
import {
pgTable,
uuid,
text,
boolean,
doublePrecision,
timestamp,
index,
} from "drizzle-orm/pg-core";
/**
* Feuerwehren. `lat/lng` werden aus der Adresse geokodiert (Geo-Workstream).
* `wehrfuehrer` bewusst ASCII (keine Umlaute in JS-Property/DB-Spalte).
*/
export const brigades = pgTable(
"brigades",
{
id: uuid("id").primaryKey().defaultRandom(),
name: text("name").notNull(),
art: text("art").notNull().default("FF"),
strasse: text("strasse"),
plz: text("plz"),
ort: text("ort"),
bundesland: text("bundesland").notNull().default("Niederösterreich"),
lat: doublePrecision("lat"),
lng: doublePrecision("lng"),
geocodeQuery: text("geocode_query"),
geocodedAt: timestamp("geocoded_at", { withTimezone: true }),
geocodeStatus: text("geocode_status"),
funkrufnameSchema: text("funkrufname_schema"),
wehrfuehrer: text("wehrfuehrer"),
telefon: text("telefon"),
email: text("email"),
aktiv: boolean("aktiv").notNull().default(true),
erstelltAm: timestamp("erstellt_am", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => ({
latlngIdx: index("brigades_latlng_idx").on(t.lat, t.lng),
}),
);

36
src/db/schema/enums.ts Normal file
View File

@@ -0,0 +1,36 @@
import { pgEnum } from "drizzle-orm/pg-core";
// Hinweis: Der erste Parameter ist der DB-Enum-Name (snake_case), das
// exportierte Drizzle-Property kann abweichend benannt sein. So existiert je
// fachlichem Enum genau EIN DB-Typ.
export const roleEnum = pgEnum("role", [
"platform_admin",
"wehr_admin",
"wehr_read",
]);
export const authTypEnum = pgEnum("auth_typ", ["authentik", "local"]);
export const merkmalTypEnum = pgEnum("merkmal_typ", [
"number",
"enum",
"boolean",
"text",
]);
export const merkmalStatusEnum = pgEnum("merkmal_status", ["active", "proposed"]);
export const geltungsbereichEnum = pgEnum("geltungsbereich", [
"vehicle",
"equipment",
"both",
]);
export const assetStatusEnum = pgEnum("asset_status", [
"einsatzbereit",
"wartung",
"ausser_dienst",
]);
export const entityTypEnum = pgEnum("entity_typ", ["vehicle", "equipment"]);

View File

@@ -0,0 +1,37 @@
import {
pgTable,
uuid,
text,
integer,
unique,
index,
primaryKey,
} from "drizzle-orm/pg-core";
import { merkmale } from "./merkmale";
export const equipmentCategories = pgTable(
"equipment_categories",
{
id: uuid("id").primaryKey().defaultRandom(),
name: text("name").notNull(),
reihenfolge: integer("reihenfolge").notNull().default(0),
},
(t) => ({ nameUq: unique("equipment_categories_name_uq").on(t.name) }),
);
export const equipmentCategoryMerkmale = pgTable(
"equipment_category_merkmale",
{
categoryId: uuid("category_id")
.notNull()
.references(() => equipmentCategories.id, { onDelete: "cascade" }),
merkmalId: uuid("merkmal_id")
.notNull()
.references(() => merkmale.id, { onDelete: "cascade" }),
reihenfolge: integer("reihenfolge").notNull().default(0),
},
(t) => ({
pk: primaryKey({ columns: [t.categoryId, t.merkmalId] }),
merkmalIdx: index("ecm_merkmal_idx").on(t.merkmalId),
}),
);

View File

@@ -1,5 +1,13 @@
// Barrel für das Datenbankschema. // Barrel für das gesamte Datenbankschema (Workstream 2 — alleiniger
// Wird vom Datenbank-Workstream (Workstream 2) mit Tabellen, Enums und Indizes // Schema-Eigentümer). Feature-Workstreams IMPORTIEREN nur von hier.
// gefüllt. Dieser Workstream (Fundament) definiert bewusst KEINE fachlichen export * from "./enums";
// Tabellen und ist NICHT Migrations-Eigentümer. export * from "./brigades";
export {}; export * from "./users";
export * from "./merkmale";
export * from "./templates";
export * from "./equipment-categories";
export * from "./assets";
export * from "./merkmal-values";
export * from "./auth-rate-limit";
export * from "./audit";
export * from "./relations";

View File

@@ -0,0 +1,36 @@
import {
pgTable,
uuid,
text,
boolean,
doublePrecision,
index,
} from "drizzle-orm/pg-core";
import { merkmale } from "./merkmale";
import { entityTypEnum } from "./enums";
/**
* EAV-Wert-Tabelle mit typisierten Spalten. `entityId` ist polymorph
* (vehicles.id ODER equipment.id, je nach entityTyp). Die vier geforderten
* Indizes ermöglichen Range-/Bool-/Text-Filter je Merkmal sowie Entity-Lookup.
*/
export const merkmalValues = pgTable(
"merkmal_values",
{
id: uuid("id").primaryKey().defaultRandom(),
merkmalId: uuid("merkmal_id")
.notNull()
.references(() => merkmale.id, { onDelete: "cascade" }),
entityTyp: entityTypEnum("entity_typ").notNull(),
entityId: uuid("entity_id").notNull(),
valueNum: doublePrecision("value_num"),
valueText: text("value_text"),
valueBool: boolean("value_bool"),
},
(t) => ({
idxNum: index("mv_merkmal_num_idx").on(t.merkmalId, t.valueNum),
idxBool: index("mv_merkmal_bool_idx").on(t.merkmalId, t.valueBool),
idxText: index("mv_merkmal_text_idx").on(t.merkmalId, t.valueText),
idxEntity: index("mv_entity_idx").on(t.entityTyp, t.entityId),
}),
);

63
src/db/schema/merkmale.ts Normal file
View File

@@ -0,0 +1,63 @@
import {
pgTable,
uuid,
text,
integer,
timestamp,
unique,
uniqueIndex,
index,
} from "drizzle-orm/pg-core";
import { sql } from "drizzle-orm";
import { brigades } from "./brigades";
import { merkmalTypEnum, merkmalStatusEnum, geltungsbereichEnum } from "./enums";
/**
* Merkmal-Katalog (Attributdefinitionen). `slug` ist der Idempotenz-Key für Seeds.
* `merkmale_active_name_uq` ist ein PARTIELLER Unique-Index auf (name)
* WHERE status='active' — zwei aktive Merkmale gleichen Namens sind verboten,
* mehrere `proposed` mit gleichem Namen sind erlaubt.
*/
export const merkmale = pgTable(
"merkmale",
{
id: uuid("id").primaryKey().defaultRandom(),
slug: text("slug").notNull(),
name: text("name").notNull(),
typ: merkmalTypEnum("typ").notNull(),
einheit: text("einheit"),
geltungsbereich: geltungsbereichEnum("geltungsbereich").notNull(),
status: merkmalStatusEnum("status").notNull().default("proposed"),
vorgeschlagenVonBrigadeId: uuid("vorgeschlagen_von_brigade_id").references(
() => brigades.id,
{ onDelete: "set null" },
),
erstelltAm: timestamp("erstellt_am", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => ({
slugUq: unique("merkmale_slug_uq").on(t.slug),
byStatus: index("merkmale_status_idx").on(t.status),
activeNameUq: uniqueIndex("merkmale_active_name_uq")
.on(t.name)
.where(sql`${t.status} = 'active'`),
}),
);
export const merkmalOptionen = pgTable(
"merkmal_optionen",
{
id: uuid("id").primaryKey().defaultRandom(),
merkmalId: uuid("merkmal_id")
.notNull()
.references(() => merkmale.id, { onDelete: "cascade" }),
wert: text("wert").notNull(),
label: text("label").notNull(),
reihenfolge: integer("reihenfolge").notNull().default(0),
},
(t) => ({
wertUq: unique("merkmal_optionen_uq").on(t.merkmalId, t.wert),
merkmalIdx: index("merkmal_optionen_merkmal_idx").on(t.merkmalId),
}),
);

143
src/db/schema/relations.ts Normal file
View File

@@ -0,0 +1,143 @@
import { relations } from "drizzle-orm";
import { brigades } from "./brigades";
import { users } from "./users";
import { merkmale, merkmalOptionen } from "./merkmale";
import {
vehicleTemplates,
vehicleTemplateMerkmale,
vehicleTemplateAliasse,
} from "./templates";
import {
equipmentCategories,
equipmentCategoryMerkmale,
} from "./equipment-categories";
import { vehicles, equipment } from "./assets";
import { merkmalValues } from "./merkmal-values";
import { auditLog } from "./audit";
export const brigadesRelations = relations(brigades, ({ many }) => ({
users: many(users),
vehicles: many(vehicles),
equipment: many(equipment),
}));
export const usersRelations = relations(users, ({ one }) => ({
brigade: one(brigades, {
fields: [users.brigadeId],
references: [brigades.id],
}),
}));
export const merkmaleRelations = relations(merkmale, ({ many, one }) => ({
optionen: many(merkmalOptionen),
values: many(merkmalValues),
templateMerkmale: many(vehicleTemplateMerkmale),
categoryMerkmale: many(equipmentCategoryMerkmale),
vorgeschlagenVonBrigade: one(brigades, {
fields: [merkmale.vorgeschlagenVonBrigadeId],
references: [brigades.id],
}),
}));
export const merkmalOptionenRelations = relations(merkmalOptionen, ({ one }) => ({
merkmal: one(merkmale, {
fields: [merkmalOptionen.merkmalId],
references: [merkmale.id],
}),
}));
export const vehicleTemplatesRelations = relations(
vehicleTemplates,
({ many }) => ({
merkmale: many(vehicleTemplateMerkmale),
aliasse: many(vehicleTemplateAliasse),
vehicles: many(vehicles),
}),
);
export const vehicleTemplateMerkmaleRelations = relations(
vehicleTemplateMerkmale,
({ one }) => ({
template: one(vehicleTemplates, {
fields: [vehicleTemplateMerkmale.templateId],
references: [vehicleTemplates.id],
}),
merkmal: one(merkmale, {
fields: [vehicleTemplateMerkmale.merkmalId],
references: [merkmale.id],
}),
}),
);
export const vehicleTemplateAliasseRelations = relations(
vehicleTemplateAliasse,
({ one }) => ({
template: one(vehicleTemplates, {
fields: [vehicleTemplateAliasse.templateId],
references: [vehicleTemplates.id],
}),
}),
);
export const equipmentCategoriesRelations = relations(
equipmentCategories,
({ many }) => ({
merkmale: many(equipmentCategoryMerkmale),
equipment: many(equipment),
}),
);
export const equipmentCategoryMerkmaleRelations = relations(
equipmentCategoryMerkmale,
({ one }) => ({
category: one(equipmentCategories, {
fields: [equipmentCategoryMerkmale.categoryId],
references: [equipmentCategories.id],
}),
merkmal: one(merkmale, {
fields: [equipmentCategoryMerkmale.merkmalId],
references: [merkmale.id],
}),
}),
);
export const vehiclesRelations = relations(vehicles, ({ one, many }) => ({
brigade: one(brigades, {
fields: [vehicles.brigadeId],
references: [brigades.id],
}),
template: one(vehicleTemplates, {
fields: [vehicles.templateId],
references: [vehicleTemplates.id],
}),
equipment: many(equipment),
}));
export const equipmentRelations = relations(equipment, ({ one }) => ({
brigade: one(brigades, {
fields: [equipment.brigadeId],
references: [brigades.id],
}),
category: one(equipmentCategories, {
fields: [equipment.categoryId],
references: [equipmentCategories.id],
}),
vehicle: one(vehicles, {
fields: [equipment.vehicleId],
references: [vehicles.id],
}),
}));
export const merkmalValuesRelations = relations(merkmalValues, ({ one }) => ({
merkmal: one(merkmale, {
fields: [merkmalValues.merkmalId],
references: [merkmale.id],
}),
}));
export const auditLogRelations = relations(auditLog, ({ one }) => ({
actor: one(users, {
fields: [auditLog.actorUserId],
references: [users.id],
}),
}));

View File

@@ -0,0 +1,71 @@
import {
pgTable,
uuid,
text,
integer,
boolean,
doublePrecision,
unique,
index,
primaryKey,
} from "drizzle-orm/pg-core";
import { merkmale } from "./merkmale";
/**
* Fahrzeug-Vorlagen (z. B. "TLFA 4000"). 11 Vorlagen lt. Plan.
* HLF 4-U ist KEINE eigene Vorlage, sondern ein bestätigter Alias auf HLF 4.
*/
export const vehicleTemplates = pgTable(
"vehicle_templates",
{
id: uuid("id").primaryKey().defaultRandom(),
code: text("code").notNull(),
name: text("name").notNull(),
beschreibung: text("beschreibung"),
reihenfolge: integer("reihenfolge").notNull().default(0),
},
(t) => ({ codeUq: unique("vehicle_templates_code_uq").on(t.code) }),
);
/**
* Vorlagen-Pflichtmerkmale mit drei typisierten Vorgabewert-Spalten.
*/
export const vehicleTemplateMerkmale = pgTable(
"vehicle_template_merkmale",
{
templateId: uuid("template_id")
.notNull()
.references(() => vehicleTemplates.id, { onDelete: "cascade" }),
merkmalId: uuid("merkmal_id")
.notNull()
.references(() => merkmale.id, { onDelete: "cascade" }),
vorgabewertNum: doublePrecision("vorgabewert_num"),
vorgabewertText: text("vorgabewert_text"),
vorgabewertBool: boolean("vorgabewert_bool"),
pflicht: boolean("pflicht").notNull().default(false),
reihenfolge: integer("reihenfolge").notNull().default(0),
},
(t) => ({
pk: primaryKey({ columns: [t.templateId, t.merkmalId] }),
merkmalIdx: index("vtm_merkmal_idx").on(t.merkmalId),
}),
);
/**
* Such-/Anzeige-Aliasse zu Vorlagen. Eigene Tabelle (kein jsonb).
* `bestaetigt` erlaubt schrittweises fachliches Freigeben.
*/
export const vehicleTemplateAliasse = pgTable(
"vehicle_template_aliasse",
{
id: uuid("id").primaryKey().defaultRandom(),
templateId: uuid("template_id")
.notNull()
.references(() => vehicleTemplates.id, { onDelete: "cascade" }),
alias: text("alias").notNull(),
bestaetigt: boolean("bestaetigt").notNull().default(false),
},
(t) => ({
uq: unique("vehicle_template_aliasse_uq").on(t.templateId, t.alias),
}),
);

27
src/db/schema/users.ts Normal file
View File

@@ -0,0 +1,27 @@
import { pgTable, uuid, text, boolean, timestamp, unique } from "drizzle-orm/pg-core";
import { brigades } from "./brigades";
import { roleEnum, authTypEnum } from "./enums";
/**
* Benutzer. Platform-Admin: `brigadeId = NULL`. Authentik-Konten: `passwortHash = NULL`.
*/
export const users = pgTable(
"users",
{
id: uuid("id").primaryKey().defaultRandom(),
brigadeId: uuid("brigade_id").references(() => brigades.id, {
onDelete: "restrict",
}),
rolle: roleEnum("rolle").notNull(),
authTyp: authTypEnum("auth_typ").notNull(),
email: text("email").notNull(),
name: text("name").notNull(),
passwortHash: text("passwort_hash"),
aktiv: boolean("aktiv").notNull().default(true),
erstelltVon: uuid("erstellt_von"),
erstelltAm: timestamp("erstellt_am", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => ({ emailUq: unique("users_email_uq").on(t.email) }),
);